fix: add hysteresis to message-count compaction (#582)
* fix: add hysteresis to message-count compaction * ci: retrigger checks after approval timeout
@@ -88,11 +88,15 @@ for block in ranked:
|
||||
|
||||
## ステップ 2:snip_compact
|
||||
|
||||
履歴が 50 メッセージを超えると、`snip_compact` は完全な履歴を `.transcripts/` に保存してから、先頭 3 件と最新 46 件を保持します。残り 1 件は archive marker に使い、削除した件数と完全な transcript の保存先を記録します。
|
||||
履歴が `trigger_messages`(デフォルト 50 件)に達すると、`snip_compact` は完全な履歴を `.transcripts/` に保存してから、`target_messages`(デフォルト 30 件)を目標に切り詰めます。先頭 3 件、archive marker 1 件、最新 26 件を保持します。marker は削除した件数と完全な transcript の保存先を記録します。発動閾値と目標件数を分けることで、新しいメッセージを追加する余裕が生まれ、その後の model call ごとに再び履歴を書き出すことを避けます。その代わり、context に直接残る最近のメッセージは少なくなります。古い内容は transcript から取得できます。
|
||||
|
||||
パラメータは `5 <= target_messages < trigger_messages` を満たす必要があります。目標件数には marker を含め、先頭 3 件に加えて最新のメッセージを少なくとも 1 件保持します。
|
||||
|
||||
```python
|
||||
if len(messages) < trigger_messages:
|
||||
return messages
|
||||
head_end = 3
|
||||
tail_start = len(messages) - (max_messages - head_end - 1)
|
||||
tail_start = len(messages) - (target_messages - head_end - 1)
|
||||
|
||||
if self.has_tool_use(messages[head_end - 1]):
|
||||
while (head_end < tail_start
|
||||
@@ -110,7 +114,7 @@ marker = {"role": "user", "content":
|
||||
messages = [*messages[:head_end], marker, *messages[tail_start:]]
|
||||
```
|
||||
|
||||
切断位置では、`assistant(tool_use)` と `user(tool_result)` の組を保護します。対応するツール呼び出しがない孤立した結果を含むと、次の API リクエストは無効になります。
|
||||
切断位置では、`assistant(tool_use)` と `user(tool_result)` の組を保護するため、保持件数が目標を少し上回る場合があります。対応するツール呼び出しがない孤立した結果を含むと、次の API リクエストは無効になります。
|
||||
|
||||
このステップはメッセージ数を抑えます。保持されたメッセージ内のツール結果は、まだ長い可能性があります。
|
||||
|
||||
@@ -340,4 +344,4 @@ s08_context_compact/code.py と s09_memory/code.py を比較し、
|
||||
|
||||
s09 Memory では、メモリの書き込み、検索、整理を実装します。
|
||||
|
||||
<!-- translation-sync: zh@v8, en@v8, ja@v8 -->
|
||||
<!-- translation-sync: zh@v9, en@v9, ja@v9 -->
|
||||
|
||||
@@ -88,11 +88,15 @@ 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 46 messages. The archive marker occupies the remaining slot, records how many messages were removed, and points to the complete transcript.
|
||||
Once the history reaches `trigger_messages` (50 by default), `snip_compact` writes the complete history to `.transcripts/`, then trims toward `target_messages` (30 by default): the first 3 messages, an archive marker, and the latest 26 messages. The marker records how many messages were removed and points to the complete transcript. Separate trigger and target counts leave room for new messages, avoiding another archive write on every subsequent model call. The tradeoff is keeping fewer recent messages directly in context; older content remains available in the transcript.
|
||||
|
||||
Require `5 <= target_messages < trigger_messages`: the target includes the marker and leaves at least one recent message after the initial three.
|
||||
|
||||
```python
|
||||
if len(messages) < trigger_messages:
|
||||
return messages
|
||||
head_end = 3
|
||||
tail_start = len(messages) - (max_messages - head_end - 1)
|
||||
tail_start = len(messages) - (target_messages - head_end - 1)
|
||||
|
||||
if self.has_tool_use(messages[head_end - 1]):
|
||||
while (head_end < tail_start
|
||||
@@ -110,7 +114,7 @@ marker = {"role": "user", "content":
|
||||
messages = [*messages[:head_end], marker, *messages[tail_start:]]
|
||||
```
|
||||
|
||||
The cut points protect every `assistant(tool_use)` and `user(tool_result)` pair. An orphaned result has no matching tool call, so the next API request would be invalid.
|
||||
The cut points protect every `assistant(tool_use)` and `user(tool_result)` pair, so the retained count may slightly exceed the target. An orphaned result has no matching tool call, so the next API request would be invalid.
|
||||
|
||||
This step controls the number of messages. Tool results inside the retained messages may still be long.
|
||||
|
||||
@@ -340,4 +344,4 @@ Context compaction lets an Agent continue a long task within a limited window. I
|
||||
|
||||
s09 Memory adds memory writing, retrieval, and consolidation.
|
||||
|
||||
<!-- translation-sync: zh@v8, en@v8, ja@v8 -->
|
||||
<!-- translation-sync: zh@v9, en@v9, ja@v9 -->
|
||||
|
||||
@@ -88,11 +88,15 @@ for block in ranked:
|
||||
|
||||
## 第二步:snip_compact
|
||||
|
||||
消息数量超过 50 条后,`snip_compact` 先把完整历史写入 `.transcripts/`,再保留最初 3 条和最近 46 条。剩余一个位置用于归档标记,其中写明删去了多少条消息,以及完整记录保存在哪里。
|
||||
消息数量达到 `trigger_messages`(默认 50 条)后,`snip_compact` 先把完整历史写入 `.transcripts/`,再向 `target_messages`(默认 30 条)裁剪:保留最初 3 条、1 条归档标记和最近 26 条。标记写明删去了多少条消息,以及完整记录保存在哪里。触发阈值与目标数量分离,为新增消息留出空间,避免后续每次模型调用都再次写入归档。代价是直接保留在上下文中的近期消息更少;较早的内容仍可从归档中取回。
|
||||
|
||||
参数需满足 `5 <= target_messages < trigger_messages`:目标数量包含归档标记,并在最初 3 条之外至少保留 1 条最近消息。
|
||||
|
||||
```python
|
||||
if len(messages) < trigger_messages:
|
||||
return messages
|
||||
head_end = 3
|
||||
tail_start = len(messages) - (max_messages - head_end - 1)
|
||||
tail_start = len(messages) - (target_messages - head_end - 1)
|
||||
|
||||
if self.has_tool_use(messages[head_end - 1]):
|
||||
while (head_end < tail_start
|
||||
@@ -110,7 +114,7 @@ marker = {"role": "user", "content":
|
||||
messages = [*messages[:head_end], marker, *messages[tail_start:]]
|
||||
```
|
||||
|
||||
切点需要保护 `assistant(tool_use)` 和 `user(tool_result)` 的配对关系。孤立的工具结果缺少对应调用,下一次 API 请求会被判定为无效。
|
||||
切点需要保护 `assistant(tool_use)` 和 `user(tool_result)` 的配对关系,因此保留的消息数可能略高于目标数量。孤立的工具结果缺少对应调用,下一次 API 请求会被判定为无效。
|
||||
|
||||
这一步控制消息数量,但保留下来的旧消息仍可能包含很长的工具结果。
|
||||
|
||||
@@ -340,4 +344,4 @@ python s08_context_compact/code.py
|
||||
|
||||
s09 Memory 将实现记忆写入、检索与整理。
|
||||
|
||||
<!-- translation-sync: zh@v8, en@v8, ja@v8 -->
|
||||
<!-- translation-sync: zh@v9, en@v9, ja@v9 -->
|
||||
|
||||
@@ -395,11 +395,13 @@ class ContextCompactor:
|
||||
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:
|
||||
def snip_compact(self, messages: list, trigger_messages: int = 50, target_messages: int = 30) -> list:
|
||||
if not 5 <= target_messages < trigger_messages:
|
||||
raise ValueError("Require 5 <= target_messages < trigger_messages")
|
||||
if len(messages) < trigger_messages:
|
||||
return messages
|
||||
head_end = 3
|
||||
tail_start = len(messages) - (max_messages - head_end - 1)
|
||||
tail_start = len(messages) - (target_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
|
||||
|
||||
@@ -56,9 +56,9 @@
|
||||
<rect x="80" y="240" width="600" height="46" rx="7" fill="url(#pre)" stroke="#2563eb" stroke-width="1.5"/>
|
||||
<text x="100" y="260" fill="#1e40af" font-size="12" font-weight="600">Step 2</text>
|
||||
<text x="155" y="260" fill="#1e40af" font-size="13" font-weight="700">snip_compact</text>
|
||||
<text x="260" y="260" fill="#1e40af" font-size="11">messages > 50 → trim middle</text>
|
||||
<text x="260" y="260" fill="#1e40af" font-size="11">messages ≥ 50 → target 30</text>
|
||||
<text x="650" y="260" fill="#1e40af" font-size="10" text-anchor="end">keep head/tail</text>
|
||||
<text x="155" y="278" fill="#2563eb" font-size="9">Trims above 50 messages and preserves tool pairs</text>
|
||||
<text x="155" y="278" fill="#2563eb" font-size="9">Leaves room for new messages; tool pairs may exceed the target</text>
|
||||
|
||||
<!-- Step 2 to Step 3 -->
|
||||
<line x1="380" y1="286" x2="380" y2="298" stroke="#94a3b8" stroke-width="1" marker-end="url(#arrow-d)"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
@@ -56,9 +56,9 @@
|
||||
<rect x="80" y="240" width="600" height="46" rx="7" fill="url(#pre)" stroke="#2563eb" stroke-width="1.5"/>
|
||||
<text x="100" y="260" fill="#1e40af" font-size="12" font-weight="600">Step 2</text>
|
||||
<text x="155" y="260" fill="#1e40af" font-size="13" font-weight="700">snip_compact</text>
|
||||
<text x="260" y="260" fill="#1e40af" font-size="11">メッセージ > 50 → 中間をトリム</text>
|
||||
<text x="260" y="260" fill="#1e40af" font-size="11">メッセージ ≥ 50 → 目標 30 件</text>
|
||||
<text x="650" y="260" fill="#1e40af" font-size="10" text-anchor="end">先頭/末尾保持</text>
|
||||
<text x="155" y="278" fill="#2563eb" font-size="9">50 件を超えたら中間を省略し、ツールの組を保持</text>
|
||||
<text x="155" y="278" fill="#2563eb" font-size="9">追加の余裕を確保;ツールの組の保持で目標を少し超える場合あり</text>
|
||||
|
||||
<!-- Step 2 to Step 3 -->
|
||||
<line x1="380" y1="286" x2="380" y2="298" stroke="#94a3b8" stroke-width="1" marker-end="url(#arrow-d)"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
@@ -56,9 +56,9 @@
|
||||
<rect x="80" y="240" width="600" height="46" rx="7" fill="url(#pre)" stroke="#2563eb" stroke-width="1.5"/>
|
||||
<text x="100" y="260" fill="#1e40af" font-size="12" font-weight="600">Step 2</text>
|
||||
<text x="155" y="260" fill="#1e40af" font-size="13" font-weight="700">snip_compact</text>
|
||||
<text x="260" y="260" fill="#1e40af" font-size="11">消息 > 50 条 → 裁掉中间</text>
|
||||
<text x="260" y="260" fill="#1e40af" font-size="11">消息 ≥ 50 条 → 目标 30 条</text>
|
||||
<text x="650" y="260" fill="#1e40af" font-size="10" text-anchor="end">保留头尾</text>
|
||||
<text x="155" y="278" fill="#2563eb" font-size="9">消息超过 50 条时裁剪,并保护工具调用与结果的配对</text>
|
||||
<text x="155" y="278" fill="#2563eb" font-size="9">留出新增消息的空间;保护工具配对时可略超目标</text>
|
||||
|
||||
<!-- Step 2 to Step 3 -->
|
||||
<line x1="380" y1="286" x2="380" y2="298" stroke="#94a3b8" stroke-width="1" marker-end="url(#arrow-d)"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
@@ -154,7 +154,7 @@ LLM call の前に compaction pipeline を走らせる:
|
||||
tool_result_budget → snip_compact → micro_compact → compact_history
|
||||
```
|
||||
|
||||
`snip_compact` は中間メッセージを切る前に完全な履歴を保存する。`micro_compact` はコンテキストが上限を超えた場合にだけ実行し、古い既読結果を保存して復元パスへ置き換え、最新 3 件を完全に保ち、上限の約 80% で停止する。未読の新しい結果自体が大きすぎる場合、S15 は履歴要約を検討する前に preview と完全な出力へのパスを残す。
|
||||
`snip_compact` はメッセージ数が 50 件に達すると完全な履歴を保存し、archive marker を含む 30 件を目標に中間メッセージを切り詰める。これにより次の保存までに新しいメッセージを追加する余裕ができるが、context に直接保持する最近のメッセージは少なくなる。tool call と result の組を保護するため、保持件数が目標を少し上回る場合がある。`micro_compact` はコンテキストが上限を超えた場合にだけ実行し、古い既読結果を保存して復元パスへ置き換え、最新 3 件を完全に保ち、上限の約 80% で停止する。未読の新しい結果自体が大きすぎる場合、S15 は履歴要約を検討する前に preview と完全な出力へのパスを残す。
|
||||
|
||||
model call は recovery で包む:
|
||||
|
||||
@@ -242,4 +242,4 @@ python s15_integrated_harness/code.py
|
||||
|
||||
[s16 Workflow Runtime](../s16_workflow_runtime/) は、この host に `Workflow` tool を追加する。Workflow は固定された orchestration path を code に置き、進行状況を記録して同じ run を再開できるようにする。
|
||||
|
||||
<!-- translation-sync: zh@v14, en@v14, ja@v14 -->
|
||||
<!-- translation-sync: zh@v15, en@v15, ja@v15 -->
|
||||
|
||||
@@ -154,7 +154,7 @@ 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.
|
||||
`snip_compact` archives the complete history when the message count reaches 50, then trims its middle toward a target of 30 messages, including the archive marker. This leaves room for new messages before the next archive write, at the cost of retaining fewer recent messages directly in context. Tool-call/result pairs remain intact, so the retained count may slightly exceed the target. `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:
|
||||
|
||||
@@ -242,4 +242,4 @@ Watch for:
|
||||
|
||||
[s16 Workflow Runtime](../s16_workflow_runtime/) adds a `Workflow` tool to this host. A workflow keeps a fixed orchestration path in code and records progress so the same run can resume.
|
||||
|
||||
<!-- translation-sync: zh@v14, en@v14, ja@v14 -->
|
||||
<!-- translation-sync: zh@v15, en@v15, ja@v15 -->
|
||||
|
||||
@@ -154,7 +154,7 @@ LLM 前先跑压缩管线:
|
||||
tool_result_budget → snip_compact → micro_compact → compact_history
|
||||
```
|
||||
|
||||
`snip_compact` 会先归档完整历史,再裁掉中段消息。`micro_compact` 只在上下文超限时运行:它先保存较早且已读取的结果,再用恢复路径替换;最近 3 条保持完整,并在接近阈值 80% 时停止。如果未读取的新结果本身过大,S15 会先保留预览和完整输出路径,再考虑总结历史。
|
||||
`snip_compact` 在消息数量达到 50 条时归档完整历史,再裁掉中段消息,目标为包含归档标记在内的 30 条。这样为新增消息留出空间,避免立即再次归档,代价是直接保留在上下文中的近期消息更少。工具调用与结果保持配对,因此保留数量可能略高于目标。`micro_compact` 只在上下文超限时运行:它先保存较早且已读取的结果,再用恢复路径替换;最近 3 条保持完整,并在接近阈值 80% 时停止。如果未读取的新结果本身过大,S15 会先保留预览和完整输出路径,再考虑总结历史。
|
||||
|
||||
调用模型时再包一层恢复:
|
||||
|
||||
@@ -242,4 +242,4 @@ python s15_integrated_harness/code.py
|
||||
|
||||
[s16 Workflow Runtime](../s16_workflow_runtime/) 会在这个 host 中加入 `Workflow` 工具。Workflow 把固定的编排路径写在代码中,并记录运行进度,使同一次运行可以继续执行。
|
||||
|
||||
<!-- translation-sync: zh@v14, en@v14, ja@v14 -->
|
||||
<!-- translation-sync: zh@v15, en@v15, ja@v15 -->
|
||||
|
||||
@@ -2105,11 +2105,13 @@ def is_archive_marker(message: dict) -> bool:
|
||||
and path.is_file())
|
||||
|
||||
|
||||
def snip_compact(messages: list, max_messages: int = 50) -> list:
|
||||
if len(messages) <= max_messages:
|
||||
def snip_compact(messages: list, trigger_messages: int = 50, target_messages: int = 30) -> list:
|
||||
if not 5 <= target_messages < trigger_messages:
|
||||
raise ValueError("Require 5 <= target_messages < trigger_messages")
|
||||
if len(messages) < trigger_messages:
|
||||
return messages
|
||||
head_end = 3
|
||||
tail_start = len(messages) - (max_messages - head_end - 1)
|
||||
tail_start = len(messages) - (target_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
|
||||
|
||||
@@ -287,6 +287,59 @@ class CompactionToolPairTests(unittest.TestCase):
|
||||
for tool_id in ("seen-2", "seen-3", "seen-4"):
|
||||
self.assertIn(f"{tool_id}: ", results[tool_id])
|
||||
|
||||
def test_snip_compact_leaves_room_before_archiving_again(self):
|
||||
for name, path in MODULES.items():
|
||||
with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp:
|
||||
module = load_module(f"{name}_snip_hysteresis", path, Path(tmp))
|
||||
messages = [
|
||||
user_text() if index % 2 == 0 else assistant_text()
|
||||
for index in range(49)
|
||||
]
|
||||
self.assertIs(prepare_context(module, messages), messages)
|
||||
self.assertFalse(module.TRANSCRIPT_DIR.exists())
|
||||
|
||||
messages.append(assistant_text())
|
||||
original = list(messages)
|
||||
messages = prepare_context(module, messages)
|
||||
self.assertEqual(len(messages), 30)
|
||||
self.assertEqual(messages[:3], original[:3])
|
||||
self.assertEqual(messages[-26:], original[-26:])
|
||||
archives = set(module.TRANSCRIPT_DIR.glob("*.jsonl"))
|
||||
self.assertEqual(len(archives), 1)
|
||||
self.assertEqual(len(next(iter(archives)).read_text().splitlines()), 50)
|
||||
|
||||
for _ in range(9):
|
||||
messages.extend([user_text(), assistant_text()])
|
||||
messages = prepare_context(module, messages)
|
||||
self.assertEqual(set(module.TRANSCRIPT_DIR.glob("*.jsonl")), archives)
|
||||
|
||||
messages.extend([user_text(), assistant_text()])
|
||||
messages = prepare_context(module, messages)
|
||||
self.assertEqual(len(messages), 30)
|
||||
self.assertEqual(len(list(module.TRANSCRIPT_DIR.glob("*.jsonl"))), 2)
|
||||
|
||||
def test_snip_compact_handles_threshold_overshoot(self):
|
||||
for name, path in MODULES.items():
|
||||
with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp:
|
||||
module = load_module(f"{name}_snip_overshoot", path, Path(tmp))
|
||||
messages = [user_text() for _ in range(55)]
|
||||
compacted = compaction_api(module).snip_compact(messages)
|
||||
self.assertEqual(len(compacted), 30)
|
||||
self.assertEqual(compacted[-1], messages[-1])
|
||||
archive, = module.TRANSCRIPT_DIR.glob("*.jsonl")
|
||||
self.assertEqual(len(archive.read_text().splitlines()), 55)
|
||||
|
||||
def test_snip_compact_rejects_invalid_thresholds(self):
|
||||
for name, path in MODULES.items():
|
||||
with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp:
|
||||
module = load_module(f"{name}_snip_thresholds", path, Path(tmp))
|
||||
for trigger, target in [(50, 4), (30, 30), (20, 30)]:
|
||||
with self.subTest(trigger=trigger, target=target):
|
||||
with self.assertRaises(ValueError):
|
||||
compaction_api(module).snip_compact(
|
||||
[], trigger_messages=trigger, target_messages=target)
|
||||
self.assertFalse(module.TRANSCRIPT_DIR.exists())
|
||||
|
||||
def test_snip_compact_keeps_head_tool_pair(self):
|
||||
messages = [
|
||||
user_text(),
|
||||
@@ -305,16 +358,18 @@ class CompactionToolPairTests(unittest.TestCase):
|
||||
with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp:
|
||||
module = load_module(f"{name}_head_under_test", path, Path(tmp))
|
||||
compacted = compaction_api(module).snip_compact(
|
||||
list(messages), max_messages=6
|
||||
list(messages), trigger_messages=7, target_messages=6
|
||||
)
|
||||
self.assertEqual(compacted[2], messages[2])
|
||||
self.assertEqual(compacted[3], messages[3])
|
||||
self.assertEqual(len(compacted), 7)
|
||||
assert_no_orphan_tool_results(self, compacted)
|
||||
self.assertEqual(
|
||||
compaction_api(module).snip_compact(
|
||||
list(compacted), max_messages=6),
|
||||
list(compacted), trigger_messages=7, target_messages=6),
|
||||
compacted,
|
||||
)
|
||||
self.assertEqual(len(list(module.TRANSCRIPT_DIR.glob("*.jsonl"))), 1)
|
||||
|
||||
def test_snip_compact_archives_the_complete_history(self):
|
||||
messages = [
|
||||
@@ -326,7 +381,7 @@ class CompactionToolPairTests(unittest.TestCase):
|
||||
module = load_module(f"{name}_snip_archive", path, Path(tmp))
|
||||
|
||||
compacted = compaction_api(module).snip_compact(
|
||||
list(messages), max_messages=6)
|
||||
list(messages), trigger_messages=8, target_messages=6)
|
||||
marker = compacted[3]["content"]
|
||||
saved_path = Path(marker.rsplit(" at ", 1)[-1].removesuffix("]"))
|
||||
|
||||
@@ -335,7 +390,7 @@ class CompactionToolPairTests(unittest.TestCase):
|
||||
self.assertEqual(len(saved_path.read_text().splitlines()), 10)
|
||||
self.assertEqual(
|
||||
compaction_api(module).snip_compact(
|
||||
list(compacted), max_messages=6),
|
||||
list(compacted), trigger_messages=8, target_messages=6),
|
||||
compacted,
|
||||
)
|
||||
|
||||
@@ -357,8 +412,10 @@ class CompactionToolPairTests(unittest.TestCase):
|
||||
with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp:
|
||||
module = load_module(f"{name}_under_test", path, Path(tmp))
|
||||
compacted = compaction_api(module).snip_compact(
|
||||
list(messages), max_messages=6
|
||||
list(messages), trigger_messages=9, target_messages=7
|
||||
)
|
||||
self.assertEqual(len(compacted), 8)
|
||||
self.assertEqual(compacted[-4:], messages[-4:])
|
||||
assert_no_orphan_tool_results(self, compacted)
|
||||
|
||||
def test_reactive_compact_keeps_tail_tool_pair(self):
|
||||
|
||||
@@ -56,9 +56,9 @@
|
||||
<rect x="80" y="240" width="600" height="46" rx="7" fill="url(#pre)" stroke="#2563eb" stroke-width="1.5"/>
|
||||
<text x="100" y="260" fill="#1e40af" font-size="12" font-weight="600">Step 2</text>
|
||||
<text x="155" y="260" fill="#1e40af" font-size="13" font-weight="700">snip_compact</text>
|
||||
<text x="260" y="260" fill="#1e40af" font-size="11">messages > 50 → trim middle</text>
|
||||
<text x="260" y="260" fill="#1e40af" font-size="11">messages ≥ 50 → target 30</text>
|
||||
<text x="650" y="260" fill="#1e40af" font-size="10" text-anchor="end">keep head/tail</text>
|
||||
<text x="155" y="278" fill="#2563eb" font-size="9">Trims above 50 messages and preserves tool pairs</text>
|
||||
<text x="155" y="278" fill="#2563eb" font-size="9">Leaves room for new messages; tool pairs may exceed the target</text>
|
||||
|
||||
<!-- Step 2 to Step 3 -->
|
||||
<line x1="380" y1="286" x2="380" y2="298" stroke="#94a3b8" stroke-width="1" marker-end="url(#arrow-d)"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
@@ -56,9 +56,9 @@
|
||||
<rect x="80" y="240" width="600" height="46" rx="7" fill="url(#pre)" stroke="#2563eb" stroke-width="1.5"/>
|
||||
<text x="100" y="260" fill="#1e40af" font-size="12" font-weight="600">Step 2</text>
|
||||
<text x="155" y="260" fill="#1e40af" font-size="13" font-weight="700">snip_compact</text>
|
||||
<text x="260" y="260" fill="#1e40af" font-size="11">メッセージ > 50 → 中間をトリム</text>
|
||||
<text x="260" y="260" fill="#1e40af" font-size="11">メッセージ ≥ 50 → 目標 30 件</text>
|
||||
<text x="650" y="260" fill="#1e40af" font-size="10" text-anchor="end">先頭/末尾保持</text>
|
||||
<text x="155" y="278" fill="#2563eb" font-size="9">50 件を超えたら中間を省略し、ツールの組を保持</text>
|
||||
<text x="155" y="278" fill="#2563eb" font-size="9">追加の余裕を確保;ツールの組の保持で目標を少し超える場合あり</text>
|
||||
|
||||
<!-- Step 2 to Step 3 -->
|
||||
<line x1="380" y1="286" x2="380" y2="298" stroke="#94a3b8" stroke-width="1" marker-end="url(#arrow-d)"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
@@ -56,9 +56,9 @@
|
||||
<rect x="80" y="240" width="600" height="46" rx="7" fill="url(#pre)" stroke="#2563eb" stroke-width="1.5"/>
|
||||
<text x="100" y="260" fill="#1e40af" font-size="12" font-weight="600">Step 2</text>
|
||||
<text x="155" y="260" fill="#1e40af" font-size="13" font-weight="700">snip_compact</text>
|
||||
<text x="260" y="260" fill="#1e40af" font-size="11">消息 > 50 条 → 裁掉中间</text>
|
||||
<text x="260" y="260" fill="#1e40af" font-size="11">消息 ≥ 50 条 → 目标 30 条</text>
|
||||
<text x="650" y="260" fill="#1e40af" font-size="10" text-anchor="end">保留头尾</text>
|
||||
<text x="155" y="278" fill="#2563eb" font-size="9">消息超过 50 条时裁剪,并保护工具调用与结果的配对</text>
|
||||
<text x="155" y="278" fill="#2563eb" font-size="9">留出新增消息的空间;保护工具配对时可略超目标</text>
|
||||
|
||||
<!-- Step 2 to Step 3 -->
|
||||
<line x1="380" y1="286" x2="380" y2="298" stroke="#94a3b8" stroke-width="1" marker-end="url(#arrow-d)"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |