Merge pull request #523 from 123456wda/fix/micro-compact-latest-batch

fix: preserve latest tool result batch during compaction
This commit is contained in:
Yang Haoran
2026-08-16 17:46:28 +08:00
committed by GitHub
9 changed files with 223 additions and 90 deletions

View File

@@ -117,12 +117,15 @@ messages = [*messages[:head_end], marker, *messages[tail_start:]]
## ステップ 3micro_compact ## ステップ 3micro_compact
`micro_compact`、現在の履歴にあるすべての `tool_result` を収集します。最新 3 件は完全に保持し、それより古く 120 文字を超える結果を短くします。保存済みの結果にはファイルパスを残し、それ以外はプレースホルダーに置き換えます。 `micro_compact`直近の assistant 応答より後に追加されたすべての `tool_result` を完全に保持し、モデルが各結果を少なくとも 1 回は完全な形で読めるようにします。モデルがすでに読んだ結果については最新 3 件を残し、それより古く 120 文字を超える結果を短くします。保存済みの結果にはファイルパスを残し、それ以外はプレースホルダーに置き換えます。
![古い結果を置き換える](images/micro-compact.ja.svg) ![古い結果を置き換える](images/micro-compact.ja.svg)
```python ```python
for block in results[:-self.KEEP_RECENT_RESULTS]: unseen = self.unseen_tool_result_positions(messages)
consumed = [entry for entry in results if entry[:2] not in unseen]
for _, _, block in consumed[:-self.KEEP_RECENT_RESULTS]:
content = str(block.get("content", "")) content = str(block.get("content", ""))
if len(content) <= 120: if len(content) <= 120:
continue continue
@@ -305,7 +308,7 @@ s01_agent_loop から s05_todo_write までの README.md を読み、
各ファイルの最上位見出しを比較して、命名の規則をまとめてください。 各ファイルの最上位見出しを比較して、命名の規則をまとめてください。
``` ```
このタスクでは少なくとも 5 件のファイル結果が生成されます。最新 3 件は完全に残り、それより前の長い結果は `[Earlier tool result omitted.]` に変わります。保存済みの結果には保存先のパスが残ります。 このタスクでは少なくとも 5 件のファイル結果が生成されます。各新規結果はモデルが初めて読むまで完全に保持されます。以降のターンでは、すでに読まれた最新 3 件を残し、それより前の長い結果は `[Earlier tool result omitted.]` に変わります。保存済みの結果には保存先のパスが残ります。
### 実験 2大きな結果を保存する ### 実験 2大きな結果を保存する

View File

@@ -117,12 +117,15 @@ This step controls the number of messages. Tool results inside the retained mess
## Step 3: micro_compact ## 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 every `tool_result` added after the most recent assistant response, so the model sees each new result in full once. Among results the model has already consumed, it keeps the latest 3 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) ![Replacing old results](images/micro-compact.en.svg)
```python ```python
for block in results[:-self.KEEP_RECENT_RESULTS]: unseen = self.unseen_tool_result_positions(messages)
consumed = [entry for entry in results if entry[:2] not in unseen]
for _, _, block in consumed[:-self.KEEP_RECENT_RESULTS]:
content = str(block.get("content", "")) content = str(block.get("content", ""))
if len(content) <= 120: if len(content) <= 120:
continue continue
@@ -305,7 +308,7 @@ Read the README.md files from s01_agent_loop through s05_todo_write.
Compare their top-level headings and summarize the naming pattern. 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. Every result remains complete until the model sees it once. On later turns, the latest 3 consumed 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 ### Experiment 2: Persist a Large Result

View File

@@ -117,12 +117,15 @@ messages = [*messages[:head_end], marker, *messages[tail_start:]]
## 第三步micro_compact ## 第三步micro_compact
`micro_compact` 收集当前历史里的全部 `tool_result`。最近 3 条保持完整,更早且超过 120 个字符的结果会缩短。已经转存的结果保留文件路径,其他结果只留下占位符: `micro_compact` 会完整保留最近一次 assistant 响应之后新增的所有 `tool_result`,确保模型至少完整读取每条新结果一次。对于模型已经读取过的结果,它保留最近 3 条,并缩短其余超过 120 个字符的结果。已经转存的结果保留文件路径,其他结果只留下占位符:
![旧结果替换为占位符](images/micro-compact.svg) ![旧结果替换为占位符](images/micro-compact.svg)
```python ```python
for block in results[:-self.KEEP_RECENT_RESULTS]: unseen = self.unseen_tool_result_positions(messages)
consumed = [entry for entry in results if entry[:2] not in unseen]
for _, _, block in consumed[:-self.KEEP_RECENT_RESULTS]:
content = str(block.get("content", "")) content = str(block.get("content", ""))
if len(content) <= 120: if len(content) <= 120:
continue continue
@@ -305,7 +308,7 @@ python s08_context_compact/code.py
比较它们的一级标题,并总结这些标题的命名规律。 比较它们的一级标题,并总结这些标题的命名规律。
``` ```
任务会产生至少 5 条文件读取结果。最近 3 条保持完整,更早且较长的结果会变成 `[Earlier tool result omitted.]`。已经转存的结果会保留保存路径。 任务会产生至少 5 条文件读取结果。每条新结果在模型首次读取前都会保持完整;后续轮次只保留最近 3 条已读取结果,更早且较长的结果会变成 `[Earlier tool result omitted.]`。已经转存的结果会保留保存路径。
### 实验二:大结果转存 ### 实验二:大结果转存

