From 726b3ee0280ac5d12dfb8e9b09aaa39c2eec7de1 Mon Sep 17 00:00:00 2001 From: tjb-tech <1193992557@qq.com> Date: Tue, 28 Apr 2026 07:04:43 +0000 Subject: [PATCH] fix(compact): handle local vision context pressure --- CHANGELOG.md | 1 + src/openharness/engine/query.py | 3 + src/openharness/services/compact/__init__.py | 48 ++++++++++++++ tests/test_engine/test_query_engine.py | 8 ++- tests/test_services/test_compact.py | 70 +++++++++++++++++++- 5 files changed, 128 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 034802c..447a676 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/openharness/engine/query.py b/src/openharness/engine/query.py index 08c2f30..9ce09a3 100644 --- a/src/openharness/engine/query.py +++ b/src/openharness/engine/query.py @@ -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", ) ) diff --git a/src/openharness/services/compact/__init__.py b/src/openharness/services/compact/__init__.py index d8d92a2..96286aa 100644 --- a/src/openharness/services/compact/__init__.py +++ b/src/openharness/services/compact/__init__.py @@ -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, diff --git a/tests/test_engine/test_query_engine.py b/tests/test_engine/test_query_engine.py index 67b6740..c81fd95 100644 --- a/tests/test_engine/test_query_engine.py +++ b/tests/test_engine/test_query_engine.py @@ -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) diff --git a/tests/test_services/test_compact.py b/tests/test_services/test_compact.py index fdc7864..ec519c2 100644 --- a/tests/test_services/test_compact.py +++ b/tests/test_services/test_compact.py @@ -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(["image context preserved"]) + + 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"