fix(gateway): degrade image attachments for text-only models

Support absolute glob patterns without crashing and retry ohmo channel messages without ImageBlocks when a provider rejects image input.\n\nFixes #225\nFixes #226
This commit is contained in:
tjb-tech
2026-05-02 14:04:25 +00:00
parent c835d7cf2e
commit 47e0e93387
4 changed files with 229 additions and 3 deletions
+95
View File
@@ -59,6 +59,10 @@ _CHANNEL_THINKING_PHRASES_EN = (
_TEXT_PREVIEW_BYTES = 4096
_TEXT_PREVIEW_CHARS = 900
_BINARY_HEAD_BYTES = 32
_IMAGE_FALLBACK_NOTE = (
"[Image attachment omitted because the active model does not support image input. "
"Use the attachment paths and summaries above if needed.]"
)
@dataclass(frozen=True)
@@ -331,6 +335,39 @@ class OhmoSessionRuntimePool:
)
try:
async for event in bundle.engine.submit_message(user_message):
if isinstance(event, ErrorEvent) and _should_retry_without_image_input(
event.message,
bundle.engine.messages,
):
logger.warning(
"ohmo runtime image input rejected; retrying without image blocks session_key=%s session_id=%s message=%r",
session_key,
bundle.session_id,
_content_snippet(event.message),
)
_strip_image_blocks_from_engine_history(bundle.engine)
yield GatewayStreamUpdate(
kind="progress",
text=_format_channel_progress(
channel=message.channel,
kind="image_fallback",
text=event.message,
session_key=session_key,
content=user_prompt,
),
metadata={"_progress": True, "_session_key": session_key, "_image_fallback": True},
)
async for retry_event in bundle.engine.continue_pending(max_turns=bundle.engine.max_turns):
async for update in self._convert_stream_event(
event=retry_event,
bundle=bundle,
message=message,
session_key=session_key,
content=user_prompt,
reply_parts=reply_parts,
):
yield update
break
async for update in self._convert_stream_event(
event=event,
bundle=bundle,
@@ -603,6 +640,10 @@ def _format_channel_progress(
return "🛠️ " + text.replace("Using ", "正在使用 ", 1)
return f"🛠️ {text}"
return text if text.startswith("🛠️ ") else f"🛠️ {text}"
if kind == "image_fallback":
if prefers_chinese:
return "🖼️ 当前模型不支持图片输入,我先改用附件路径和摘要继续。"
return "🖼️ The active model does not support image input. Ill retry with attachment paths and summaries."
if kind == "status":
normalized = text.strip()
if normalized == "Auto-compacting conversation memory to keep things fast and focused.":
@@ -680,6 +721,60 @@ def _build_inbound_user_message(message: InboundMessage) -> ConversationMessage:
return ConversationMessage.from_user_content(content)
def _should_retry_without_image_input(error_message: str, messages: list[ConversationMessage]) -> bool:
"""Return True when a provider rejects image input and history contains images."""
if not _history_has_image_blocks(messages):
return False
normalized = error_message.lower()
image_signal = any(
phrase in normalized
for phrase in (
"image input",
"image_url",
"multimodal",
"vision",
"image content",
)
)
rejection_signal = any(
phrase in normalized
for phrase in (
"no endpoints found",
"not support",
"does not support",
"unsupported",
"cannot support",
"can't support",
)
)
return image_signal and rejection_signal
def _history_has_image_blocks(messages: list[ConversationMessage]) -> bool:
return any(any(isinstance(block, ImageBlock) for block in message.content) for message in messages)
def _strip_image_blocks_from_engine_history(engine) -> None:
messages = _strip_image_blocks_from_messages(list(engine.messages))
if hasattr(engine, "load_messages"):
engine.load_messages(messages)
else:
engine.messages = messages
def _strip_image_blocks_from_messages(messages: list[ConversationMessage]) -> list[ConversationMessage]:
return [_strip_image_blocks_from_message(message) for message in messages]
def _strip_image_blocks_from_message(message: ConversationMessage) -> ConversationMessage:
if not any(isinstance(block, ImageBlock) for block in message.content):
return message
content = [block for block in message.content if not isinstance(block, ImageBlock)]
if not any(isinstance(block, TextBlock) for block in content):
content.append(TextBlock(text=_IMAGE_FALLBACK_NOTE))
return message.model_copy(update={"content": content})
def _build_speaker_context(message: InboundMessage) -> str:
"""Return a lightweight speaker header for group-chat messages."""
metadata = message.metadata or {}
+32 -2
View File
@@ -34,8 +34,8 @@ class GlobTool(BaseTool):
return True
async def execute(self, arguments: GlobToolInput, context: ToolExecutionContext) -> ToolResult:
root = _resolve_path(context.cwd, arguments.root) if arguments.root else context.cwd
matches = await _glob(root, arguments.pattern, limit=arguments.limit)
root, pattern = _resolve_glob_request(context.cwd, arguments.root, arguments.pattern)
matches = await _glob(root, pattern, limit=arguments.limit)
if not matches:
return ToolResult(output="(no matches)")
return ToolResult(output="\n".join(matches))
@@ -48,6 +48,33 @@ def _resolve_path(base: Path, candidate: str | None) -> Path:
return path.resolve()
def _resolve_glob_request(base: Path, root_arg: str | None, pattern: str) -> tuple[Path, str]:
"""Return a concrete search root plus a root-relative glob pattern."""
if not pattern.strip():
return (_resolve_path(base, root_arg) if root_arg else base, pattern)
candidate = Path(pattern).expanduser()
if not candidate.is_absolute():
return (_resolve_path(base, root_arg) if root_arg else base, pattern)
parts = candidate.parts
first_glob_index = next(
(index for index, part in enumerate(parts) if _has_glob_magic(part)),
None,
)
if first_glob_index is None:
return candidate.parent.resolve(), candidate.name
root_parts = parts[:first_glob_index]
root = Path(*root_parts).resolve() if root_parts else Path(candidate.anchor or "/").resolve()
relative_pattern = str(Path(*parts[first_glob_index:]))
return root, relative_pattern
def _has_glob_magic(value: str) -> bool:
return any(char in value for char in "*?[")
def _looks_like_git_repo(path: Path) -> bool:
"""Heuristic: determine whether we should include hidden paths when searching.
@@ -74,6 +101,9 @@ async def _glob(root: Path, pattern: str, *, limit: int) -> list[str]:
Uses ripgrep's file walker when available (respects .gitignore and can skip
heavy directories like `.venv/`), with a Python fallback.
"""
if not root.exists() or not root.is_dir():
return []
rg = shutil.which("rg")
# `Path.glob("**/*")` will traverse hidden and ignored paths (like `.venv/`)
# and can be very slow on real workspaces. Prefer `rg --files`.
+84 -1
View File
@@ -16,7 +16,7 @@ from openharness.commands import CommandResult
from openharness.commands.registry import SlashCommand, create_default_command_registry
from openharness.config.settings import Settings
from openharness.engine.messages import ConversationMessage, ImageBlock, TextBlock, ToolUseBlock
from openharness.engine.stream_events import AssistantTextDelta, CompactProgressEvent, ToolExecutionStarted
from openharness.engine.stream_events import AssistantTextDelta, CompactProgressEvent, ErrorEvent, ToolExecutionStarted
from openharness.memory import add_memory_entry as add_project_memory_entry
from openharness.memory import list_memory_files as list_project_memory_files
@@ -788,6 +788,89 @@ async def test_runtime_pool_includes_media_paths_in_prompt(tmp_path, monkeypatch
assert "text preview: Quarterly summary Revenue up 12%" in text
@pytest.mark.asyncio
async def test_runtime_pool_retries_with_attachment_summary_when_model_rejects_images(tmp_path, monkeypatch):
workspace = tmp_path / ".ohmo-home"
initialize_workspace(workspace)
image_path = tmp_path / "example.png"
image_path.write_bytes(
b"\x89PNG\r\n\x1a\n"
b"\x00\x00\x00\rIHDR"
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00"
b"\x90wS\xde\x00\x00\x00\nIDATx\x9cc`\x00\x00\x00\x02\x00\x01"
b"\xe2!\xbc3\x00\x00\x00\x00IEND\xaeB`\x82"
)
captured: dict[str, object] = {}
async def fake_build_runtime(**kwargs):
class FakeEngine:
def __init__(self):
self.messages = []
self.total_usage = UsageSnapshot()
self.max_turns = 8
def set_system_prompt(self, prompt):
return None
def load_messages(self, messages):
self.messages = list(messages)
async def submit_message(self, content):
self.messages.append(content)
yield ErrorEvent(
message=(
"API error: Error code: 404 - {'error': {'message': "
"'No endpoints found that support image input', 'code': 404}}"
)
)
async def continue_pending(self, *, max_turns=None):
captured["retry_messages"] = list(self.messages)
yield AssistantTextDelta(text="done")
return SimpleNamespace(
engine=FakeEngine(),
session_id="sess123",
current_settings=lambda: SimpleNamespace(model="openrouter/text-only"),
commands=SimpleNamespace(lookup=lambda raw: None),
)
async def fake_start_runtime(bundle):
return None
monkeypatch.setattr("ohmo.gateway.runtime.build_runtime", fake_build_runtime)
monkeypatch.setattr("ohmo.gateway.runtime.start_runtime", fake_start_runtime)
pool = OhmoSessionRuntimePool(cwd=tmp_path, workspace=workspace, provider_profile="openrouter")
message = InboundMessage(
channel="telegram",
sender_id="u1",
chat_id="c1",
content="帮我看这个图片",
media=[str(image_path)],
)
updates = [u async for u in pool.stream_message(message, "telegram:c1")]
assert updates[-1].kind == "final"
assert updates[-1].text == "done"
assert not any(update.kind == "error" for update in updates)
assert any(update.metadata.get("_image_fallback") for update in updates)
retry_messages = captured["retry_messages"]
assert all(
not isinstance(block, ImageBlock)
for item in retry_messages
for block in item.content
)
text = "".join(
block.text
for item in retry_messages
for block in item.content
if isinstance(block, TextBlock)
)
assert "[Channel attachments]" in text
assert f"image: example.png (path: {image_path})" in text
def test_runtime_pool_includes_group_speaker_context():
built = _build_inbound_user_message(
InboundMessage(
+18
View File
@@ -82,6 +82,24 @@ async def test_glob_and_grep(tmp_path: Path):
assert "a.py:1:def alpha():" in file_root_result.output
@pytest.mark.asyncio
async def test_glob_tool_accepts_absolute_patterns(tmp_path: Path, monkeypatch):
monkeypatch.setattr("openharness.tools.glob_tool.shutil.which", lambda _: None)
context = ToolExecutionContext(cwd=tmp_path.parent)
nested = tmp_path / "pkg"
nested.mkdir()
(nested / "a.py").write_text("print('a')\n", encoding="utf-8")
(nested / "b.txt").write_text("b\n", encoding="utf-8")
result = await GlobTool().execute(
GlobToolInput(pattern=str(tmp_path / "**" / "*.py")),
context,
)
assert result.is_error is False
assert result.output.splitlines() == ["pkg/a.py"]
@pytest.mark.asyncio
async def test_bash_tool_runs_command(tmp_path: Path):
result = await BashTool().execute(