fix: preserve tool results during context compaction

This commit is contained in:
Haoran
2026-08-23 11:50:52 +08:00
parent 61c3e64595
commit 6d70d2e071
24 changed files with 843 additions and 497 deletions
+20 -20
View File
@@ -88,11 +88,11 @@ for block in ranked:
## ステップ 2snip_compact
履歴が 50 メッセージを超えると、`snip_compact` は完全な履歴を `.transcripts/` に保存してから、先頭 3 件と最新 47 件を保持します。中間のマーカーには、削除した件数と transcript の保存先を記録します。
履歴が 50 メッセージを超えると、`snip_compact` は完全な履歴を `.transcripts/` に保存してから、先頭 3 件と最新 46 件を保持します。残り 1 件は archive marker に使い、削除した件数と完全な transcript の保存先を記録します。
```python
head_end = 3
tail_start = len(messages) - (max_messages - head_end)
tail_start = len(messages) - (max_messages - head_end - 1)
if self.has_tool_use(messages[head_end - 1]):
while (head_end < tail_start
@@ -117,37 +117,34 @@ messages = [*messages[:head_end], marker, *messages[tail_start:]]
## ステップ 3micro_compact
最初の 2 ステップの後、`prepare` は残りのコンテキストサイズを推定し、`CONTEXT_CHAR_LIMIT` を超えている場合にだけ `micro_compact` を実行します。`micro_compact` は直近の assistant 応答より後に追加されたすべての `tool_result` を完全に保持し、モデルが各結果を少なくとも 1 回は完全な形で読めるようにします。モデルがすでに読んだ結果については最新 3 件を残し、それより古く 120 文字を超える結果を短くします。保存済みの結果にはファイルパスを残し、それ以外はプレースホルダーに置き換えます。
最初の 2 ステップの後、`prepare` は残りのコンテキストサイズを推定し、`CONTEXT_CHAR_LIMIT` を超えている場合にだけ `micro_compact` を実行します。モデルがすでに読んだ結果については最新 3 件を残し、それより古く 120 文字を超える結果を、コンテキストが上限の 80% に近づくまで順に短くします。古い結果は置換前に完全な内容をディスクへ保存するため、各プレースホルダーには復元用のパスが残ります。
![古い結果を置き換える](images/micro-compact.ja.svg)
![古い結果を復元可能なパスへ置き換える](images/micro-compact.ja.svg)
```python
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]:
if self.estimate_chars(messages) <= target_chars:
break
content = str(block.get("content", ""))
if len(content) <= 120:
continue
saved_path = next(
(line.removeprefix("Full output: ") for line in content.splitlines()
if line.startswith("Full output: ")),
None,
)
block["content"] = (
f"[Earlier tool result saved at {saved_path}]"
if saved_path else "[Earlier tool result omitted.]"
)
saved_path = self.persisted_output_path(content)
if not saved_path:
saved_path = self.save_output(block["tool_use_id"], content)
block["content"] = f"[Earlier tool result saved at {saved_path}]"
```
保存していない古い結果にはプレースホルダーだけが残ります。ステップ 1 で保存した結果には、完全な出力を読み直すためのパスが残ります。
新しい結果は通常、モデルが一度読むまで完全な形で保持されます。未読の最新バッチだけでコンテキストを超える場合、`fit_tool_results` は大きな結果を保存し、1,000 文字の preview と完全な出力へのパスを残します。これにより、モデルが新しい結果を見る前に履歴全体を要約する事態を避けます。
最初の 2 ステップは毎ラウンド実行され、ステップ 3 はコンテキストが上限を超えた場合にだけ実行されます。3 ステップとも決定的なテキスト処理と構造操作であり、追加の API 呼び出しは発生しません。
最初の 2 ステップは毎ラウンド実行され、ステップ 3 はコンテキストが上限を超えた場合にだけ実行されます。3 ステップとも決定的で復元可能なテキスト処理と構造操作であり、追加の API 呼び出しは発生しません。
## ステップ 4compact_history
`micro_compact` の後、コードは `estimate_chars(messages)` でコンテキストを再び推定します。
`micro_compact``fit_tool_results` の後、コードは `estimate_chars(messages)` でコンテキストを再び推定します。
```python
CONTEXT_CHAR_LIMIT = 50000
@@ -181,13 +178,16 @@ def compact_history(messages, active_request):
## 順序を固定する理由
パイプラインは次の順序で処理し、必要な場合にだけ情報を失う圧縮へ進みます。
パイプラインは次の順序で処理し、必要な場合にだけ情報を失う要約へ進みます。
```python
messages = self.tool_result_budget(messages)
messages = self.snip_compact(messages)
if self.estimate_chars(messages) > self.CONTEXT_CHAR_LIMIT:
messages = self.micro_compact(messages)
target = int(self.CONTEXT_CHAR_LIMIT * 0.8)
messages = self.micro_compact(messages, target)
if self.estimate_chars(messages) > self.CONTEXT_CHAR_LIMIT:
messages = self.fit_tool_results(messages, target)
if self.estimate_chars(messages) > self.CONTEXT_CHAR_LIMIT:
messages = self.compact_history(messages, active_request)
```
@@ -195,7 +195,7 @@ if self.estimate_chars(messages) > self.CONTEXT_CHAR_LIMIT:
この順序には 2 つの条件があります。
1. ステップ 1 と 2 は毎ラウンド実行され、ステップ 3 は上限を超えた場合だけ実行されます。API リクエストを追加するのはステップ 4 だけです。
2. `tool_result_budget``micro_compact` より先に動く必要があります。古い結果をプレースホルダーにする前に、大きな結果をディスクへ保存します。
2. 短縮した各ツール結果には `.task_outputs/tool-results/` 内の信頼できるパスを残します。それでも上限を超える場合にだけ、モデルによる履歴要約へ進みます。
各ラウンドは、コストが低く情報を再取得しやすい処理から始まります。
@@ -310,7 +310,7 @@ s01_agent_loop から s05_todo_write までの README.md を読み、
各ファイルの最上位見出しを比較して、命名の規則をまとめてください。
```
このタスクでは少なくとも 5 件のファイル結果が生成されます。各新規結果はモデルが初めて読むまで完全に保持されます。以降のターンでは、すでに読まれた最新 3 件を残し、それより前の長い結果は `[Earlier tool result omitted.]` に変わります。保存済みの結果には保存先のパスが残ります。
このタスクでは少なくとも 5 件のファイル結果が生成されます。新しい結果は通常、モデルが初めて読むまで完全に保持されます。未読結果自体が大きすぎる場合は、preview と復元パスを残します。以降のターンでは、すでに読まれた最新 3 件を残し、それより前の長い結果は `[Earlier tool result saved at ...]` 参照に変わります。
### 実験 2:大きな結果を保存する
+20 -20
View File
@@ -88,11 +88,11 @@ This step examines only the latest batch of tool results. The complete output re
## Step 2: snip_compact
Once the history exceeds 50 messages, `snip_compact` writes the complete history to `.transcripts/`, then keeps the first 3 and latest 47 messages. The marker records how many messages were removed and where to find the complete transcript.
Once the history exceeds 50 messages, `snip_compact` writes the complete history to `.transcripts/`, then keeps the first 3 and latest 46 messages. The archive marker occupies the remaining slot, records how many messages were removed, and points to the complete transcript.
```python
head_end = 3
tail_start = len(messages) - (max_messages - head_end)
tail_start = len(messages) - (max_messages - head_end - 1)
if self.has_tool_use(messages[head_end - 1]):
while (head_end < tail_start
@@ -117,37 +117,34 @@ This step controls the number of messages. Tool results inside the retained mess
## Step 3: micro_compact
After the first two steps, `prepare` estimates the remaining context size and runs `micro_compact` only when it is above `CONTEXT_CHAR_LIMIT`. `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:
After the first two steps, `prepare` estimates the remaining context size and runs `micro_compact` only when it is above `CONTEXT_CHAR_LIMIT`. Among results the model has already consumed, `micro_compact` keeps the latest 3 and shortens older results longer than 120 characters until the context approaches 80% of the limit. Before replacing an old result, it writes the complete content to disk, so every replacement retains a recovery path:
![Replacing old results](images/micro-compact.en.svg)
![Replacing old results with recovery paths](images/micro-compact.en.svg)
```python
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]:
if self.estimate_chars(messages) <= target_chars:
break
content = str(block.get("content", ""))
if len(content) <= 120:
continue
saved_path = next(
(line.removeprefix("Full output: ") for line in content.splitlines()
if line.startswith("Full output: ")),
None,
)
block["content"] = (
f"[Earlier tool result saved at {saved_path}]"
if saved_path else "[Earlier tool result omitted.]"
)
saved_path = self.persisted_output_path(content)
if not saved_path:
saved_path = self.save_output(block["tool_use_id"], content)
block["content"] = f"[Earlier tool result saved at {saved_path}]"
```
An old result that was not persisted keeps only a placeholder. Results saved in Step 1 retain the path to their complete output.
New results normally stay complete until the model consumes them. If an unseen batch alone is too large for the context, `fit_tool_results` persists its largest results and keeps a 1,000-character preview plus the full-output path. This avoids summarizing the entire history before the model can inspect the new result.
The first two steps run every round. Step 3 runs only when the context is above the limit. All three are deterministic text and structure operations; they do not add API calls.
The first two steps run every round. Step 3 runs only when the context is above the limit. All three are deterministic and recoverable text and structure operations; they do not add API calls.
## Step 4: compact_history
After `micro_compact`, the code estimates the context again with `estimate_chars(messages)`:
After `micro_compact` and `fit_tool_results`, the code estimates the context again with `estimate_chars(messages)`:
```python
CONTEXT_CHAR_LIMIT = 50000
@@ -181,13 +178,16 @@ This lesson uses character count as its trigger, and all related thresholds use
## Why the Order Is Fixed
The pipeline uses this order and only enters the lossy steps when necessary:
The pipeline uses this order and only enters the lossy summary step when necessary:
```python
messages = self.tool_result_budget(messages)
messages = self.snip_compact(messages)
if self.estimate_chars(messages) > self.CONTEXT_CHAR_LIMIT:
messages = self.micro_compact(messages)
target = int(self.CONTEXT_CHAR_LIMIT * 0.8)
messages = self.micro_compact(messages, target)
if self.estimate_chars(messages) > self.CONTEXT_CHAR_LIMIT:
messages = self.fit_tool_results(messages, target)
if self.estimate_chars(messages) > self.CONTEXT_CHAR_LIMIT:
messages = self.compact_history(messages, active_request)
```
@@ -195,7 +195,7 @@ if self.estimate_chars(messages) > self.CONTEXT_CHAR_LIMIT:
This order satisfies two constraints:
1. Steps 1 and 2 run every round. Step 3 runs only above the limit, and only Step 4 adds an API request.
2. `tool_result_budget` must run before `micro_compact`. Large results need to reach disk before older results can become placeholders.
2. Every shortened tool result keeps a trusted path inside `.task_outputs/tool-results/`; only a remaining overflow reaches model-generated history summarization.
Each round therefore starts with the lowest-cost operation whose information is easiest to recover.
@@ -310,7 +310,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. 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.
This task produces at least 5 file results. New results normally remain complete until the model sees them once; an oversized unseen result keeps a preview and recovery path instead. On later turns, the latest 3 consumed results remain complete while older long results become `[Earlier tool result saved at ...]` references.
### Experiment 2: Persist a Large Result
+20 -20
View File
@@ -88,11 +88,11 @@ for block in ranked:
## 第二步:snip_compact
消息数量超过 50 条后,`snip_compact` 先把完整历史写入 `.transcripts/`,再保留最初 3 条和最近 47 条。中间的标记会写明删去了多少条消息,以及完整记录保存在哪里。
消息数量超过 50 条后,`snip_compact` 先把完整历史写入 `.transcripts/`,再保留最初 3 条和最近 46 条。剩余一个位置用于归档标记,其中写明删去了多少条消息,以及完整记录保存在哪里。
```python
head_end = 3
tail_start = len(messages) - (max_messages - head_end)
tail_start = len(messages) - (max_messages - head_end - 1)
if self.has_tool_use(messages[head_end - 1]):
while (head_end < tail_start
@@ -117,37 +117,34 @@ messages = [*messages[:head_end], marker, *messages[tail_start:]]
## 第三步:micro_compact
前两步完成后,`prepare` 会估算剩余上下文的大小,只有超过 `CONTEXT_CHAR_LIMIT` 时才执行 `micro_compact``micro_compact` 会完整保留最近一次 assistant 响应之后新增的所有 `tool_result`,确保模型至少完整读取每条新结果一次。对于模型已经读取过的结果,它保留最近 3 条,并缩短其余超过 120 个字符的结果。已经转存的结果保留文件路径,其他结果只留下占位符
前两步完成后,`prepare` 会估算剩余上下文的大小,只有超过 `CONTEXT_CHAR_LIMIT` 时才执行 `micro_compact`。对于模型已经读取过的结果,它保留最近 3 条,并逐条缩短更早且超过 120 个字符的结果,直到上下文接近阈值的 80%。旧结果被替换前会先完整落盘,因此每个占位都带有可恢复路径
![旧结果替换为占位符](images/micro-compact.svg)
![旧结果替换为可恢复路径](images/micro-compact.svg)
```python
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]:
if self.estimate_chars(messages) <= target_chars:
break
content = str(block.get("content", ""))
if len(content) <= 120:
continue
saved_path = next(
(line.removeprefix("Full output: ") for line in content.splitlines()
if line.startswith("Full output: ")),
None,
)
block["content"] = (
f"[Earlier tool result saved at {saved_path}]"
if saved_path else "[Earlier tool result omitted.]"
)
saved_path = self.persisted_output_path(content)
if not saved_path:
saved_path = self.save_output(block["tool_use_id"], content)
block["content"] = f"[Earlier tool result saved at {saved_path}]"
```
未转存的旧结果只保留占位符。第一步保存过的完整结果仍能通过路径读取,不会在第三步丢失位置
新结果通常会保持完整,直到模型读取一次。如果仅未读取的最新一批结果就足以撑爆上下文,`fit_tool_results` 会把其中最大的结果落盘,并保留 1,000 字符预览和完整路径,避免模型看到新结果前就先总结整段历史
前两步每轮都会执行,第三步只在上下文超限时执行。三步都是确定性的结构和文本操作,不产生额外 API 调用。
前两步每轮都会执行,第三步只在上下文超限时执行。三步都是确定性、可恢复的结构和文本操作,不产生额外 API 调用。
## 第四步:compact_history
`micro_compact` 执行后,代码会再次用 `estimate_chars(messages)` 估算上下文:
`micro_compact``fit_tool_results` 执行后,代码会再次用 `estimate_chars(messages)` 估算上下文:
```python
CONTEXT_CHAR_LIMIT = 50000
@@ -181,13 +178,16 @@ def compact_history(messages, active_request):
## 为什么顺序固定
管线按以下顺序执行,并且只在必要时进入有损压缩步骤:
管线按以下顺序执行,并且只在必要时进入有损的摘要步骤:
```python
messages = self.tool_result_budget(messages)
messages = self.snip_compact(messages)
if self.estimate_chars(messages) > self.CONTEXT_CHAR_LIMIT:
messages = self.micro_compact(messages)
target = int(self.CONTEXT_CHAR_LIMIT * 0.8)
messages = self.micro_compact(messages, target)
if self.estimate_chars(messages) > self.CONTEXT_CHAR_LIMIT:
messages = self.fit_tool_results(messages, target)
if self.estimate_chars(messages) > self.CONTEXT_CHAR_LIMIT:
messages = self.compact_history(messages, active_request)
```
@@ -195,7 +195,7 @@ if self.estimate_chars(messages) > self.CONTEXT_CHAR_LIMIT:
这个顺序同时满足两个条件:
1. 第一步和第二步每轮执行,第三步只在超限时执行,只有第四步会增加 API 请求。
2. `tool_result_budget` 必须早于 `micro_compact`。大结果先落盘,之后才允许旧结果变成占位符
2. 每条被缩短的工具结果都保留 `.task_outputs/tool-results/` 内的可信路径;只有仍然超限时才进入模型生成的历史摘要
顺序固定后,每一轮都从成本更低、信息更容易恢复的操作开始。
@@ -310,7 +310,7 @@ python s08_context_compact/code.py
比较它们的一级标题,并总结这些标题的命名规律。
```
任务会产生至少 5 条文件读取结果。每条新结果在模型首次读取前都会保持完整;后续轮次保留最近 3 条已读取结果,更早且较长的结果会变成 `[Earlier tool result omitted.]`。已经转存的结果会保留保存路径
任务会产生至少 5 条文件读取结果。新结果通常会完整保留到模型首次读取;如果未读取结果本身过大,则保留预览和恢复路径。后续轮次保留最近 3 条已读取结果,更早且较长的结果会变成 `[Earlier tool result saved at ...]` 引用
### 实验二:大结果转存
+97 -19
View File
@@ -18,10 +18,13 @@ s08_context_compact.py - Context Compact
| no | yes
| v
| +--------------------+
| | micro_compact | shorten old tool results
| | micro_compact | save + shorten old results
| +--------------------+
| |
| v
| fit_tool_results persist oversized new results
| |
| v
| still over limit?
| | no | yes
v v v
@@ -298,15 +301,53 @@ class ContextCompactor:
transcript.write(json.dumps(message, default=str, ensure_ascii=False) + "\n")
return path
def persist_large_output(self, tool_use_id: str, output: str) -> str:
if len(output) <= self.LARGE_RESULT_CHAR_LIMIT:
return output
def persisted_output_path(self, output: str) -> str | None:
candidate = None
if output.startswith("<persisted-output>\n"):
candidate = next(
(line.removeprefix("Full output: ")
for line in output.splitlines()
if line.startswith("Full output: ")),
None,
)
prefix = "[Earlier tool result saved at "
if output.startswith(prefix) and output.endswith("]"):
candidate = output.removeprefix(prefix).removesuffix("]")
if not candidate:
return None
path = Path(candidate)
if (not path.resolve().is_relative_to(self.tool_results_dir.resolve())
or not path.is_file()):
return None
return str(path)
def save_output(self, tool_use_id: str, output: str) -> Path:
self.tool_results_dir.mkdir(parents=True, exist_ok=True)
safe_id = re.sub(r"[^A-Za-z0-9._-]", "_", str(tool_use_id))[:120] or "unknown"
path = self.tool_results_dir / f"{safe_id}.txt"
if not path.exists():
path.write_text(output)
return f"<persisted-output>\nFull output: {path}\nPreview:\n{output[:2000]}\n</persisted-output>"
path.write_text(output, encoding="utf-8")
return path
def persisted_preview(self, tool_use_id: str, output: str,
preview_chars: int = 2000) -> str:
saved_path = self.persisted_output_path(output)
if saved_path:
path = Path(saved_path)
try:
with path.open(encoding="utf-8") as saved:
preview = saved.read(preview_chars)
except OSError:
preview = output[:preview_chars]
else:
path = self.save_output(tool_use_id, output)
preview = output[:preview_chars]
return (f"<persisted-output>\nFull output: {path}\n"
f"Preview:\n{preview}\n</persisted-output>")
def persist_large_output(self, tool_use_id: str, output: str) -> str:
if len(output) <= self.LARGE_RESULT_CHAR_LIMIT:
return output
return self.persisted_preview(tool_use_id, output)
def tool_result_budget(self, messages: list, max_chars: int | None = None) -> list:
if not messages:
@@ -328,11 +369,21 @@ class ContextCompactor:
total = sum(len(str(item.get("content", ""))) for item in blocks)
return messages
def is_archive_marker(self, message: dict) -> bool:
content = message.get("content")
match = (re.fullmatch(r"\[\d+ messages archived at (.+)\]", content)
if isinstance(content, str) else None)
if not match:
return False
path = Path(match.group(1))
return (path.resolve().is_relative_to(self.transcript_dir.resolve())
and path.is_file())
def snip_compact(self, messages: list, max_messages: int = 50) -> list:
if len(messages) <= max_messages:
return messages
head_end = 3
tail_start = len(messages) - (max_messages - head_end)
tail_start = len(messages) - (max_messages - head_end - 1)
if self.has_tool_use(messages[head_end - 1]):
while head_end < tail_start and self.is_tool_result(messages[head_end]):
head_end += 1
@@ -341,12 +392,16 @@ class ContextCompactor:
tail_start -= 1
if head_end >= tail_start:
return messages
middle = messages[head_end:tail_start]
if len(middle) == 1 and self.is_archive_marker(middle[0]):
return messages
transcript_path = self.write_transcript(messages)
marker = {"role": "user", "content":
f"[{tail_start - head_end} messages archived at {transcript_path}]"}
return [*messages[:head_end], marker, *messages[tail_start:]]
def micro_compact(self, messages: list) -> list:
def micro_compact(self, messages: list,
target_chars: int | None = None) -> list:
results = [
(message_index, block_index, block)
for message_index, message in enumerate(messages)
@@ -357,18 +412,38 @@ class ContextCompactor:
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]:
if (target_chars is not None
and self.estimate_chars(messages) <= target_chars):
break
content = str(block.get("content", ""))
if len(content) <= 120:
continue
saved_path = next(
(line.removeprefix("Full output: ") for line in content.splitlines()
if line.startswith("Full output: ")),
None,
)
block["content"] = (
f"[Earlier tool result saved at {saved_path}]"
if saved_path else "[Earlier tool result omitted.]"
)
saved_path = self.persisted_output_path(content)
if not saved_path:
saved_path = str(self.save_output(
block.get("tool_use_id", "unknown"), content))
block["content"] = f"[Earlier tool result saved at {saved_path}]"
return messages
def fit_tool_results(self, messages: list, target_chars: int) -> list:
results = [
block
for message in messages
if message.get("role") == "user" and isinstance(message.get("content"), list)
for block in message["content"]
if isinstance(block, dict) and block.get("type") == "tool_result"
]
for block in sorted(
results,
key=lambda item: len(str(item.get("content", ""))),
reverse=True):
if self.estimate_chars(messages) <= target_chars:
break
output = str(block.get("content", ""))
replacement = self.persisted_preview(
block.get("tool_use_id", "unknown"), output, preview_chars=1000)
if len(replacement) < len(output):
block["content"] = replacement
return messages
def summary_input(self, messages: list) -> str:
@@ -426,7 +501,10 @@ class ContextCompactor:
messages = self.tool_result_budget(messages)
messages = self.snip_compact(messages)
if self.estimate_chars(messages) > self.CONTEXT_CHAR_LIMIT:
messages = self.micro_compact(messages)
target = int(self.CONTEXT_CHAR_LIMIT * 0.8)
messages = self.micro_compact(messages, target)
if self.estimate_chars(messages) > self.CONTEXT_CHAR_LIMIT:
messages = self.fit_tool_results(messages, target)
if self.estimate_chars(messages) > self.CONTEXT_CHAR_LIMIT:
print("[auto compact]")
messages = self.compact_history(messages, active_request)
@@ -67,9 +67,9 @@
<rect x="80" y="300" width="600" height="46" rx="7" fill="url(#pre)" stroke="#2563eb" stroke-width="1.5"/>
<text x="100" y="320" fill="#1e40af" font-size="12" font-weight="600">Step 3</text>
<text x="155" y="320" fill="#1e40af" font-size="13" font-weight="700">micro_compact</text>
<text x="260" y="320" fill="#1e40af" font-size="11">old tool_result → placeholder (keep latest 3)</text>
<text x="260" y="320" fill="#1e40af" font-size="11">old tool_result → recovery path (keep latest 3)</text>
<text x="650" y="320" fill="#1e40af" font-size="10" text-anchor="end">compact old</text>
<text x="155" y="338" fill="#2563eb" font-size="9">Runs over the context limit and keeps the latest 3 results complete</text>
<text x="155" y="338" fill="#2563eb" font-size="9">Runs over limit; saves old results and targets 80% of the limit</text>
<!-- ===== Auto-compact title ===== -->
<rect x="20" y="358" width="720" height="24" rx="4" fill="#f1f5f9"/>

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

@@ -67,9 +67,9 @@
<rect x="80" y="300" width="600" height="46" rx="7" fill="url(#pre)" stroke="#2563eb" stroke-width="1.5"/>
<text x="100" y="320" fill="#1e40af" font-size="12" font-weight="600">Step 3</text>
<text x="155" y="320" fill="#1e40af" font-size="13" font-weight="700">micro_compact</text>
<text x="260" y="320" fill="#1e40af" font-size="11">古い tool_result → プレースホルダー(最新 3 件保持)</text>
<text x="260" y="320" fill="#1e40af" font-size="11">古い tool_result → 復元パス(最新 3 件保持)</text>
<text x="650" y="320" fill="#1e40af" font-size="10" text-anchor="end">旧結果を圧縮</text>
<text x="155" y="338" fill="#2563eb" font-size="9">上限超過時に実行し、最新 3 件は完全に保持</text>
<text x="155" y="338" fill="#2563eb" font-size="9">上限超過時に古い結果を保存し、上限の約 80% を目標に短縮</text>
<!-- ===== 自動圧縮タイトル ===== -->
<rect x="20" y="358" width="720" height="24" rx="4" fill="#f1f5f9"/>

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

@@ -67,9 +67,9 @@
<rect x="80" y="300" width="600" height="46" rx="7" fill="url(#pre)" stroke="#2563eb" stroke-width="1.5"/>
<text x="100" y="320" fill="#1e40af" font-size="12" font-weight="600">Step 3</text>
<text x="155" y="320" fill="#1e40af" font-size="13" font-weight="700">micro_compact</text>
<text x="260" y="320" fill="#1e40af" font-size="11">旧 tool_result → 占位符(保留最近 3 条)</text>
<text x="260" y="320" fill="#1e40af" font-size="11">旧 tool_result → 恢复路径(保留最近 3 条)</text>
<text x="650" y="320" fill="#1e40af" font-size="10" text-anchor="end">压旧结果</text>
<text x="155" y="338" fill="#2563eb" font-size="9">上下文超限时执行,最近 3 条结果保持完整</text>
<text x="155" y="338" fill="#2563eb" font-size="9">超限时保存旧结果,并将上下文压到阈值约 80%</text>
<!-- ===== 自动压缩标题 ===== -->
<rect x="20" y="358" width="720" height="24" rx="4" fill="#f1f5f9"/>

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

@@ -41,18 +41,18 @@
<rect x="400" y="130" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="138" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result saved at .task_outputs/...]</text>
<rect x="400" y="145" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="153" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result omitted.]</text>
<text x="408" y="153" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result saved at .task_outputs/...]</text>
<rect x="400" y="160" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="168" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result omitted.]</text>
<text x="408" y="168" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result saved at .task_outputs/...]</text>
<rect x="400" y="175" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="183" fill="#92400e" font-size="8" font-family="monospace">Read file J: (full content, 2800 chars)</text>
<text x="545" y="212" fill="#ca8a04" font-size="9" font-weight="600" text-anchor="middle">Keep latest 3; first 7 become placeholders</text>
<text x="545" y="212" fill="#ca8a04" font-size="9" font-weight="600" text-anchor="middle">Keep latest 3; first 7 become recovery paths</text>
<!-- How -->
<rect x="20" y="228" width="680" height="62" rx="6" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
<text x="35" y="248" fill="#1e3a5f" font-size="11" font-weight="600">Rule</text>
<text x="75" y="248" fill="#475569" font-size="10">Keep the latest 3; replace older results above 120 characters with placeholders.</text>
<text x="35" y="264" fill="#1e3a5f" font-size="11" font-weight="600">Placeholder</text>
<text x="105" y="264" fill="#475569" font-size="10">Keep the saved path when one exists; otherwise mark the result omitted.</text>
<text x="75" y="248" fill="#475569" font-size="10">Keep the latest 3; save and shorten older results until context reaches 80%.</text>
<text x="35" y="264" fill="#1e3a5f" font-size="11" font-weight="600">Recovery</text>
<text x="95" y="264" fill="#475569" font-size="10">Every shortened result retains its trusted path under .task_outputs/.</text>
<text x="105" y="280" fill="#94a3b8" font-size="9">The message structure remains valid for the next loop iteration.</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -41,18 +41,18 @@
<rect x="400" y="130" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="138" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result saved at .task_outputs/...]</text>
<rect x="400" y="145" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="153" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result omitted.]</text>
<text x="408" y="153" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result saved at .task_outputs/...]</text>
<rect x="400" y="160" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="168" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result omitted.]</text>
<text x="408" y="168" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result saved at .task_outputs/...]</text>
<rect x="400" y="175" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="183" fill="#92400e" font-size="8" font-family="monospace">Read file J: (完全な内容, 2800 文字)</text>
<text x="545" y="212" fill="#ca8a04" font-size="9" font-weight="600" text-anchor="middle">最新 3 件を保持、前 7 件は置換</text>
<text x="545" y="212" fill="#ca8a04" font-size="9" font-weight="600" text-anchor="middle">最新 3 件を保持、前 7 件は復元パスへ置換</text>
<!-- 原理 -->
<rect x="20" y="228" width="680" height="62" rx="6" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
<text x="35" y="248" fill="#1e3a5f" font-size="11" font-weight="600">処理規則</text>
<text x="95" y="248" fill="#475569" font-size="10">最新 3 件を保持し、120 文字超の古い結果をプレースホルダーに置換</text>
<text x="35" y="264" fill="#1e3a5f" font-size="11" font-weight="600">プレースホルダー</text>
<text x="125" y="264" fill="#475569" font-size="10">保存先があればパスを残し、なければ省略済みと示す。</text>
<text x="95" y="248" fill="#475569" font-size="10">最新 3 件を保持し、古い結果を保存して上限の 80% まで短縮</text>
<text x="35" y="264" fill="#1e3a5f" font-size="11" font-weight="600">復元方法</text>
<text x="95" y="264" fill="#475569" font-size="10">短縮した各結果に .task_outputs/ 内の信頼できるパスを残す。</text>
<text x="125" y="280" fill="#94a3b8" font-size="9">メッセージ構造を保ったまま次のループへ進める。</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

+7 -7
View File
@@ -11,7 +11,7 @@
<rect width="720" height="300" fill="#fafbfc" rx="8"/>
<rect x="0" y="0" width="720" height="38" fill="url(#header)" rx="8"/>
<rect x="0" y="30" width="720" height="8" fill="url(#header)"/>
<text x="360" y="25" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">Step 3: micro_compact,旧结果占位替换</text>
<text x="360" y="25" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">Step 3: micro_compact,旧结果可恢复替换</text>
<!-- 痛点 -->
<rect x="20" y="54" width="680" height="36" rx="6" fill="#fef2f2" stroke="#fca5a5" stroke-width="1"/>
@@ -40,18 +40,18 @@
<rect x="400" y="130" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="138" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result saved at .task_outputs/...]</text>
<rect x="400" y="145" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="153" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result omitted.]</text>
<text x="408" y="153" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result saved at .task_outputs/...]</text>
<rect x="400" y="160" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="168" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result omitted.]</text>
<text x="408" y="168" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result saved at .task_outputs/...]</text>
<rect x="400" y="175" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="183" fill="#92400e" font-size="8" font-family="monospace">Read file J: (完整内容, 2800 字符)</text>
<text x="545" y="212" fill="#ca8a04" font-size="9" font-weight="600">保留最近 3 条,前 7 条变占位</text>
<text x="545" y="212" fill="#ca8a04" font-size="9" font-weight="600">保留最近 3 条,前 7 条变恢复路径</text>
<!-- 原理 -->
<rect x="20" y="228" width="680" height="62" rx="6" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
<text x="35" y="248" fill="#1e3a5f" font-size="11" font-weight="600">处理规则</text>
<text x="95" y="248" fill="#475569" font-size="10">最近 3 条保持完整,更早且超过 120 字符的结果替换为占位符</text>
<text x="35" y="264" fill="#1e3a5f" font-size="11" font-weight="600">占位内容</text>
<text x="95" y="264" fill="#475569" font-size="10">有落盘路径时保留路径,否则标记该结果已省略</text>
<text x="95" y="248" fill="#475569" font-size="10">最近 3 条保持完整,更早的结果先保存,再逐条缩短到阈值 80%</text>
<text x="35" y="264" fill="#1e3a5f" font-size="11" font-weight="600">恢复方式</text>
<text x="95" y="264" fill="#475569" font-size="10">每条缩短结果都保留 .task_outputs/ 下的可信路径</text>
<text x="95" y="280" fill="#94a3b8" font-size="9">消息结构保持不变,后续循环仍可继续处理。</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

+2
View File
@@ -154,6 +154,8 @@ LLM call の前に compaction pipeline を走らせる:
tool_result_budget → snip_compact → micro_compact → compact_history
```
`snip_compact` は中間メッセージを切る前に完全な履歴を保存する。`micro_compact` はコンテキストが上限を超えた場合にだけ実行し、古い既読結果を保存して復元パスへ置き換え、最新 3 件を完全に保ち、上限の約 80% で停止する。未読の新しい結果自体が大きすぎる場合、S15 は履歴要約を検討する前に preview と完全な出力へのパスを残す。
model call は recovery で包む:
- 429: exponential backoff retry
+2
View File
@@ -154,6 +154,8 @@ Before the LLM call, S15 runs the compaction pipeline:
tool_result_budget → snip_compact → micro_compact → compact_history
```
`snip_compact` archives the complete history before trimming its middle. `micro_compact` runs only above the context limit: it saves older consumed results before replacing them with recovery paths, keeps the latest 3 complete, and stops near 80% of the limit. If a new unseen result is itself too large, S15 keeps a preview and the full-output path before considering history summarization.
The model call is wrapped with recovery:
- 429: exponential backoff retry
+2
View File
@@ -154,6 +154,8 @@ LLM 前先跑压缩管线:
tool_result_budget → snip_compact → micro_compact → compact_history
```
`snip_compact` 会先归档完整历史,再裁掉中段消息。`micro_compact` 只在上下文超限时运行:它先保存较早且已读取的结果,再用恢复路径替换;最近 3 条保持完整,并在接近阈值 80% 时停止。如果未读取的新结果本身过大,S15 会先保留预览和完整输出路径,再考虑总结历史。
调用模型时再包一层恢复:
- 429:指数退避重试
+99 -14
View File
@@ -1975,15 +1975,55 @@ def unseen_tool_result_positions(messages: list) -> set[tuple[int, int]]:
}
def persisted_output_path(output: str) -> str | None:
candidate = None
if output.startswith("<persisted-output>\n"):
candidate = next(
(line.removeprefix("Full output: ") for line in output.splitlines()
if line.startswith("Full output: ")),
None,
)
prefix = "[Earlier tool result saved at "
if output.startswith(prefix) and output.endswith("]"):
candidate = output.removeprefix(prefix).removesuffix("]")
if not candidate:
return None
path = Path(candidate)
if (not path.resolve().is_relative_to(TOOL_RESULTS_DIR.resolve())
or not path.is_file()):
return None
return str(path)
def save_output(tool_use_id: str, output: str) -> Path:
TOOL_RESULTS_DIR.mkdir(parents=True, exist_ok=True)
safe_id = re.sub(r"[^A-Za-z0-9._-]", "_", str(tool_use_id))[:120] or "unknown"
path = TOOL_RESULTS_DIR / f"{safe_id}.txt"
path.write_text(output, encoding="utf-8")
return path
def persisted_preview(tool_use_id: str, output: str,
preview_chars: int = 2000) -> str:
saved_path = persisted_output_path(output)
if saved_path:
path = Path(saved_path)
try:
with path.open(encoding="utf-8") as saved:
preview = saved.read(preview_chars)
except OSError:
preview = output[:preview_chars]
else:
path = save_output(tool_use_id, output)
preview = output[:preview_chars]
return (f"<persisted-output>\nFull output: {path}\n"
f"Preview:\n{preview}\n</persisted-output>")
def persist_large_output(tool_use_id: str, output: str) -> str:
if len(output) <= PERSIST_THRESHOLD:
return output
TOOL_RESULTS_DIR.mkdir(parents=True, exist_ok=True)
path = TOOL_RESULTS_DIR / f"{tool_use_id}.txt"
if not path.exists():
path.write_text(output)
return (f"<persisted-output>\nFull output: {path}\n"
f"Preview:\n{output[:2000]}\n</persisted-output>")
return persisted_preview(tool_use_id, output)
def tool_result_budget(messages: list, max_bytes: int = 200_000) -> list:
@@ -2010,10 +2050,22 @@ def tool_result_budget(messages: list, max_bytes: int = 200_000) -> list:
return messages
def is_archive_marker(message: dict) -> bool:
content = message.get("content")
match = (re.fullmatch(r"\[\d+ messages archived at (.+)\]", content)
if isinstance(content, str) else None)
if not match:
return False
path = Path(match.group(1))
return (path.resolve().is_relative_to(TRANSCRIPT_DIR.resolve())
and path.is_file())
def snip_compact(messages: list, max_messages: int = 50) -> list:
if len(messages) <= max_messages:
return messages
head_end, tail_start = 3, len(messages) - (max_messages - 3)
head_end = 3
tail_start = len(messages) - (max_messages - head_end - 1)
if head_end > 0 and message_has_tool_use(messages[head_end - 1]):
while head_end < len(messages) and is_tool_result_message(messages[head_end]):
head_end += 1
@@ -2023,26 +2075,55 @@ def snip_compact(messages: list, max_messages: int = 50) -> list:
tail_start -= 1
if head_end >= tail_start:
return messages
middle = messages[head_end:tail_start]
if len(middle) == 1 and is_archive_marker(middle[0]):
return messages
snipped = tail_start - head_end
transcript = write_transcript(messages)
return (messages[:head_end]
+ [{"role": "user", "content": f"[snipped {snipped} messages]"}]
+ [{"role": "user", "content":
f"[{snipped} messages archived at {transcript}]"}]
+ messages[tail_start:])
def micro_compact(messages: list) -> list:
def micro_compact(messages: list, target_chars: int | None = None) -> list:
tool_results = collect_tool_results(messages)
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.]"
if target_chars is not None and estimate_size(messages) <= target_chars:
break
content = str(block.get("content", ""))
if len(content) <= 120:
continue
saved_path = persisted_output_path(content)
if not saved_path:
saved_path = str(save_output(
block.get("tool_use_id", "unknown"), content))
block["content"] = f"[Earlier tool result saved at {saved_path}]"
return messages
def fit_tool_results(messages: list, target_chars: int) -> list:
results = [block for _, _, block in collect_tool_results(messages)]
for block in sorted(
results,
key=lambda item: len(str(item.get("content", ""))),
reverse=True):
if estimate_size(messages) <= target_chars:
break
output = str(block.get("content", ""))
replacement = persisted_preview(
block.get("tool_use_id", "unknown"), output, preview_chars=1000)
if len(replacement) < len(output):
block["content"] = replacement
return messages
def write_transcript(messages: list) -> Path:
TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True)
path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl"
with path.open("w") as f:
path = TRANSCRIPT_DIR / f"transcript_{time.time_ns()}.jsonl"
with path.open("x") as f:
for msg in messages:
f.write(json.dumps(msg, default=str) + "\n")
return path
@@ -2970,7 +3051,11 @@ def prepare_context(messages: list, active_request: str) -> list:
# Every LLM turn enters through the same context budget pipeline.
messages[:] = tool_result_budget(messages)
messages[:] = snip_compact(messages)
messages[:] = micro_compact(messages)
if estimate_size(messages) > CONTEXT_LIMIT:
target = int(CONTEXT_LIMIT * 0.8)
messages[:] = micro_compact(messages, target)
if estimate_size(messages) > CONTEXT_LIMIT:
messages[:] = fit_tool_results(messages, target)
if estimate_size(messages) > CONTEXT_LIMIT:
messages[:] = compact_history(messages, active_request)
return messages
+125
View File
@@ -132,7 +132,104 @@ def compaction_api(module):
return getattr(module, "COMPACTOR", module)
def prepare_context(module, messages, active_request="continue"):
api = compaction_api(module)
if hasattr(api, "prepare"):
return api.prepare(messages, active_request)
return module.prepare_context(messages, active_request)
class CompactionToolPairTests(unittest.TestCase):
def test_prepare_preserves_consumed_results_below_pressure_limit(self):
for name, path in MODULES.items():
with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp:
messages = []
expected = {}
for index in range(5):
tool_id = f"tool-{index}"
output = f"{tool_id}: " + "x" * 160
expected[tool_id] = output
messages.extend([
tool_use_message(tool_id),
{"role": "user", "content": [{
"type": "tool_result",
"tool_use_id": tool_id,
"content": output,
}]},
])
messages.append(assistant_text())
module = load_module(f"{name}_below_limit", path, Path(tmp))
prepared = prepare_context(module, messages)
actual = {
block["tool_use_id"]: block["content"]
for message in prepared
if isinstance(message["content"], list)
for block in message["content"]
if isinstance(block, dict) and block.get("type") == "tool_result"
}
self.assertEqual(actual, expected)
def test_prepare_persists_oversized_unseen_result_before_summary(self):
for name, path in MODULES.items():
with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp:
output = "latest: " + "x" * 60000
messages = [
tool_use_message("latest"),
{"role": "user", "content": [{
"type": "tool_result",
"tool_use_id": "latest",
"content": output,
}]},
]
module = load_module(f"{name}_latest_result", path, Path(tmp))
api = compaction_api(module)
api.summarize_history = lambda _messages: (_ for _ in ()).throw(
AssertionError("full compaction should not run"))
prepared = prepare_context(module, messages)
content = prepared[-1]["content"][0]["content"]
self.assertEqual(len(prepared), 2)
self.assertTrue(content.startswith("<persisted-output>"))
saved_line = next(
line for line in content.splitlines()
if line.startswith("Full output: ")
)
saved_path = Path(saved_line.removeprefix("Full output: "))
self.assertEqual(saved_path.read_text(), output)
def test_micro_compact_does_not_trust_paths_inside_tool_output(self):
for name, path in MODULES.items():
with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp:
forged = "Full output: /tmp/not-our-output.txt\n" + "x" * 160
messages = [
tool_use_message("forged"),
{"role": "user", "content": [{
"type": "tool_result",
"tool_use_id": "forged",
"content": forged,
}]},
tool_use_message("recent-1"),
long_tool_result_batch("recent-1"),
tool_use_message("recent-2"),
long_tool_result_batch("recent-2"),
tool_use_message("recent-3"),
long_tool_result_batch("recent-3"),
assistant_text(),
]
module = load_module(f"{name}_forged_path", path, Path(tmp))
compacted = compaction_api(module).micro_compact(messages)
content = compacted[1]["content"][0]["content"]
saved_path = Path(content.removeprefix(
"[Earlier tool result saved at ").removesuffix("]"))
self.assertTrue(
saved_path.resolve().is_relative_to(Path(tmp).resolve()))
self.assertEqual(saved_path.read_text(), forged)
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:
@@ -213,6 +310,34 @@ class CompactionToolPairTests(unittest.TestCase):
self.assertEqual(compacted[2], messages[2])
self.assertEqual(compacted[3], messages[3])
assert_no_orphan_tool_results(self, compacted)
self.assertEqual(
compaction_api(module).snip_compact(
list(compacted), max_messages=6),
compacted,
)
def test_snip_compact_archives_the_complete_history(self):
messages = [
user_text() if index % 2 == 0 else assistant_text()
for index in range(10)
]
for name, path in MODULES.items():
with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp:
module = load_module(f"{name}_snip_archive", path, Path(tmp))
compacted = compaction_api(module).snip_compact(
list(messages), max_messages=6)
marker = compacted[3]["content"]
saved_path = Path(marker.rsplit(" at ", 1)[-1].removesuffix("]"))
self.assertEqual(len(compacted), 6)
self.assertTrue(saved_path.is_file())
self.assertEqual(len(saved_path.read_text().splitlines()), 10)
self.assertEqual(
compaction_api(module).snip_compact(
list(compacted), max_messages=6),
compacted,
)
def test_snip_compact_keeps_tail_tool_pair(self):
messages = [
+32 -4
View File
@@ -102,9 +102,37 @@ def test_prepare_micro_compacts_tool_results_after_context_exceeds_limit(
if block["type"] == "tool_result"
]
assert actual_results[:2] == [
"[Earlier tool result omitted.]",
"[Earlier tool result omitted.]",
]
assert all(result.startswith("[Earlier tool result saved at ")
for result in actual_results[:2])
for index, result in enumerate(actual_results[:2]):
saved_path = Path(result.removeprefix(
"[Earlier tool result saved at ").removesuffix("]"))
assert saved_path.read_text() == f"result-{index}:" + "x" * 1000
assert all(result.startswith(f"result-{index}:")
for index, result in enumerate(actual_results[2:], start=2))
def test_prepare_persists_oversized_unseen_result_before_full_compact(
tmp_path, monkeypatch):
lesson = load_lesson(monkeypatch, tmp_path)
output = "latest-result:" + "x" * 60000
messages = [
{"role": "assistant", "content": [
{"type": "tool_use", "id": "latest", "name": "read_file", "input": {}}
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "latest", "content": output}
]},
]
compactor = lesson["COMPACTOR"]
compactor.summarize_history = lambda _messages: (_ for _ in ()).throw(
AssertionError("full compaction should not run"))
prepared = compactor.prepare(messages, "inspect the result")
content = prepared[-1]["content"][0]["content"]
assert len(prepared) == 2
assert content.startswith("<persisted-output>")
saved_line = next(line for line in content.splitlines()
if line.startswith("Full output: "))
assert Path(saved_line.removeprefix("Full output: ")).read_text() == output
@@ -67,9 +67,9 @@
<rect x="80" y="300" width="600" height="46" rx="7" fill="url(#pre)" stroke="#2563eb" stroke-width="1.5"/>
<text x="100" y="320" fill="#1e40af" font-size="12" font-weight="600">Step 3</text>
<text x="155" y="320" fill="#1e40af" font-size="13" font-weight="700">micro_compact</text>
<text x="260" y="320" fill="#1e40af" font-size="11">old tool_result → placeholder (keep latest 3)</text>
<text x="260" y="320" fill="#1e40af" font-size="11">old tool_result → recovery path (keep latest 3)</text>
<text x="650" y="320" fill="#1e40af" font-size="10" text-anchor="end">compact old</text>
<text x="155" y="338" fill="#2563eb" font-size="9">Runs over the context limit and keeps the latest 3 results complete</text>
<text x="155" y="338" fill="#2563eb" font-size="9">Runs over limit; saves old results and targets 80% of the limit</text>
<!-- ===== Auto-compact title ===== -->
<rect x="20" y="358" width="720" height="24" rx="4" fill="#f1f5f9"/>

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

