From 7226f555d48900076eba126acf37c9ed5f002b5d Mon Sep 17 00:00:00 2001 From: Xinlu Lai <35400185+CrazyBoyM@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:47:00 +0800 Subject: [PATCH] fix(s08): preserve the complete active request after compaction (#603) Incorporate #578, preserve #561's regression tests, and distinguish a complete request from a prefix of an older request. EN/ZH/JA examples stay synchronized. Co-authored-by: ship --- s08_context_compact/README.ja.md | 17 ++++- s08_context_compact/README.md | 17 ++++- s08_context_compact/README.zh.md | 17 ++++- s08_context_compact/code.py | 13 ++++ tests/test_s08_context_compact.py | 113 ++++++++++++++++++++++++++++++ 5 files changed, 174 insertions(+), 3 deletions(-) diff --git a/s08_context_compact/README.ja.md b/s08_context_compact/README.ja.md index c6d9e85f..665ca6b3 100644 --- a/s08_context_compact/README.ja.md +++ b/s08_context_compact/README.ja.md @@ -116,6 +116,8 @@ messages = [*messages[:head_end], marker, *messages[tail_start:]] 切断位置では、`assistant(tool_use)` と `user(tool_result)` の組を保護するため、保持件数が目標を少し上回る場合があります。対応するツール呼び出しがない孤立した結果を含むと、次の API リクエストは無効になります。 +ツール呼び出しが繰り返されると、現在の要求がアーカイブ対象の中間領域へ移ることがあります。実際に切り詰めた後、`prepare` は元のユーザーメッセージまたは既存の `Current user request` マーカーがまだ見えるかを確認します。どちらも残っていなければ、処理を続ける前に `active_request` を一度だけ追加します。 + このステップはメッセージ数を抑えます。保持されたメッセージ内のツール結果は、まだ長い可能性があります。 @@ -186,7 +188,20 @@ def compact_history(messages, active_request): ```python messages = self.tool_result_budget(messages) +before_snip = messages messages = self.snip_compact(messages) +active_request_marker = ( + f"Current user request:\n{active_request}\n\n" + "Conversation summary (reference only):" +) +if (messages is not before_snip + and not any( + message.get("role") == "user" + and isinstance(message.get("content"), str) + and (message["content"] == active_request + or active_request_marker in message["content"]) + for message in messages)): + messages.append({"role": "user", "content": active_request}) if self.estimate_chars(messages) > self.CONTEXT_CHAR_LIMIT: target = int(self.CONTEXT_CHAR_LIMIT * 0.8) messages = self.micro_compact(messages, target) @@ -250,7 +265,7 @@ def agent_loop(messages, active_request): raise ``` -すべてのモデル呼び出しが同じパイプラインを通ります。CLI は `query` を追加した後に `agent_loop(history, query)` を呼ぶため、圧縮を繰り返しても現在の要求は失われません。`micro_compact` の後も上限を超える場合、または API が拒否した場合にだけ、コードはモデルへ要約を依頼します。 +すべてのモデル呼び出しが同じパイプラインを通ります。CLI は `query` を追加した後に `agent_loop(history, query)` を呼びます。実際に切り詰められ、見えるコピーがすべて削除された場合にだけ、`prepare` は `active_request` を追加し直します。そのため、圧縮を繰り返しても現在の要求は失われません。`micro_compact` の後も上限を超える場合、または API が拒否した場合にだけ、コードはモデルへ要約を依頼します。 ## compact ツール diff --git a/s08_context_compact/README.md b/s08_context_compact/README.md index 162b8c5f..0d8e6aa6 100644 --- a/s08_context_compact/README.md +++ b/s08_context_compact/README.md @@ -116,6 +116,8 @@ messages = [*messages[:head_end], marker, *messages[tail_start:]] 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. +Repeated tool calls can move the active request into the archived middle. After a real snip, `prepare` checks whether the original user message or an existing `Current user request` marker is still visible. If neither remains, it appends `active_request` once before continuing. + This step controls the number of messages. Tool results inside the retained messages may still be long. @@ -186,7 +188,20 @@ The pipeline uses this order and only enters the lossy summary step when necessa ```python messages = self.tool_result_budget(messages) +before_snip = messages messages = self.snip_compact(messages) +active_request_marker = ( + f"Current user request:\n{active_request}\n\n" + "Conversation summary (reference only):" +) +if (messages is not before_snip + and not any( + message.get("role") == "user" + and isinstance(message.get("content"), str) + and (message["content"] == active_request + or active_request_marker in message["content"]) + for message in messages)): + messages.append({"role": "user", "content": active_request}) if self.estimate_chars(messages) > self.CONTEXT_CHAR_LIMIT: target = int(self.CONTEXT_CHAR_LIMIT * 0.8) messages = self.micro_compact(messages, target) @@ -250,7 +265,7 @@ def agent_loop(messages, active_request): raise ``` -Every model call enters through the same pipeline. After appending `query`, the CLI calls `agent_loop(history, query)`, so repeated compaction cannot lose the current request. The code asks for a summary only when `micro_compact` still leaves the context above the limit or when the API rejects it. +Every model call enters through the same pipeline. After appending `query`, the CLI calls `agent_loop(history, query)`, and `prepare` restores `active_request` only if a real snip removed every visible copy. Repeated compaction therefore cannot lose the current request. The code asks for a summary only when `micro_compact` still leaves the context above the limit or when the API rejects it. ## The compact Tool diff --git a/s08_context_compact/README.zh.md b/s08_context_compact/README.zh.md index 7b1ad76b..1be58557 100644 --- a/s08_context_compact/README.zh.md +++ b/s08_context_compact/README.zh.md @@ -116,6 +116,8 @@ messages = [*messages[:head_end], marker, *messages[tail_start:]] 切点需要保护 `assistant(tool_use)` 和 `user(tool_result)` 的配对关系,因此保留的消息数可能略高于目标数量。孤立的工具结果缺少对应调用,下一次 API 请求会被判定为无效。 +重复的工具调用可能让当前请求进入被归档的中间区域。真正发生裁剪后,`prepare` 会检查原始用户消息或已有的 `Current user request` 标记是否仍然可见;如果两者都不存在,就在继续处理前补回一次 `active_request`。 + 这一步控制消息数量,但保留下来的旧消息仍可能包含很长的工具结果。 @@ -186,7 +188,20 @@ def compact_history(messages, active_request): ```python messages = self.tool_result_budget(messages) +before_snip = messages messages = self.snip_compact(messages) +active_request_marker = ( + f"Current user request:\n{active_request}\n\n" + "Conversation summary (reference only):" +) +if (messages is not before_snip + and not any( + message.get("role") == "user" + and isinstance(message.get("content"), str) + and (message["content"] == active_request + or active_request_marker in message["content"]) + for message in messages)): + messages.append({"role": "user", "content": active_request}) if self.estimate_chars(messages) > self.CONTEXT_CHAR_LIMIT: target = int(self.CONTEXT_CHAR_LIMIT * 0.8) messages = self.micro_compact(messages, target) @@ -250,7 +265,7 @@ def agent_loop(messages, active_request): raise ``` -每次调用模型前都会经过同一条管线。CLI 在追加 `query` 后调用 `agent_loop(history, query)`,所以压缩多少次都不会丢失本轮请求。只有 `micro_compact` 处理后仍超过阈值,或者 API 明确拒绝上下文时,代码才会请求模型生成摘要。 +每次调用模型前都会经过同一条管线。CLI 在追加 `query` 后调用 `agent_loop(history, query)`;只有真正发生裁剪并移除了所有可见副本时,`prepare` 才会补回 `active_request`。因此压缩多少次都不会丢失本轮请求。只有 `micro_compact` 处理后仍超过阈值,或者 API 明确拒绝上下文时,代码才会请求模型生成摘要。 ## compact 工具 diff --git a/s08_context_compact/code.py b/s08_context_compact/code.py index 5a3b7621..f4e0117d 100644 --- a/s08_context_compact/code.py +++ b/s08_context_compact/code.py @@ -517,7 +517,20 @@ class ContextCompactor: def prepare(self, messages: list, active_request: str) -> list: messages = self.tool_result_budget(messages) + before_snip = messages messages = self.snip_compact(messages) + active_request_marker = ( + f"Current user request:\n{active_request}\n\n" + "Conversation summary (reference only):" + ) + if (messages is not before_snip + and not any( + message.get("role") == "user" + and isinstance(message.get("content"), str) + and (message["content"] == active_request + or active_request_marker in message["content"]) + for message in messages)): + messages.append({"role": "user", "content": active_request}) if self.estimate_chars(messages) > self.CONTEXT_CHAR_LIMIT: target = int(self.CONTEXT_CHAR_LIMIT * 0.8) messages = self.micro_compact(messages, target) diff --git a/tests/test_s08_context_compact.py b/tests/test_s08_context_compact.py index 2d7465a8..7f37632a 100644 --- a/tests/test_s08_context_compact.py +++ b/tests/test_s08_context_compact.py @@ -207,3 +207,116 @@ def test_agent_loop_propagates_unrelated_errors(tmp_path, monkeypatch): assert caught.value is error create.assert_called_once() compact.assert_not_called() + + +def test_prepare_leaves_short_task_unchanged(tmp_path, monkeypatch): + lesson = load_lesson(monkeypatch, tmp_path) + active_request = "inspect the repository" + messages = [ + {"role": "user", "content": active_request}, + {"role": "assistant", "content": [ + {"type": "text", "text": "done"} + ]}, + ] + + prepared = lesson["COMPACTOR"].prepare(messages, active_request) + + assert prepared is messages + + +def test_prepare_keeps_active_request_after_repeated_snip_compact( + tmp_path, monkeypatch): + lesson = load_lesson(monkeypatch, tmp_path) + active_request = "compare s08_context_compact/code.py and s09_memory/code.py" + messages = [ + {"role": "user", "content": "read the first five lesson READMEs"}, + {"role": "assistant", "content": [ + {"type": "text", "text": "first answer"} + ]}, + {"role": "user", "content": "analyze docs.json"}, + {"role": "assistant", "content": [ + {"type": "text", "text": "second answer"} + ]}, + {"role": "user", "content": active_request}, + ] + for index in range(23): + tool_id = f"current-tool-{index}" + messages.extend([ + {"role": "assistant", "content": [ + {"type": "tool_use", "id": tool_id, + "name": "read_file", "input": {}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": tool_id, + "content": f"result-{index}"} + ]}, + ]) + + compactor = lesson["COMPACTOR"] + compactor.summarize_history = lambda _messages: (_ for _ in ()).throw( + AssertionError("snip compact should be enough")) + + prepared = compactor.prepare(messages, active_request) + extra_tool_id = "current-tool-after-first-snip" + prepared.extend([ + {"role": "assistant", "content": [ + {"type": "tool_use", "id": extra_tool_id, + "name": "read_file", "input": {}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": extra_tool_id, + "content": "more-current-work"} + ]}, + ]) + prepared = compactor.prepare(prepared, active_request) + + request_marker = f"Current user request:\n{active_request}" + visible_requests = [ + message["content"] + for message in prepared + if message["role"] == "user" + and isinstance(message["content"], str) + and (message["content"] == active_request + or request_marker in message["content"]) + ] + assert len(visible_requests) == 1 + assert any(compactor.is_archive_marker(message) for message in prepared) + for index, message in enumerate(prepared): + if compactor.is_tool_result(message): + assert index > 0 + assert compactor.has_tool_use(prepared[index - 1]) + + +@pytest.mark.parametrize("previous_request,expected_restored", [ + ("review code and rewrite it", True), + ("review code", False), +]) +def test_prepare_matches_the_complete_request_in_a_retained_summary( + tmp_path, monkeypatch, previous_request, expected_restored): + lesson = load_lesson(monkeypatch, tmp_path) + compactor = lesson["COMPACTOR"] + request = "review code" + summary = compactor.summary_message( + "Compacted", previous_request, "previous work", tmp_path / "old.jsonl") + messages = [ + summary, + {"role": "assistant", "content": [{"type": "text", "text": "done"}]}, + {"role": "user", "content": "earlier question"}, + {"role": "assistant", "content": [{"type": "text", "text": "done"}]}, + {"role": "user", "content": request}, + ] + for index in range(30): + tool_id = f"summary-tool-{index}" + messages.extend([ + {"role": "assistant", "content": [ + {"type": "tool_use", "id": tool_id, "name": "read_file", "input": {}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": tool_id, "content": "ok"} + ]}, + ]) + + prepared = compactor.prepare(messages, request) + + assert summary in prepared + assert any(message.get("content") == request for message in prepared) is expected_restored