chore: import original project baseline

Import the pre-repair source tree as the history baseline.
Runtime data (data/), virtualenvs, bytecode caches and logs are
gitignored so local secrets and user state stay out of the repo.
This commit is contained in:
2026-09-17 16:40:01 +08:00
commit a7412824e0
124 changed files with 26747 additions and 0 deletions
+637
View File
@@ -0,0 +1,637 @@
"""
core/agent/compaction.py
========================
🌟 pi 上下文压缩算法的 Python 1:1 移植(harness 原版算法)
对照 pi-main 源码(逐函数对应):
packages/agent/src/harness/compaction/compaction.ts
DEFAULT_COMPACTION_SETTINGS -> CompactionSettings / DEFAULT_COMPACTION_SETTINGS
shouldCompact -> 见 context.py: should_compact(同一公式)
estimateTokens / estimateContextTokens -> context.pyusage 锚定,CJK 感知见说明)
findValidCutPoints / findCutPoint -> find_valid_cut_points / find_cut_point
findTurnStartIndex -> find_turn_start
prepareCompaction -> prepare_compaction
SUMMARIZATION_SYSTEM_PROMPT -> 同名(逐字移植)
SUMMARIZATION_PROMPT -> 同名(逐字移植)
UPDATE_SUMMARIZATION_PROMPT -> 同名(逐字移植,迭代更新用)
TURN_PREFIX_SUMMARIZATION_PROMPT -> 同名(逐字移植,断轮前缀用)
generateSummaryWithUsage -> generate_summarymaxTokens = 0.8×reserve
generateTurnPrefixSummary -> generate_turn_prefix_summary0.5×reserve
compact -> compact_context(断轮双摘要 + 拼接格式 1:1)
packages/agent/src/harness/compaction/utils.ts
serializeConversation -> serialize_conversation1:1,含 2000 字符截断)
extractFileOpsFromMessage -> extract_file_ops_from_message
computeFileLists -> compute_file_lists
formatFileOperations -> format_file_operations
TOOL_RESULT_MAX_CHARS = 2000 -> 同名常量
摘要 LLM 调用由上层注入:
summarize_fn(prompt_text: str, system_prompt: str, max_tokens: int) -> str
pi 里是 models.completeSimple + retryhaocode 用 OpenAI 客户端非流式调用,
由 llm_engine.AgentWorker 实现并注入。)
已声明的偏差(仅 2 处,见 context.py 头注):
1. 单条消息 token 估算用 CJK 感知启发式(pi 是 chars/4)——对中文会话更安全
2. 摘要 LLM 调用失败时降级为机械摘录(pi 返回 CompactionError)——桌面应用优先不丢上下文
其余全部 1:1:触发公式、usage 锚定、token 预算切点、有效切点规则、断轮双摘要、
迭代式 previousSummary 更新、摘要提示词逐字、文件操作附录、拼接格式。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Callable, List, Optional, Set, Tuple
from .types import AgentMessage, ModelConfig
# ======================================================================
# 压缩设置 —— 1:1 对照 DEFAULT_COMPACTION_SETTINGS
# ======================================================================
@dataclass
class CompactionSettings:
"""pi: interface CompactionSettings { enabled; reserveTokens; keepRecentTokens }"""
enabled: bool = True
reserve_tokens: int = 16384 # pi: 16384(摘要提示词与输出预留)
keep_recent_tokens: int = 20000 # pi: 20000(压缩后保留的近期上下文预算)
DEFAULT_COMPACTION_SETTINGS = CompactionSettings()
# pi utils.ts: const TOOL_RESULT_MAX_CHARS = 2000
TOOL_RESULT_MAX_CHARS = 2000
# ======================================================================
# 摘要提示词 —— 从 pi compaction.ts 逐字移植(不得改写,摘要质量依赖它)
# ======================================================================
SUMMARIZATION_SYSTEM_PROMPT = \
"You are a context summarization assistant. Your task is to read a conversation " \
"between a user and an AI assistant, then produce a structured summary following " \
"the exact format specified.\n\n" \
"Do NOT continue the conversation. Do NOT respond to any questions in the " \
"conversation. ONLY output the structured summary."
SUMMARIZATION_PROMPT = """The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
Use this EXACT format:
## Goal
[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
## Constraints & Preferences
- [Any constraints, preferences, or requirements mentioned by user]
- [Or "(none)" if none were mentioned]
## Progress
### Done
- [x] [Completed tasks/changes]
### In Progress
- [ ] [Current work]
### Blocked
- [Issues preventing progress, if any]
## Key Decisions
- **[Decision]**: [Brief rationale]
## Next Steps
1. [Ordered list of what should happen next]
## Critical Context
- [Any data, examples, or references needed to continue]
- [Or "(none)" if not applicable]
Keep each section concise. Preserve exact file paths, function names, and error messages."""
UPDATE_SUMMARIZATION_PROMPT = """The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.
Update the existing structured summary with new information. RULES:
- PRESERVE all existing information from the previous summary
- ADD new progress, decisions, and context from the new messages
- UPDATE the Progress section: move items from "In Progress" to "Done" when completed
- UPDATE "Next Steps" based on what was accomplished
- PRESERVE exact file paths, function names, and error messages
- If something is no longer relevant, you may remove it
Use this EXACT format:
## Goal
[Preserve existing goals, add new ones if the task expanded]
## Constraints & Preferences
- [Preserve existing, add new ones discovered]
## Progress
### Done
- [x] [Include previously done items AND newly completed items]
### In Progress
- [ ] [Current work - update based on progress]
### Blocked
- [Current blockers - remove if resolved]
## Key Decisions
- **[Decision]**: [Brief rationale] (preserve all previous, add new)
## Next Steps
1. [Update based on current state]
## Critical Context
- [Preserve important context, add new if needed]
Keep each section concise. Preserve exact file paths, function names, and error messages."""
TURN_PREFIX_SUMMARIZATION_PROMPT = """This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
Summarize the prefix to provide context for the retained suffix:
## Original Request
[What did the user ask for in this turn?]
## Early Progress
- [Key decisions and work done in the prefix]
## Context for Suffix
- [Information needed to understand the retained recent work]
Be concise. Focus on what's needed to understand the kept suffix."""
# ======================================================================
# 对话序列化 —— 1:1 对照 utils.ts serializeConversation
# ======================================================================
def _safe_json(value) -> str:
try:
s = json.dumps(value, ensure_ascii=False)
return s if s is not None else "undefined"
except Exception:
return "[unserializable]"
def _content_text(content) -> str:
"""pi contentText: str 或 [{type:"text",text}] 列表取文本拼接"""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for b in content:
if isinstance(b, dict) and b.get("type") == "text":
parts.append(str(b.get("text", "")))
return "\n".join(p for p in parts if p)
return str(content)
def _truncate_for_summary(text: str, max_chars: int) -> str:
"""pi utils.ts truncateForSummary(逐字逻辑)"""
if len(text) <= max_chars:
return text
truncated = len(text) - max_chars
return f"{text[:max_chars]}\n\n[... {truncated} more characters truncated]"
def serialize_conversation(messages: List[AgentMessage]) -> str:
"""
1:1 对照 utils.ts serializeConversation(输出格式逐字一致):
[User]: ...
[Assistant thinking]: ...
[Assistant]: ...
[Assistant tool calls]: name(k=v, k2=v2); name2(...)
[Tool result]: ...(超 2000 字符截断)
"""
parts: List[str] = []
for msg in messages:
if msg.role == "user":
content = _content_text(msg.content)
if content:
parts.append(f"[User]: {content}")
elif msg.role == "assistant":
thinking_parts = []
tool_calls = []
if msg.reasoning:
thinking_parts.append(msg.reasoning)
text = _content_text(msg.content)
for tc in (msg.tool_calls or []):
args_str = ", ".join(f"{k}={_safe_json(v)}"
for k, v in (tc.arguments or {}).items())
tool_calls.append(f"{tc.name}({args_str})")
if thinking_parts:
parts.append(f"[Assistant thinking]: {chr(10).join(thinking_parts)}")
if text:
parts.append(f"[Assistant]: {text}")
if tool_calls:
parts.append(f"[Assistant tool calls]: {'; '.join(tool_calls)}")
elif msg.role == "toolResult":
content = _content_text(msg.content)
if content:
parts.append(f"[Tool result]: "
f"{_truncate_for_summary(content, TOOL_RESULT_MAX_CHARS)}")
return "\n\n".join(parts)
# ======================================================================
# 文件操作提取 —— 1:1 对照 utils.ts extractFileOps*/computeFileLists/formatFileOperations
# ======================================================================
class FileOperations:
def __init__(self):
self.read: Set[str] = set()
self.written: Set[str] = set()
self.edited: Set[str] = set()
def extract_file_ops_from_message(message: AgentMessage, file_ops: FileOperations):
"""pi: assistant 的 toolCall 参数里 path 字段 → read/write/edit 归类"""
if message.role != "assistant":
return
for tc in (message.tool_calls or []):
args = tc.arguments or {}
path = args.get("path")
if not isinstance(path, str) or not path:
continue
if tc.name == "read":
file_ops.read.add(path)
elif tc.name == "write":
file_ops.written.add(path)
elif tc.name == "edit":
file_ops.edited.add(path)
def compute_file_lists(file_ops: FileOperations) -> Tuple[List[str], List[str]]:
"""pi computeFileLists: modified = edited|writtenreadOnly = read-modified;均排序"""
modified = file_ops.edited | file_ops.written
read_only = sorted(f for f in file_ops.read if f not in modified)
return read_only, sorted(modified)
def format_file_operations(read_files: List[str], modified_files: List[str]) -> str:
"""pi formatFileOperations: <read-files>/<modified-files> 标签拼接"""
sections = []
if read_files:
sections.append("<read-files>\n" + "\n".join(read_files) + "\n</read-files>")
if modified_files:
sections.append("<modified-files>\n" + "\n".join(modified_files) + "\n</modified-files>")
if not sections:
return ""
return "\n\n" + "\n\n".join(sections)
# ======================================================================
# 切分点 —— 1:1 对照 findValidCutPoints / findTurnStartIndex / findCutPoint
# ======================================================================
@dataclass
class CutPointResult:
"""pi: interface CutPointResult"""
first_kept_index: int # 保留段首条在 compactable 列表里的下标
turn_start_index: int = -1 # 断轮时:该轮起点(user 消息)下标;否则 -1
is_split_turn: bool = False
def find_valid_cut_points(messages: List[AgentMessage],
start_index: int, end_index: int) -> List[int]:
"""
pi findValidCutPoints:消息角色为 user/assistant 的位置是有效切点
toolResult 不能做切点——它会与前面的 toolCall 分离)。
pi 里的 bashExecution/branchSummary/compactionSummary 等角色
在 haocode 消息模型中不存在,等价规则即 role in (user, assistant)。
"""
cut_points = []
for i in range(start_index, end_index):
if messages[i].role in ("user", "assistant"):
cut_points.append(i)
return cut_points
def find_turn_start(messages: List[AgentMessage], entry_index: int,
start_index: int) -> int:
"""pi findTurnStartIndex:向前找本轮起点(user 消息 / branch_summary"""
for i in range(entry_index, start_index - 1, -1):
if messages[i].role == "user":
return i
return -1
def _estimate(msg: AgentMessage) -> int:
from .context import estimate_message_tokens
return estimate_message_tokens(msg)
def find_cut_point(messages: List[AgentMessage], start_index: int,
end_index: int, keep_recent_tokens: int) -> CutPointResult:
"""
1:1 对照 pi findCutPoint
1. 从尾部向前累计 token,直到累计 >= keep_recent_tokens
2. 取该位置(含)之后的第一个有效切点
3. 切点不是 user 消息 → 断轮:找本轮起点,前缀单独摘要
(pi 的「回退跳过状态条目」循环针对 session 状态条目;
haocode 消息列表没有状态条目,等价省略。)
"""
cut_points = find_valid_cut_points(messages, start_index, end_index)
if not cut_points:
return CutPointResult(first_kept_index=start_index)
accumulated = 0
cut_index = cut_points[0]
for i in range(end_index - 1, start_index - 1, -1):
accumulated += _estimate(messages[i])
if accumulated >= keep_recent_tokens:
for c in cut_points:
if c >= i:
cut_index = c
break
break
is_user = messages[cut_index].role == "user"
turn_start = -1 if is_user else find_turn_start(messages, cut_index, start_index)
is_split = (not is_user) and turn_start != -1
return CutPointResult(first_kept_index=cut_index,
turn_start_index=turn_start,
is_split_turn=is_split)
# ======================================================================
# 压缩准备 —— 1:1 对照 prepareCompaction
# ======================================================================
@dataclass
class CompactionPreparation:
"""pi: interface CompactionPreparation"""
messages_to_summarize: List[AgentMessage] = field(default_factory=list)
turn_prefix_messages: List[AgentMessage] = field(default_factory=list)
retained_tail: List[AgentMessage] = field(default_factory=list)
is_split_turn: bool = False
tokens_before: int = 0
previous_summary: Optional[str] = None
file_ops: FileOperations = field(default_factory=FileOperations)
settings: CompactionSettings = DEFAULT_COMPACTION_SETTINGS
def _compaction_diag(line: str) -> None:
"""压缩自诊断日志(与 recovery.compact_diag_log 同一文件/规范:
print + 追加 compaction_diag.log + 失败静默)。"""
import os as _os
import time as _time
t = _time.time()
stamp = (f"[{_time.strftime('%H:%M:%S', _time.localtime(t))}"
f".{int(t * 1000) % 1000:03d}]")
print(f"{stamp} {line}", flush=True)
try:
path = _os.path.join(
_os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))),
"compaction_diag.log")
with open(path, "a", encoding="utf-8") as f:
f.write(f"{stamp} {line}\n")
except Exception:
pass
def _nothing_to_summarize_diag(messages: List[AgentMessage],
compactable: List[AgentMessage],
cut: CutPointResult,
settings: CompactionSettings) -> str:
"""prepare_compaction 返回 None(无可摘要内容)时的自诊断行:
记录分支子类型 + 关键量,下次失败可直接从日志定位原因。
子类型:
no_valid_cut_points —— 可压缩范围内没有任何 user/assistant 条目
total_below_keep_recent —— 可压缩范围总量 < keep_recent(通常意味着
上下文主体是旧摘要本身,无新内容)
cut_pinned_at_zero —— 预算从尾部累加只在 i=0 才达标,即首条
条目独占 ≥ (总量-keep_recent) 的 token
(单条超长消息/巨型工具输出主导上下文)
"""
from .context import estimate_context_tokens, estimate_message_tokens
try:
total = estimate_context_tokens(messages).tokens
n_user = sum(1 for m in compactable if m.role == "user")
n_asst = sum(1 for m in compactable if m.role == "assistant")
ests = [(estimate_message_tokens(m), i) for i, m in enumerate(compactable)]
e0 = ests[0][0] if ests else 0
rest = sum(e for e, _ in ests[1:])
top3 = sorted(ests, reverse=True)[:3]
roles = " ".join(("T" if m.role == "toolResult" else m.role[0].upper())
for m in compactable[:10])
if not find_valid_cut_points(compactable, 0, len(compactable)):
sub = "no_valid_cut_points"
elif sum(e for e, _ in ests) < settings.keep_recent_tokens:
sub = "total_below_keep_recent"
else:
sub = "cut_pinned_at_zero"
big = "; ".join(f"idx{i}={e}" for e, i in top3)
return (f"[COMPACT_NONE] branch=nothing_to_summarize sub={sub} "
f"msgs={len(messages)} est_total={total} compactable={len(compactable)} "
f"keep_recent={settings.keep_recent_tokens} "
f"first_kept={cut.first_kept_index} turn_start={cut.turn_start_index} "
f"split={cut.is_split_turn} user={n_user} asst={n_asst} "
f"est_first={e0} est_rest={rest} top3=[{big}] head_roles=[{roles}]")
except Exception as ex: # 诊断本身失败不能影响主流程
return f"[COMPACT_NONE] branch=nothing_to_summarize (diag failed: {ex})"
def prepare_compaction(messages: List[AgentMessage],
settings: Optional[CompactionSettings] = None
) -> Optional[CompactionPreparation]:
"""
1:1 对照 pi prepareCompaction
- 上一条压缩摘要(messages[0].kind == "compaction_summary")不重复摘要,
其内容作为 previousSummary 走迭代更新提示词
- 可压缩范围 = 摘要之后的全部消息(pi 里等价于「上次保留尾 + 新消息」)
- 切点在可压缩范围内选;tokens_before 按完整上下文(含摘要消息)估算
不可压缩(空/无摘要对象)时返回 None(对照 pi 返回 ok(undefined))。
"""
from .context import estimate_context_tokens
settings = settings or DEFAULT_COMPACTION_SETTINGS
if not messages:
_compaction_diag("[COMPACT_NONE] branch=empty_messages")
return None
if messages[-1].kind == "compaction_summary":
_compaction_diag(
f"[COMPACT_NONE] branch=tail_is_summary msgs={len(messages)} "
f"tail_head={_content_text(messages[-1].content)[:60]!r}")
return None
previous_summary = None
if messages[0].kind == "compaction_summary":
previous_summary = _content_text(messages[0].content)
compactable = messages[1:]
else:
compactable = messages
if not compactable:
_compaction_diag("[COMPACT_NONE] branch=compactable_empty (上下文只剩旧摘要)")
return None
tokens_before = estimate_context_tokens(messages).tokens
cut = find_cut_point(compactable, 0, len(compactable),
settings.keep_recent_tokens)
history_end = cut.turn_start_index if cut.is_split_turn else cut.first_kept_index
messages_to_summarize = compactable[:history_end]
turn_prefix_messages = []
if cut.is_split_turn:
turn_prefix_messages = compactable[cut.turn_start_index:cut.first_kept_index]
retained_tail = compactable[cut.first_kept_index:]
if not messages_to_summarize and not turn_prefix_messages:
# 🆕 自诊断:记录是哪种子条件导致无东西可摘要(见 _nothing_to_summarize_diag
_compaction_diag(_nothing_to_summarize_diag(messages, compactable, cut,
settings))
return None # 没有可摘要内容
file_ops = FileOperations()
for m in messages_to_summarize:
extract_file_ops_from_message(m, file_ops)
if cut.is_split_turn:
for m in turn_prefix_messages:
extract_file_ops_from_message(m, file_ops)
return CompactionPreparation(
messages_to_summarize=messages_to_summarize,
turn_prefix_messages=turn_prefix_messages,
retained_tail=retained_tail,
is_split_turn=cut.is_split_turn,
tokens_before=tokens_before,
previous_summary=previous_summary,
file_ops=file_ops,
settings=settings,
)
# ======================================================================
# 摘要生成 —— 1:1 对照 generateSummaryWithUsage / generateTurnPrefixSummary
# ======================================================================
def _build_summary_prompt(conversation_text: str, previous_summary: Optional[str]) -> str:
"""pi generateSummaryWithUsage 的 prompt 组装(逐字结构)"""
base = UPDATE_SUMMARIZATION_PROMPT if previous_summary else SUMMARIZATION_PROMPT
prompt = f"<conversation>\n{conversation_text}\n</conversation>\n\n"
if previous_summary:
prompt += f"<previous-summary>\n{previous_summary}\n</previous-summary>\n\n"
prompt += base
return prompt
def generate_summary(messages: List[AgentMessage],
summarize_fn: Callable[[str, str, int], str],
reserve_tokens: int,
model_max_tokens: int,
previous_summary: Optional[str] = None
) -> Tuple[Optional[str], Optional[str]]:
"""
返回 (summary_text, error)。
maxTokens = min(0.8 × reserveTokens, model.maxTokens) —— 1:1 对照。
"""
max_tokens = min(
int(0.8 * reserve_tokens),
model_max_tokens if model_max_tokens > 0 else (1 << 30),
)
conversation = serialize_conversation(messages)
prompt = _build_summary_prompt(conversation, previous_summary)
try:
text = summarize_fn(prompt, SUMMARIZATION_SYSTEM_PROMPT, max_tokens)
except Exception as e:
return None, f"Summarization failed: {e}"
if not text or not text.strip():
return None, "Summarization failed: empty response"
return text.strip(), None
def generate_turn_prefix_summary(messages: List[AgentMessage],
summarize_fn: Callable[[str, str, int], str],
reserve_tokens: int,
model_max_tokens: int
) -> Tuple[Optional[str], Optional[str]]:
"""maxTokens = min(0.5 × reserveTokens, model.maxTokens) —— 1:1 对照"""
max_tokens = min(
int(0.5 * reserve_tokens),
model_max_tokens if model_max_tokens > 0 else (1 << 30),
)
conversation = serialize_conversation(messages)
prompt = f"<conversation>\n{conversation}\n</conversation>\n\n{TURN_PREFIX_SUMMARIZATION_PROMPT}"
try:
text = summarize_fn(prompt, SUMMARIZATION_SYSTEM_PROMPT, max_tokens)
except Exception as e:
return None, f"Turn prefix summarization failed: {e}"
if not text or not text.strip():
return None, "Turn prefix summarization failed: empty response"
return text.strip(), None
# ======================================================================
# 主入口 —— 1:1 对照 compact()
# ======================================================================
def compact_context(messages: List[AgentMessage],
model: ModelConfig,
summarize_fn: Callable[[str, str, int], str],
settings: Optional[CompactionSettings] = None
) -> Optional[List[AgentMessage]]:
"""
执行压缩。返回 [压缩摘要消息] + 保留尾巴;不可压缩时返回 None。
摘要消息: role="user", kind="compaction_summary"content 为纯摘要文本
pi 的 compaction 条目;下次压缩时自动走迭代更新提示词)。
断轮(切点落在某轮中间)时 1:1 对照 pi compact()
历史摘要 与 轮前缀摘要 分两次 LLM 调用,拼接为
{history}\n\n---\n\n**Turn Context (split turn):**\n\n{prefix}
最后追加文件操作附录(<read-files>/<modified-files>)。
"""
settings = settings or DEFAULT_COMPACTION_SETTINGS
prep = prepare_compaction(messages, settings)
if prep is None:
return None
history_text: Optional[str] = None
prefix_error: Optional[str] = None
history_error: Optional[str] = None
if prep.is_split_turn and prep.turn_prefix_messages:
if prep.messages_to_summarize:
history_text, history_error = generate_summary(
prep.messages_to_summarize, summarize_fn,
prep.settings.reserve_tokens, model.max_tokens,
prep.previous_summary)
if history_error:
return _degraded_compact(prep, history_error)
else:
history_text = "No prior history."
prefix_text, prefix_error = generate_turn_prefix_summary(
prep.turn_prefix_messages, summarize_fn,
prep.settings.reserve_tokens, model.max_tokens)
if prefix_error:
return _degraded_compact(prep, prefix_error)
summary = (f"{history_text}\n\n---\n\n"
f"**Turn Context (split turn):**\n\n{prefix_text}")
else:
if not prep.messages_to_summarize:
return None
summary, history_error = generate_summary(
prep.messages_to_summarize, summarize_fn,
prep.settings.reserve_tokens, model.max_tokens,
prep.previous_summary)
if history_error:
return _degraded_compact(prep, history_error)
read_files, modified_files = compute_file_lists(prep.file_ops)
summary += format_file_operations(read_files, modified_files)
summary_msg = AgentMessage(role="user", content=summary,
kind="compaction_summary")
return [summary_msg] + prep.retained_tail
def _degraded_compact(prep: CompactionPreparation,
error: str) -> Optional[List[AgentMessage]]:
"""
已声明偏差(对照 pi: 直接返回 CompactionError):
桌面应用优先「不丢上下文」——摘要失败时降级为机械摘录,
保留尾巴原样不动。
"""
old = prep.messages_to_summarize + prep.turn_prefix_messages
if not old:
return None
excerpt = serialize_conversation(old)[-500:]
summary = (f"(自动压缩:摘要生成失败 [{error}],以下为旧对话尾部摘录)\n\n{excerpt}")
summary_msg = AgentMessage(role="user", content=summary,
kind="compaction_summary")
return [summary_msg] + prep.retained_tail