@@ -67,9 +67,9 @@
<rect x="80" y="300" width="600" height="46" rx="7" fill="url(#pre)" stroke="#2563eb" stroke-width="1.5"/>
<text x="100" y="320" fill="#1e40af" font-size="12" font-weight="600">Step 3</text>
<text x="155" y="320" fill="#1e40af" font-size="13" font-weight="700">micro_compact</text>
<text x="260" y="320" fill="#1e40af" font-size="11">古い tool_result → プレースホルダー(最新 3 件保持)</text>
<text x="260" y="320" fill="#1e40af" font-size="11">古い tool_result → 復元パス(最新 3 件保持)</text>
<text x="650" y="320" fill="#1e40af" font-size="10" text-anchor="end">旧結果を圧縮</text>
<text x="155" y="338" fill="#2563eb" font-size="9">上限超過時に実行し、最新 3 件は完全に保持</text>
<text x="155" y="338" fill="#2563eb" font-size="9">上限超過時に古い結果を保存し、上限の約 80% を目標に短縮</text>
<!-- ===== 自動圧縮タイトル ===== -->
<rect x="20" y="358" width="720" height="24" rx="4" fill="#f1f5f9"/>

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

@@ -67,9 +67,9 @@
<rect x="80" y="300" width="600" height="46" rx="7" fill="url(#pre)" stroke="#2563eb" stroke-width="1.5"/>
<text x="100" y="320" fill="#1e40af" font-size="12" font-weight="600">Step 3</text>
<text x="155" y="320" fill="#1e40af" font-size="13" font-weight="700">micro_compact</text>
<text x="260" y="320" fill="#1e40af" font-size="11">旧 tool_result → 占位符(保留最近 3 条)</text>
<text x="260" y="320" fill="#1e40af" font-size="11">旧 tool_result → 恢复路径(保留最近 3 条)</text>
<text x="650" y="320" fill="#1e40af" font-size="10" text-anchor="end">压旧结果</text>
<text x="155" y="338" fill="#2563eb" font-size="9">上下文超限时执行,最近 3 条结果保持完整</text>
<text x="155" y="338" fill="#2563eb" font-size="9">超限时保存旧结果,并将上下文压到阈值约 80%</text>
<!-- ===== 自动压缩标题 ===== -->
<rect x="20" y="358" width="720" height="24" rx="4" fill="#f1f5f9"/>

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

