fix: preserve latest tool result batch during compaction

This commit is contained in:
123456wda 2026-08-15 20:55:42 +08:00
parent 66cfd2ca91
commit b5bd0ddfa1
6 changed files with 72 additions and 10 deletions

View file

@ -117,7 +117,7 @@ messages = [*messages[:head_end], marker, *messages[tail_start:]]
## ステップ 3micro_compact
`micro_compact`、現在の履歴にあるすべての `tool_result` を収集します。最新 3 件は完全に保持し、それより古く 120 文字を超える結果を短くします。保存済みの結果にはファイルパスを残し、それ以外はプレースホルダーに置き換えます。
`micro_compact`最新の `tool_result` バッチを完全に保持し、さらに以前のバッチから最新 3 件を残します。それより古く 120 文字を超える結果を短くします。保存済みの結果にはファイルパスを残し、それ以外はプレースホルダーに置き換えます。
![古い結果を置き換える](images/micro-compact.ja.svg)
@ -305,7 +305,7 @@ s01_agent_loop から s05_todo_write までの README.md を読み、
各ファイルの最上位見出しを比較して、命名の規則をまとめてください。
```
このタスクでは少なくとも 5 件のファイル結果が生成されます。最新 3 件は完全に残り、それより前の長い結果は `[Earlier tool result omitted.]` に変わります。保存済みの結果には保存先のパスが残ります。
このタスクでは少なくとも 5 件のファイル結果が生成されます。最新のバッチと、それ以前の最新 3 件は完全に残り、それより前の長い結果は `[Earlier tool result omitted.]` に変わります。保存済みの結果には保存先のパスが残ります。
### 実験 2大きな結果を保存する

View file

@ -117,7 +117,7 @@ This step controls the number of messages. Tool results inside the retained mess
## Step 3: micro_compact
`micro_compact` collects all current `tool_result` blocks. It preserves the latest 3 results and shortens earlier results longer than 120 characters. Persisted results keep their file path; the rest become placeholders:
`micro_compact` preserves the newest `tool_result` batch in full, then keeps the latest 3 results from earlier batches and shortens older results longer than 120 characters. Persisted results keep their file path; the rest become placeholders:
![Replacing old results](images/micro-compact.en.svg)
@ -305,7 +305,7 @@ Read the README.md files from s01_agent_loop through s05_todo_write.
Compare their top-level headings and summarize the naming pattern.
```
This task produces at least 5 file results. The latest 3 remain complete, while earlier long results become `[Earlier tool result omitted.]`. A persisted result retains its saved path.
This task produces at least 5 file results. The newest batch and the latest 3 earlier results remain complete, while older long results become `[Earlier tool result omitted.]`. A persisted result retains its saved path.
### Experiment 2: Persist a Large Result

View file

@ -117,7 +117,7 @@ messages = [*messages[:head_end], marker, *messages[tail_start:]]
## 第三步micro_compact
`micro_compact` 收集当前历史里的全部 `tool_result`。最近 3 条保持完整,更早且超过 120 个字符的结果会缩短。已经转存的结果保留文件路径,其他结果只留下占位符:
`micro_compact` 会完整保留最新一批 `tool_result`,再保留更早批次中最近 3 条结果;其余超过 120 个字符的旧结果会缩短。已经转存的结果保留文件路径,其他结果只留下占位符:
![旧结果替换为占位符](images/micro-compact.svg)
@ -305,7 +305,7 @@ python s08_context_compact/code.py
比较它们的一级标题,并总结这些标题的命名规律。
```
任务会产生至少 5 条文件读取结果。最近 3 条保持完整,更早且较长的结果会变成 `[Earlier tool result omitted.]`。已经转存的结果会保留保存路径。
任务会产生至少 5 条文件读取结果。最新一批以及更早批次中最近 3 条结果保持完整,更早且较长的结果会变成 `[Earlier tool result omitted.]`。已经转存的结果会保留保存路径。
### 实验二:大结果转存

View file

@ -331,7 +331,20 @@ class ContextCompactor:
for block in message["content"]
if isinstance(block, dict) and block.get("type") == "tool_result"
]
for block in results[:-self.KEEP_RECENT_RESULTS]:
latest_batch = []
for message in reversed(messages):
content = message.get("content")
if message.get("role") != "user" or not isinstance(content, list):
continue
latest_batch = [
block for block in content
if isinstance(block, dict) and block.get("type") == "tool_result"
]
if latest_batch:
break
latest_batch_ids = {id(block) for block in latest_batch}
older_results = [block for block in results if id(block) not in latest_batch_ids]
for block in older_results[:-self.KEEP_RECENT_RESULTS]:
content = str(block.get("content", ""))
if len(content) <= 120:
continue

View file

@ -1959,9 +1959,20 @@ def snip_compact(messages: list, max_messages: int = 50) -> list:
def micro_compact(messages: list) -> list:
tool_results = collect_tool_results(messages)
if len(tool_results) <= KEEP_RECENT_TOOL_RESULTS:
return messages
for _, _, block in tool_results[:-KEEP_RECENT_TOOL_RESULTS]:
latest_batch = []
for message in reversed(messages):
content = message.get("content")
if message.get("role") != "user" or not isinstance(content, list):
continue
latest_batch = [
block for block in content
if isinstance(block, dict) and block.get("type") == "tool_result"
]
if latest_batch:
break
latest_batch_ids = {id(block) for block in latest_batch}
older_results = [entry for entry in tool_results if id(entry[2]) not in latest_batch_ids]
for _, _, block in older_results[:-KEEP_RECENT_TOOL_RESULTS]:
if len(str(block.get("content", ""))) > 120:
block["content"] = "[Earlier tool result compacted. Re-run if needed.]"
return messages

View file

@ -86,6 +86,17 @@ def tool_result_message(tool_id="tool-1"):
}
def long_tool_result_batch(*tool_ids):
return {
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": tool_id,
"content": f"{tool_id}: " + "x" * 160}
for tool_id in tool_ids
],
}
def message_has_tool_use(message):
content = message.get("content")
return (
@ -112,6 +123,33 @@ def compaction_api(module):
class CompactionToolPairTests(unittest.TestCase):
def test_micro_compact_keeps_latest_tool_result_batch(self):
for name, path in MODULES.items():
with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp:
messages = [
long_tool_result_batch("old-1"),
long_tool_result_batch("old-2"),
long_tool_result_batch("old-3"),
long_tool_result_batch("old-4"),
user_text(),
long_tool_result_batch(
"latest-1", "latest-2", "latest-3", "latest-4"
),
]
module = load_module(f"{name}_micro_batch_under_test", path, Path(tmp))
compacted = compaction_api(module).micro_compact(messages)
results = {
block["tool_use_id"]: block["content"]
for message in compacted
if isinstance(message["content"], list)
for block in message["content"]
if isinstance(block, dict) and block.get("type") == "tool_result"
}
self.assertNotIn("old-1: ", results["old-1"])
for tool_id in ("old-2", "old-3", "old-4",
"latest-1", "latest-2", "latest-3", "latest-4"):
self.assertIn(f"{tool_id}: ", results[tool_id])
def test_snip_compact_keeps_head_tool_pair(self):
messages = [
user_text(),