View File

@@ -267,6 +267,23 @@ class ContextCompactor:
for block in content) for block in content)
) )
@staticmethod
def unseen_tool_result_positions(messages: list) -> set[tuple[int, int]]:
"""Return results added since the model's most recent response."""
last_assistant = next(
(index for index in range(len(messages) - 1, -1, -1)
if messages[index].get("role") == "assistant"),
-1,
)
return {
(message_index, block_index)
for message_index in range(last_assistant + 1, len(messages))
if messages[message_index].get("role") == "user"
and isinstance(messages[message_index].get("content"), list)
for block_index, block in enumerate(messages[message_index]["content"])
if isinstance(block, dict) and block.get("type") == "tool_result"
}
def write_transcript(self, messages: list) -> Path: def write_transcript(self, messages: list) -> Path:
self.transcript_dir.mkdir(parents=True, exist_ok=True) self.transcript_dir.mkdir(parents=True, exist_ok=True)
path = self.transcript_dir / f"transcript_{uuid.uuid4().hex}.jsonl" path = self.transcript_dir / f"transcript_{uuid.uuid4().hex}.jsonl"
@@ -325,13 +342,15 @@ class ContextCompactor:
def micro_compact(self, messages: list) -> list: def micro_compact(self, messages: list) -> list:
results = [ results = [
block (message_index, block_index, block)
for message in messages for message_index, message in enumerate(messages)
if message.get("role") == "user" and isinstance(message.get("content"), list) if message.get("role") == "user" and isinstance(message.get("content"), list)
for block in message["content"] for block_index, block in enumerate(message["content"])
if isinstance(block, dict) and block.get("type") == "tool_result" if isinstance(block, dict) and block.get("type") == "tool_result"
] ]
for block in results[:-self.KEEP_RECENT_RESULTS]: unseen = self.unseen_tool_result_positions(messages)
consumed = [entry for entry in results if entry[:2] not in unseen]
for _, _, block in consumed[:-self.KEEP_RECENT_RESULTS]:
content = str(block.get("content", "")) content = str(block.get("content", ""))
if len(content) <= 120: if len(content) <= 120:
continue continue

View File

@@ -1903,6 +1903,23 @@ def collect_tool_results(messages: list):
return found return found
def unseen_tool_result_positions(messages: list) -> set[tuple[int, int]]:
"""Return results added since the model's most recent response."""
last_assistant = next(
(index for index in range(len(messages) - 1, -1, -1)
if messages[index].get("role") == "assistant"),
-1,
)
return {
(message_index, block_index)
for message_index in range(last_assistant + 1, len(messages))
if messages[message_index].get("role") == "user"
and isinstance(messages[message_index].get("content"), list)
for block_index, block in enumerate(messages[message_index]["content"])
if isinstance(block, dict) and block.get("type") == "tool_result"
}
def persist_large_output(tool_use_id: str, output: str) -> str: def persist_large_output(tool_use_id: str, output: str) -> str:
if len(output) <= PERSIST_THRESHOLD: if len(output) <= PERSIST_THRESHOLD:
return output return output
@@ -1959,9 +1976,9 @@ def snip_compact(messages: list, max_messages: int = 50) -> list:
def micro_compact(messages: list) -> list: def micro_compact(messages: list) -> list:
tool_results = collect_tool_results(messages) tool_results = collect_tool_results(messages)
if len(tool_results) <= KEEP_RECENT_TOOL_RESULTS: unseen = unseen_tool_result_positions(messages)
return messages consumed = [entry for entry in tool_results if entry[:2] not in unseen]
for _, _, block in tool_results[:-KEEP_RECENT_TOOL_RESULTS]: for _, _, block in consumed[:-KEEP_RECENT_TOOL_RESULTS]:
if len(str(block.get("content", ""))) > 120: if len(str(block.get("content", ""))) > 120:
block["content"] = "[Earlier tool result compacted. Re-run if needed.]" block["content"] = "[Earlier tool result compacted. Re-run if needed.]"
return messages return messages

View File

