fix(s08): preserve the complete active request after compaction

Incorporate PR #578, retain the overflow regression tests from #561,
and match the complete request field in retained summaries.

Co-authored-by: ship <shiyipeng2002@163.com>
This commit is contained in:
Xinlu Lai
2026-10-27 12:44:36 +08:00
co-authored by ship
parent 11c6ce5ff5
commit a7bbf7dfe6
5 changed files with 174 additions and 3 deletions
+16 -1
View File
@@ -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 ツール
+16 -1
View File
@@ -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
+16 -1
View File
@@ -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 工具
+13
View File
@@ -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)
+113
View File
@@ -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