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:
@@ -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. I’ll 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 {}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user