mirror of
https://github.com/shareAI-lab/analysis_claude_code.git
synced 2026-09-20 12:13:38 +08:00
Merge pull request #523 from 123456wda/fix/micro-compact-latest-batch
fix: preserve latest tool result batch during compaction
This commit is contained in:
@@ -117,12 +117,15 @@ messages = [*messages[:head_end], marker, *messages[tail_start:]]
|
||||
|
||||
## ステップ 3:micro_compact
|
||||
|
||||
`micro_compact` は、現在の履歴にあるすべての `tool_result` を収集します。最新 3 件は完全に保持し、それより古く 120 文字を超える結果を短くします。保存済みの結果にはファイルパスを残し、それ以外はプレースホルダーに置き換えます。
|
||||
`micro_compact` は直近の assistant 応答より後に追加されたすべての `tool_result` を完全に保持し、モデルが各結果を少なくとも 1 回は完全な形で読めるようにします。モデルがすでに読んだ結果については最新 3 件を残し、それより古く 120 文字を超える結果を短くします。保存済みの結果にはファイルパスを残し、それ以外はプレースホルダーに置き換えます。
|
||||
|
||||

|
||||
|
||||
```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", ""))
|
||||
if len(content) <= 120:
|
||||
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:大きな結果を保存する
|
||||
|
||||
|
||||
@@ -117,12 +117,15 @@ 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 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:
|
||||
|
||||

|
||||
|
||||
```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", ""))
|
||||
if len(content) <= 120:
|
||||
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.
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -117,12 +117,15 @@ messages = [*messages[:head_end], marker, *messages[tail_start:]]
|
||||
|
||||
## 第三步:micro_compact
|
||||
|
||||
`micro_compact` 收集当前历史里的全部 `tool_result`。最近 3 条保持完整,更早且超过 120 个字符的结果会缩短。已经转存的结果保留文件路径,其他结果只留下占位符:
|
||||
`micro_compact` 会完整保留最近一次 assistant 响应之后新增的所有 `tool_result`,确保模型至少完整读取每条新结果一次。对于模型已经读取过的结果,它保留最近 3 条,并缩短其余超过 120 个字符的旧结果。已经转存的结果保留文件路径,其他结果只留下占位符:
|
||||
|
||||

|
||||
|
||||
```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", ""))
|
||||
if len(content) <= 120:
|
||||
continue
|
||||
@@ -305,7 +308,7 @@ python s08_context_compact/code.py
|
||||
比较它们的一级标题,并总结这些标题的命名规律。
|
||||
```
|
||||
|
||||
任务会产生至少 5 条文件读取结果。最近 3 条保持完整,更早且较长的结果会变成 `[Earlier tool result omitted.]`。已经转存的结果会保留保存路径。
|
||||
任务会产生至少 5 条文件读取结果。每条新结果在模型首次读取前都会保持完整;后续轮次只保留最近 3 条已读取结果,更早且较长的结果会变成 `[Earlier tool result omitted.]`。已经转存的结果会保留保存路径。
|
||||
|
||||
### 实验二:大结果转存
|
||||
|
||||
|
||||
@@ -267,6 +267,23 @@ class ContextCompactor:
|
||||
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:
|
||||
self.transcript_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = self.transcript_dir / f"transcript_{uuid.uuid4().hex}.jsonl"
|
||||
@@ -325,13 +342,15 @@ class ContextCompactor:
|
||||
|
||||
def micro_compact(self, messages: list) -> list:
|
||||
results = [
|
||||
block
|
||||
for message in messages
|
||||
(message_index, block_index, block)
|
||||
for message_index, message in enumerate(messages)
|
||||
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"
|
||||
]
|
||||
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", ""))
|
||||
if len(content) <= 120:
|
||||
continue
|
||||
|
||||
@@ -1903,6 +1903,23 @@ def collect_tool_results(messages: list):
|
||||
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:
|
||||
if len(output) <= PERSIST_THRESHOLD:
|
||||
return output
|
||||
@@ -1959,9 +1976,9 @@ 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]:
|
||||
unseen = unseen_tool_result_positions(messages)
|
||||
consumed = [entry for entry in tool_results if entry[:2] not in unseen]
|
||||
for _, _, block in consumed[:-KEEP_RECENT_TOOL_RESULTS]:
|
||||
if len(str(block.get("content", ""))) > 120:
|
||||
block["content"] = "[Earlier tool result compacted. Re-run if needed.]"
|
||||
return messages
|
||||
|
||||
@@ -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"):
|
||||
return {
|
||||
"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):
|
||||
content = message.get("content")
|
||||
return (
|
||||
@@ -112,6 +133,63 @@ def compaction_api(module):
|
||||
|
||||
|
||||
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):
|
||||
messages = [
|
||||
user_text(),
|
||||
|
||||
@@ -195,6 +195,10 @@ function extractDocVersion(filename: string): string | 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 {
|
||||
const titleMatch = content.match(/^#\s+(.+)$/m);
|
||||
return titleMatch ? titleMatch[1] : fallback;
|
||||
@@ -268,7 +272,7 @@ function rewriteChapterMarkdown(
|
||||
function buildRootVersions(chapters: ChapterSource[]): AgentVersion[] {
|
||||
const versions: AgentVersion[] = [];
|
||||
for (const chapter of chapters) {
|
||||
const source = fs.readFileSync(chapter.codePath, "utf-8");
|
||||
const source = readText(chapter.codePath);
|
||||
const lines = source.split("\n");
|
||||
const meta = VERSION_META[chapter.id];
|
||||
const localTools = extractTools(source);
|
||||
@@ -310,7 +314,7 @@ function buildLegacyVersions(): AgentVersion[] {
|
||||
if (!id) return null;
|
||||
|
||||
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 meta = VERSION_META[id];
|
||||
|
||||
@@ -346,7 +350,7 @@ function buildRootDocs(chapters: ChapterSource[]): DocContent[] {
|
||||
const filePath = path.join(chapter.dirPath, filename);
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const raw = readText(filePath);
|
||||
const content = rewriteChapterMarkdown(raw, chapter, locale);
|
||||
docs.push({
|
||||
version: chapter.id,
|
||||
@@ -376,7 +380,7 @@ function buildLegacyDocs(): DocContent[] {
|
||||
|
||||
const relPath = path.join(locale, filename);
|
||||
const filePath = path.join(LEGACY_DOCS_DIR, relPath);
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const content = readText(filePath);
|
||||
docs.push({
|
||||
version,
|
||||
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
Reference in New Issue
Block a user