@@ -41,18 +41,18 @@
<rect x="400" y="130" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="138" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result saved at .task_outputs/...]</text>
<rect x="400" y="145" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="153" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result omitted.]</text>
<text x="408" y="153" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result saved at .task_outputs/...]</text>
<rect x="400" y="160" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="168" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result omitted.]</text>
<text x="408" y="168" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result saved at .task_outputs/...]</text>
<rect x="400" y="175" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="183" fill="#92400e" font-size="8" font-family="monospace">Read file J: (full content, 2800 chars)</text>
<text x="545" y="212" fill="#ca8a04" font-size="9" font-weight="600" text-anchor="middle">Keep latest 3; first 7 become placeholders</text>
<text x="545" y="212" fill="#ca8a04" font-size="9" font-weight="600" text-anchor="middle">Keep latest 3; first 7 become recovery paths</text>
<!-- How -->
<rect x="20" y="228" width="680" height="62" rx="6" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
<text x="35" y="248" fill="#1e3a5f" font-size="11" font-weight="600">Rule</text>
<text x="75" y="248" fill="#475569" font-size="10">Keep the latest 3; replace older results above 120 characters with placeholders.</text>
<text x="35" y="264" fill="#1e3a5f" font-size="11" font-weight="600">Placeholder</text>
<text x="105" y="264" fill="#475569" font-size="10">Keep the saved path when one exists; otherwise mark the result omitted.</text>
<text x="75" y="248" fill="#475569" font-size="10">Keep the latest 3; save and shorten older results until context reaches 80%.</text>
<text x="35" y="264" fill="#1e3a5f" font-size="11" font-weight="600">Recovery</text>
<text x="95" y="264" fill="#475569" font-size="10">Every shortened result retains its trusted path under .task_outputs/.</text>
<text x="105" y="280" fill="#94a3b8" font-size="9">The message structure remains valid for the next loop iteration.</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -41,18 +41,18 @@
<rect x="400" y="130" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="138" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result saved at .task_outputs/...]</text>
<rect x="400" y="145" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="153" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result omitted.]</text>
<text x="408" y="153" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result saved at .task_outputs/...]</text>
<rect x="400" y="160" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="168" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result omitted.]</text>
<text x="408" y="168" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result saved at .task_outputs/...]</text>
<rect x="400" y="175" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="183" fill="#92400e" font-size="8" font-family="monospace">Read file J: (完全な内容, 2800 文字)</text>
<text x="545" y="212" fill="#ca8a04" font-size="9" font-weight="600" text-anchor="middle">最新 3 件を保持、前 7 件は置換</text>
<text x="545" y="212" fill="#ca8a04" font-size="9" font-weight="600" text-anchor="middle">最新 3 件を保持、前 7 件は復元パスへ置換</text>
<!-- 原理 -->
<rect x="20" y="228" width="680" height="62" rx="6" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
<text x="35" y="248" fill="#1e3a5f" font-size="11" font-weight="600">処理規則</text>
<text x="95" y="248" fill="#475569" font-size="10">最新 3 件を保持し、120 文字超の古い結果をプレースホルダーに置換</text>
<text x="35" y="264" fill="#1e3a5f" font-size="11" font-weight="600">プレースホルダー</text>
<text x="125" y="264" fill="#475569" font-size="10">保存先があればパスを残し、なければ省略済みと示す。</text>
<text x="95" y="248" fill="#475569" font-size="10">最新 3 件を保持し、古い結果を保存して上限の 80% まで短縮</text>
<text x="35" y="264" fill="#1e3a5f" font-size="11" font-weight="600">復元方法</text>
<text x="95" y="264" fill="#475569" font-size="10">短縮した各結果に .task_outputs/ 内の信頼できるパスを残す。</text>
<text x="125" y="280" fill="#94a3b8" font-size="9">メッセージ構造を保ったまま次のループへ進める。</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -11,7 +11,7 @@
<rect width="720" height="300" fill="#fafbfc" rx="8"/>
<rect x="0" y="0" width="720" height="38" fill="url(#header)" rx="8"/>
<rect x="0" y="30" width="720" height="8" fill="url(#header)"/>
<text x="360" y="25" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">Step 3: micro_compact,旧结果占位替换</text>
<text x="360" y="25" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">Step 3: micro_compact,旧结果可恢复替换</text>
<!-- 痛点 -->
<rect x="20" y="54" width="680" height="36" rx="6" fill="#fef2f2" stroke="#fca5a5" stroke-width="1"/>
@@ -40,18 +40,18 @@
<rect x="400" y="130" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="138" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result saved at .task_outputs/...]</text>
<rect x="400" y="145" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="153" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result omitted.]</text>
<text x="408" y="153" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result saved at .task_outputs/...]</text>
<rect x="400" y="160" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="168" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result omitted.]</text>
<text x="408" y="168" fill="#92400e" font-size="8" font-family="monospace">[Earlier tool result saved at .task_outputs/...]</text>
<rect x="400" y="175" width="290" height="10" rx="2" fill="#fef3c7"/>
<text x="408" y="183" fill="#92400e" font-size="8" font-family="monospace">Read file J: (完整内容, 2800 字符)</text>
<text x="545" y="212" fill="#ca8a04" font-size="9" font-weight="600">保留最近 3 条,前 7 条变占位</text>
<text x="545" y="212" fill="#ca8a04" font-size="9" font-weight="600">保留最近 3 条,前 7 条变恢复路径</text>
<!-- 原理 -->
<rect x="20" y="228" width="680" height="62" rx="6" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
<text x="35" y="248" fill="#1e3a5f" font-size="11" font-weight="600">处理规则</text>
<text x="95" y="248" fill="#475569" font-size="10">最近 3 条保持完整,更早且超过 120 字符的结果替换为占位符</text>
<text x="35" y="264" fill="#1e3a5f" font-size="11" font-weight="600">占位内容</text>
<text x="95" y="264" fill="#475569" font-size="10">有落盘路径时保留路径,否则标记该结果已省略</text>
<text x="95" y="248" fill="#475569" font-size="10">最近 3 条保持完整,更早的结果先保存,再逐条缩短到阈值 80%</text>
<text x="35" y="264" fill="#1e3a5f" font-size="11" font-weight="600">恢复方式</text>
<text x="95" y="264" fill="#475569" font-size="10">每条缩短结果都保留 .task_outputs/ 下的可信路径</text>
<text x="95" y="280" fill="#94a3b8" font-size="9">消息结构保持不变,后续循环仍可继续处理。</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long