@@ -79,6 +79,16 @@ def tool_use_message(tool_id="tool-1"):
} }
def tool_use_batch(*tool_ids):
return {
"role": "assistant",
"content": [
types.SimpleNamespace(type="tool_use", id=tool_id, name="bash")
for tool_id in tool_ids
],
}
def tool_result_message(tool_id="tool-1"): def tool_result_message(tool_id="tool-1"):
return { return {
"role": "user", "role": "user",
@@ -86,6 +96,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): def message_has_tool_use(message):
content = message.get("content") content = message.get("content")
return ( return (
@@ -112,6 +133,63 @@ def compaction_api(module):
class CompactionToolPairTests(unittest.TestCase): class CompactionToolPairTests(unittest.TestCase):
def test_micro_compact_keeps_unseen_tool_result_batch(self):
for name, path in MODULES.items():
with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp:
messages = [
tool_use_message("old-1"),
long_tool_result_batch("old-1"),
tool_use_message("old-2"),
long_tool_result_batch("old-2"),
tool_use_message("old-3"),
long_tool_result_batch("old-3"),
tool_use_message("old-4"),
long_tool_result_batch("old-4"),
tool_use_batch("latest-1", "latest-2", "latest-3", "latest-4"),
long_tool_result_batch(
"latest-1", "latest-2", "latest-3", "latest-4"
),
{"role": "user", "content": [
{"type": "text", "text": "<task_notification>done</task_notification>"}
]},
{"role": "user", "content": "<reminder>Update your todos.</reminder>"},
]
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_micro_compact_releases_batch_after_model_consumes_it(self):
for name, path in MODULES.items():
with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp:
messages = [
tool_use_batch("seen-1", "seen-2", "seen-3", "seen-4"),
long_tool_result_batch("seen-1", "seen-2", "seen-3", "seen-4"),
assistant_text(),
user_text(),
]
module = load_module(f"{name}_consumed_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("seen-1: ", results["seen-1"])
for tool_id in ("seen-2", "seen-3", "seen-4"):
self.assertIn(f"{tool_id}: ", results[tool_id])
def test_snip_compact_keeps_head_tool_pair(self): def test_snip_compact_keeps_head_tool_pair(self):
messages = [ messages = [
user_text(), user_text(),

View File

@@ -195,6 +195,10 @@ function extractDocVersion(filename: string): string | null {
return match ? match[1] : null; return match ? match[1] : null;
} }
function readText(filePath: string): string {
return fs.readFileSync(filePath, "utf-8").replace(/\r\n/g, "\n");
}
function titleFromMarkdown(content: string, fallback: string): string { function titleFromMarkdown(content: string, fallback: string): string {
const titleMatch = content.match(/^#\s+(.+)$/m); const titleMatch = content.match(/^#\s+(.+)$/m);
return titleMatch ? titleMatch[1] : fallback; return titleMatch ? titleMatch[1] : fallback;
@@ -268,7 +272,7 @@ function rewriteChapterMarkdown(
function buildRootVersions(chapters: ChapterSource[]): AgentVersion[] { function buildRootVersions(chapters: ChapterSource[]): AgentVersion[] {
const versions: AgentVersion[] = []; const versions: AgentVersion[] = [];
for (const chapter of chapters) { for (const chapter of chapters) {
const source = fs.readFileSync(chapter.codePath, "utf-8"); const source = readText(chapter.codePath);
const lines = source.split("\n"); const lines = source.split("\n");
const meta = VERSION_META[chapter.id]; const meta = VERSION_META[chapter.id];
const localTools = extractTools(source); const localTools = extractTools(source);
@@ -310,7 +314,7 @@ function buildLegacyVersions(): AgentVersion[] {
if (!id) return null; if (!id) return null;
const filePath = path.join(LEGACY_AGENTS_DIR, filename); const filePath = path.join(LEGACY_AGENTS_DIR, filename);
const source = fs.readFileSync(filePath, "utf-8"); const source = readText(filePath);
const lines = source.split("\n"); const lines = source.split("\n");
const meta = VERSION_META[id]; const meta = VERSION_META[id];
@@ -346,7 +350,7 @@ function buildRootDocs(chapters: ChapterSource[]): DocContent[] {
const filePath = path.join(chapter.dirPath, filename); const filePath = path.join(chapter.dirPath, filename);
if (!fs.existsSync(filePath)) continue; if (!fs.existsSync(filePath)) continue;
const raw = fs.readFileSync(filePath, "utf-8"); const raw = readText(filePath);
const content = rewriteChapterMarkdown(raw, chapter, locale); const content = rewriteChapterMarkdown(raw, chapter, locale);
docs.push({ docs.push({
version: chapter.id, version: chapter.id,
@@ -376,7 +380,7 @@ function buildLegacyDocs(): DocContent[] {
const relPath = path.join(locale, filename); const relPath = path.join(locale, filename);
const filePath = path.join(LEGACY_DOCS_DIR, relPath); const filePath = path.join(LEGACY_DOCS_DIR, relPath);
const content = fs.readFileSync(filePath, "utf-8"); const content = readText(filePath);
docs.push({ docs.push({
version, version,
locale: detectLocale(relPath), locale: detectLocale(relPath),

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long