fix(compact): handle local vision context pressure

This commit is contained in:
tjb-tech
2026-04-28 07:04:43 +00:00
parent 438e373097
commit 726b3ee028
5 changed files with 128 additions and 2 deletions
+1
View File
@@ -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.
+3
View File
@@ -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,
+7 -1
View File
@@ -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)
+69 -1
View File
@@ -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"