mirror of
https://github.com/shareAI-lab/analysis_claude_code.git
synced 2026-10-29 17:52:20 +08:00
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:
@@ -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 ツール
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 工具
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user