diff --git a/s08_context_compact/README.en.md b/s08_context_compact/README.en.md index 6b2f2359..15199c37 100644 --- a/s08_context_compact/README.en.md +++ b/s08_context_compact/README.en.md @@ -46,10 +46,12 @@ def snip_compact(messages, max_messages=50): if len(messages) <= max_messages: return messages head_end, tail_start = 3, len(messages) - (max_messages - 3) - if _message_has_tool_use(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 - if _is_tool_result_message(messages[tail_start]) and _message_has_tool_use(messages[tail_start - 1]): + if (tail_start > 0 and tail_start < len(messages) + and _is_tool_result_message(messages[tail_start]) + and _message_has_tool_use(messages[tail_start - 1])): tail_start -= 1 snipped = tail_start - head_end placeholder = {"role": "user", "content": f"[snipped {snipped} messages from conversation middle]"} @@ -139,10 +141,12 @@ This triggers **reactive_compact**: more aggressive than compact_history, it ret ```python def reactive_compact(messages): transcript = write_transcript(messages) - summary = summarize_history(messages) tail_start = max(0, len(messages) - 5) - if _is_tool_result_message(messages[tail_start]) and _message_has_tool_use(messages[tail_start - 1]): + if (tail_start > 0 and tail_start < len(messages) + and _is_tool_result_message(messages[tail_start]) + and _message_has_tool_use(messages[tail_start - 1])): tail_start -= 1 + summary = summarize_history(messages[:tail_start]) return [{"role": "user", "content": f"[Reactive compact]\n\n{summary}"}, *messages[tail_start:]] ``` diff --git a/s08_context_compact/README.ja.md b/s08_context_compact/README.ja.md index 84bfb381..da174d0a 100644 --- a/s08_context_compact/README.ja.md +++ b/s08_context_compact/README.ja.md @@ -46,10 +46,12 @@ def snip_compact(messages, max_messages=50): if len(messages) <= max_messages: return messages head_end, tail_start = 3, len(messages) - (max_messages - 3) - if _message_has_tool_use(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 - if _is_tool_result_message(messages[tail_start]) and _message_has_tool_use(messages[tail_start - 1]): + if (tail_start > 0 and tail_start < len(messages) + and _is_tool_result_message(messages[tail_start]) + and _message_has_tool_use(messages[tail_start - 1])): tail_start -= 1 snipped = tail_start - head_end placeholder = {"role": "user", "content": f"[snipped {snipped} messages from conversation middle]"} @@ -139,10 +141,12 @@ API がまだ `prompt_too_long`(413)を返すことがある。コンテキ ```python def reactive_compact(messages): transcript = write_transcript(messages) - summary = summarize_history(messages) tail_start = max(0, len(messages) - 5) - if _is_tool_result_message(messages[tail_start]) and _message_has_tool_use(messages[tail_start - 1]): + if (tail_start > 0 and tail_start < len(messages) + and _is_tool_result_message(messages[tail_start]) + and _message_has_tool_use(messages[tail_start - 1])): tail_start -= 1 + summary = summarize_history(messages[:tail_start]) return [{"role": "user", "content": f"[Reactive compact]\n\n{summary}"}, *messages[tail_start:]] ``` diff --git a/s08_context_compact/README.md b/s08_context_compact/README.md index 22d96715..ab5dae54 100644 --- a/s08_context_compact/README.md +++ b/s08_context_compact/README.md @@ -46,10 +46,12 @@ def snip_compact(messages, max_messages=50): if len(messages) <= max_messages: return messages head_end, tail_start = 3, len(messages) - (max_messages - 3) - if _message_has_tool_use(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 - if _is_tool_result_message(messages[tail_start]) and _message_has_tool_use(messages[tail_start - 1]): + if (tail_start > 0 and tail_start < len(messages) + and _is_tool_result_message(messages[tail_start]) + and _message_has_tool_use(messages[tail_start - 1])): tail_start -= 1 snipped = tail_start - head_end placeholder = {"role": "user", "content": f"[snipped {snipped} messages from conversation middle]"} @@ -139,10 +141,12 @@ def compact_history(messages): ```python def reactive_compact(messages): transcript = write_transcript(messages) - summary = summarize_history(messages) tail_start = max(0, len(messages) - 5) - if _is_tool_result_message(messages[tail_start]) and _message_has_tool_use(messages[tail_start - 1]): + if (tail_start > 0 and tail_start < len(messages) + and _is_tool_result_message(messages[tail_start]) + and _message_has_tool_use(messages[tail_start - 1])): tail_start -= 1 + summary = summarize_history(messages[:tail_start]) return [{"role": "user", "content": f"[Reactive compact]\n\n{summary}"}, *messages[tail_start:]] ``` diff --git a/s08_context_compact/code.py b/s08_context_compact/code.py index 6f162efb..7186df55 100644 --- a/s08_context_compact/code.py +++ b/s08_context_compact/code.py @@ -382,12 +382,12 @@ def compact_history(messages): # Emergency: reactiveCompact — on API error def reactive_compact(messages): transcript = write_transcript(messages) - summary = summarize_history(messages) tail_start = max(0, len(messages) - 5) if (tail_start > 0 and tail_start < len(messages) and _is_tool_result_message(messages[tail_start]) and _message_has_tool_use(messages[tail_start - 1])): tail_start -= 1 + summary = summarize_history(messages[:tail_start]) return [{"role": "user", "content": f"[Reactive compact]\n\n{summary}"}, *messages[tail_start:]] diff --git a/s09_memory/code.py b/s09_memory/code.py index e962db01..117c8359 100644 --- a/s09_memory/code.py +++ b/s09_memory/code.py @@ -540,12 +540,12 @@ def compact_history(msgs): def reactive_compact(msgs): write_transcript(msgs) - summary = summarize_history(msgs) tail_start = max(0, len(msgs) - 5) if (tail_start > 0 and tail_start < len(msgs) and _is_tool_result_message(msgs[tail_start]) and _message_has_tool_use(msgs[tail_start - 1])): tail_start -= 1 + summary = summarize_history(msgs[:tail_start]) return [{"role": "user", "content": f"[Reactive compact]\n\n{summary}"}, *msgs[tail_start:]] diff --git a/s20_comprehensive/code.py b/s20_comprehensive/code.py index bd62553e..543bab11 100644 --- a/s20_comprehensive/code.py +++ b/s20_comprehensive/code.py @@ -1190,15 +1190,15 @@ def compact_history(messages: list) -> list: def reactive_compact(messages: list) -> list: transcript = write_transcript(messages) print(f" \033[31m[reactive compact] transcript saved: {transcript}\033[0m") - try: - summary = summarize_history(messages) - except Exception: - summary = "Earlier conversation was trimmed after a prompt-too-long error." tail_start = max(0, len(messages) - 5) if (tail_start > 0 and tail_start < len(messages) and is_tool_result_message(messages[tail_start]) and message_has_tool_use(messages[tail_start - 1])): tail_start -= 1 + try: + summary = summarize_history(messages[:tail_start]) + except Exception: + summary = "Earlier conversation was trimmed after a prompt-too-long error." return [{"role": "user", "content": f"[Reactive compact]\n\n{summary}"}, *messages[tail_start:]] diff --git a/tests/test_compaction_tool_pairs.py b/tests/test_compaction_tool_pairs.py index e4f67d7b..3289a977 100644 --- a/tests/test_compaction_tool_pairs.py +++ b/tests/test_compaction_tool_pairs.py @@ -178,6 +178,72 @@ class CompactionToolPairTests(unittest.TestCase): self.assertEqual(compacted[1], messages[3]) assert_no_orphan_tool_results(self, compacted) + def test_reactive_compact_summarizes_only_old_history(self): + messages = [ + user_text(), + assistant_text(), + user_text(), + assistant_text(), + user_text(), + assistant_text(), + user_text(), + assistant_text(), + user_text(), + ] + + for name, path in MODULES.items(): + with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp: + module = load_module(f"{name}_reactive_oldhist_under_test", path, Path(tmp)) + module.write_transcript = lambda _messages: Path("transcript.jsonl") + captured = {} + + def fake_summarize(passed, _store=captured): + _store["messages"] = list(passed) + return "summary" + + module.summarize_history = fake_summarize + compacted = module.reactive_compact(list(messages)) + # The summary must cover only the old history, not the kept tail. + self.assertEqual(captured["messages"], messages[:4]) + # The recent tail is appended verbatim after the summary message. + self.assertEqual(compacted[1:], messages[4:]) + assert_no_orphan_tool_results(self, compacted) + + def test_reactive_compact_summary_excludes_tail_pair_pulled_in(self): + # A tool_use/tool_result pair straddles the tail boundary, so the + # adjustment pulls the tool_use into the kept tail. The summary must + # cover only what stays trimmed (messages[:adjusted_tail_start]), i.e. + # it must not re-summarize the tool_use that is kept verbatim. + messages = [ + user_text(), + assistant_text(), + user_text(), + tool_use_message("reactive-tool"), + tool_result_message("reactive-tool"), + assistant_text(), + user_text(), + assistant_text(), + user_text(), + ] + + for name, path in MODULES.items(): + with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp: + module = load_module(f"{name}_reactive_pairscope_under_test", path, Path(tmp)) + module.write_transcript = lambda _messages: Path("transcript.jsonl") + captured = {} + + def fake_summarize(passed, _store=captured): + _store["messages"] = list(passed) + return "summary" + + module.summarize_history = fake_summarize + compacted = module.reactive_compact(list(messages)) + # tail_start starts at 4, decrements to 3 to keep the pair intact. + self.assertEqual(captured["messages"], messages[:3]) + self.assertEqual(compacted[1], messages[3]) + self.assertEqual(compacted[1:], messages[3:]) + assert_no_orphan_tool_results(self, compacted) + def test_s20_has_tool_use_still_accepts_content_blocks(self): with tempfile.TemporaryDirectory() as tmp: module = load_module("s20_has_tool_use_under_test", MODULES["s20"], Path(tmp))