fix(compact): handle local vision context pressure
This commit is contained in:
@@ -27,6 +27,7 @@ The format is based on Keep a Changelog, and this project currently tracks chang
|
||||
|
||||
### Fixed
|
||||
|
||||
- Compaction now detects llama.cpp/OpenAI-compatible context overflow errors, accounts for image blocks in auto-compact token estimates, and strips image payloads from summarizer-only compaction requests.
|
||||
- Fixed `glob` and `grep` tools hanging indefinitely when the `rg` subprocess produced enough stderr output to fill the OS pipe buffer. `stderr` is now redirected to `DEVNULL` so it is discarded rather than blocking the child process.
|
||||
- Fixed `bash_tool` hanging after a timed-out command when the subprocess stdout stream stayed open. `_read_remaining_output` now applies a 2-second `asyncio.wait_for` timeout so the tool always returns promptly.
|
||||
- Fixed `session_runner` background task deadlock caused by an unread `stderr=PIPE` stream. The subprocess now uses `stderr=STDOUT` so all output merges into the single readable stdout pipe.
|
||||
|
||||
@@ -65,6 +65,9 @@ def _is_prompt_too_long_error(exc: Exception) -> bool:
|
||||
"too many tokens",
|
||||
"too large for the model",
|
||||
"maximum context length",
|
||||
"exceed_context",
|
||||
"exceeds the available context size",
|
||||
"available context size",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -71,6 +72,7 @@ DEFAULT_GAP_THRESHOLD_MINUTES = 60
|
||||
|
||||
# Token estimation padding (conservative)
|
||||
TOKEN_ESTIMATION_PADDING = 4 / 3
|
||||
_DEFAULT_VISION_IMAGE_TOKEN_ESTIMATE = 3_072
|
||||
|
||||
# Default context windows per model family
|
||||
_DEFAULT_CONTEXT_WINDOW = 200_000
|
||||
@@ -113,6 +115,7 @@ class CompactionResult:
|
||||
def estimate_message_tokens(messages: list[ConversationMessage]) -> int:
|
||||
"""Estimate total tokens for a conversation, including the 4/3 padding."""
|
||||
total = 0
|
||||
image_token_estimate = _vision_token_budget_per_image()
|
||||
for msg in messages:
|
||||
for block in msg.content:
|
||||
if isinstance(block, TextBlock):
|
||||
@@ -122,6 +125,8 @@ def estimate_message_tokens(messages: list[ConversationMessage]) -> int:
|
||||
elif isinstance(block, ToolUseBlock):
|
||||
total += estimate_tokens(block.name)
|
||||
total += estimate_tokens(str(block.input))
|
||||
elif isinstance(block, ImageBlock):
|
||||
total += image_token_estimate
|
||||
return int(total * TOKEN_ESTIMATION_PADDING)
|
||||
|
||||
|
||||
@@ -130,6 +135,42 @@ def estimate_conversation_tokens(messages: list[ConversationMessage]) -> int:
|
||||
return estimate_message_tokens(messages)
|
||||
|
||||
|
||||
def _vision_token_budget_per_image() -> int:
|
||||
raw = os.environ.get("OPENHARNESS_IMAGE_TOKEN_ESTIMATE", "").strip()
|
||||
if raw:
|
||||
try:
|
||||
return max(64, int(raw))
|
||||
except ValueError:
|
||||
log.warning("Ignoring invalid OPENHARNESS_IMAGE_TOKEN_ESTIMATE=%r", raw)
|
||||
return _DEFAULT_VISION_IMAGE_TOKEN_ESTIMATE
|
||||
|
||||
|
||||
def _replace_images_with_compaction_placeholders(
|
||||
messages: list[ConversationMessage],
|
||||
) -> list[ConversationMessage]:
|
||||
"""Strip image payloads from summarizer-only compact requests."""
|
||||
replaced: list[ConversationMessage] = []
|
||||
for message in messages:
|
||||
next_content: list[ContentBlock] = []
|
||||
changed = False
|
||||
for block in message.content:
|
||||
if isinstance(block, ImageBlock):
|
||||
changed = True
|
||||
label = block.source_path.strip() or "inline"
|
||||
next_content.append(
|
||||
TextBlock(
|
||||
text=f"[Image omitted from compaction summarization; source: {label}.]\n"
|
||||
)
|
||||
)
|
||||
else:
|
||||
next_content.append(block)
|
||||
if changed:
|
||||
replaced.append(message.model_copy(update={"content": next_content}))
|
||||
else:
|
||||
replaced.append(message)
|
||||
return replaced
|
||||
|
||||
|
||||
def _sanitize_metadata(value: Any) -> Any:
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value
|
||||
@@ -215,6 +256,10 @@ def _is_prompt_too_long_error(exc: Exception) -> bool:
|
||||
"context window",
|
||||
"too many tokens",
|
||||
"too large for the model",
|
||||
"maximum context length",
|
||||
"exceed_context",
|
||||
"exceeds the available context size",
|
||||
"available context size",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1160,6 +1205,9 @@ async def compact_conversation(
|
||||
|
||||
async def _collect_summary(summary_request_messages: list[ConversationMessage]) -> str:
|
||||
collected = ""
|
||||
summary_request_messages = _replace_images_with_compaction_placeholders(
|
||||
summary_request_messages
|
||||
)
|
||||
stream = api_client.stream_message(
|
||||
ApiMessageRequest(
|
||||
model=model,
|
||||
|
||||
@@ -35,7 +35,7 @@ from openharness.engine.messages import ToolResultBlock
|
||||
from openharness.hooks import HookExecutionContext, HookExecutor, HookEvent
|
||||
from openharness.hooks.loader import HookRegistry
|
||||
from openharness.hooks.schemas import PromptHookDefinition
|
||||
from openharness.engine.query import QueryContext, _execute_tool_call
|
||||
from openharness.engine.query import QueryContext, _execute_tool_call, _is_prompt_too_long_error
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -165,6 +165,12 @@ class _NoopApiClient:
|
||||
yield None
|
||||
|
||||
|
||||
def test_query_prompt_too_long_detection_handles_llama_cpp_errors():
|
||||
assert _is_prompt_too_long_error(
|
||||
RequestFailure("exceed_context_size_error: prompt exceeds the available context size")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_engine_plain_text_reply(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.delenv("CLAUDE_CODE_COORDINATOR_MODE", raising=False)
|
||||
|
||||
@@ -21,7 +21,9 @@ from openharness.services import (
|
||||
)
|
||||
from openharness.services.compact import (
|
||||
AutoCompactState,
|
||||
_is_prompt_too_long_error,
|
||||
auto_compact_if_needed,
|
||||
estimate_message_tokens as estimate_compact_message_tokens,
|
||||
get_autocompact_threshold,
|
||||
should_autocompact,
|
||||
try_context_collapse,
|
||||
@@ -103,9 +105,10 @@ def test_compact_messages_drops_dangling_preserved_tool_use():
|
||||
class _CompactApiClient:
|
||||
def __init__(self, responses):
|
||||
self._responses = list(responses)
|
||||
self.requests = []
|
||||
|
||||
async def stream_message(self, request):
|
||||
del request
|
||||
self.requests.append(request)
|
||||
response = self._responses.pop(0)
|
||||
if isinstance(response, Exception):
|
||||
raise response
|
||||
@@ -165,6 +168,41 @@ def test_try_context_collapse_trims_oversized_messages():
|
||||
assert "[collapsed" in result[0].text
|
||||
|
||||
|
||||
def test_compact_prompt_too_long_detection_handles_llama_cpp_errors():
|
||||
assert _is_prompt_too_long_error(
|
||||
RuntimeError("exceed_context_size_error: prompt exceeds the available context size")
|
||||
)
|
||||
|
||||
|
||||
def test_compact_token_estimate_counts_images(monkeypatch):
|
||||
monkeypatch.setenv("OPENHARNESS_IMAGE_TOKEN_ESTIMATE", "6000")
|
||||
messages = [
|
||||
ConversationMessage(
|
||||
role="user",
|
||||
content=[ImageBlock(media_type="image/png", data="YWJj", source_path="/tmp/screen.png")],
|
||||
)
|
||||
]
|
||||
|
||||
assert estimate_compact_message_tokens(messages) == 8000
|
||||
|
||||
|
||||
def test_should_autocompact_counts_image_tokens(monkeypatch):
|
||||
monkeypatch.setenv("OPENHARNESS_IMAGE_TOKEN_ESTIMATE", "6000")
|
||||
messages = [
|
||||
ConversationMessage(
|
||||
role="user",
|
||||
content=[ImageBlock(media_type="image/png", data="YWJj", source_path="/tmp/screen.png")],
|
||||
)
|
||||
]
|
||||
|
||||
assert should_autocompact(
|
||||
messages,
|
||||
"local-vision",
|
||||
AutoCompactState(),
|
||||
auto_compact_threshold_tokens=7000,
|
||||
) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compact_conversation_retries_after_incomplete_response():
|
||||
messages = [
|
||||
@@ -188,6 +226,36 @@ async def test_compact_conversation_retries_after_incomplete_response():
|
||||
assert any(message.text.startswith("This session is being continued") for message in rebuilt)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compact_conversation_replaces_images_in_summary_request():
|
||||
image = ImageBlock(media_type="image/png", data="YWJj", source_path="/tmp/screen.png")
|
||||
messages = [
|
||||
ConversationMessage(role="user", content=[image]),
|
||||
ConversationMessage(role="assistant", content=[TextBlock(text="I can see the screenshot")]),
|
||||
ConversationMessage(role="user", content=[TextBlock(text="Please summarize before moving on")]),
|
||||
ConversationMessage(role="assistant", content=[TextBlock(text="Working")]),
|
||||
]
|
||||
client = _CompactApiClient(["<summary>image context preserved</summary>"])
|
||||
|
||||
await compact_conversation(
|
||||
messages,
|
||||
api_client=client,
|
||||
model="local-vision",
|
||||
preserve_recent=1,
|
||||
)
|
||||
|
||||
request = client.requests[0]
|
||||
request_blocks = [block for message in request.messages for block in message.content]
|
||||
assert not any(isinstance(block, ImageBlock) for block in request_blocks)
|
||||
assert any(
|
||||
isinstance(block, TextBlock)
|
||||
and "Image omitted from compaction summarization" in block.text
|
||||
and "/tmp/screen.png" in block.text
|
||||
for block in request_blocks
|
||||
)
|
||||
assert isinstance(messages[0].content[0], ImageBlock)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compact_conversation_runs_hooks_and_preserves_carryover_state(tmp_path):
|
||||
image_path = tmp_path / "sample.png"
|
||||
|
||||
Reference in New Issue
Block a user