2025-10-01 23:29:25 +00:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""
|
|
|
|
|
|
Vision Tools Module
|
|
|
|
|
|
|
|
|
|
|
|
This module provides vision analysis tools that work with image URLs.
|
2026-03-14 21:14:20 -07:00
|
|
|
|
Uses the centralized auxiliary vision router, which can select OpenRouter,
|
|
|
|
|
|
Nous, Codex, native Anthropic, or a custom OpenAI-compatible endpoint.
|
2025-10-01 23:29:25 +00:00
|
|
|
|
|
|
|
|
|
|
Available tools:
|
|
|
|
|
|
- vision_analyze_tool: Analyze images from URLs with custom prompts
|
|
|
|
|
|
|
|
|
|
|
|
Features:
|
2025-10-08 02:38:04 +00:00
|
|
|
|
- Downloads images from URLs and converts to base64 for API compatibility
|
2025-10-01 23:29:25 +00:00
|
|
|
|
- Comprehensive image description
|
|
|
|
|
|
- Context-aware analysis based on user queries
|
2025-10-08 02:38:04 +00:00
|
|
|
|
- Automatic temporary file cleanup
|
2025-10-01 23:29:25 +00:00
|
|
|
|
- Proper error handling and validation
|
|
|
|
|
|
- Debug logging support
|
|
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
|
from vision_tools import vision_analyze_tool
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
|
|
|
|
|
|
# Analyze an image
|
|
|
|
|
|
result = await vision_analyze_tool(
|
|
|
|
|
|
image_url="https://example.com/image.jpg",
|
|
|
|
|
|
user_prompt="What architectural style is this building?"
|
|
|
|
|
|
)
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-03-05 16:11:59 +03:00
|
|
|
|
import base64
|
fix(vision): cap vision_analyze fan-out concurrency process-wide
A single agent turn can fan out N vision_analyze calls at once — the
classic trigger is "analyze every frame of this video", where ffmpeg
explodes a clip into dozens of frames and the model calls vision_analyze
on each. Every call does a CPU-heavy base64-encode/resize burst AND holds
a long-lived LLM stream open. The tool executor runs concurrent tool calls
on a per-session ThreadPoolExecutor (_MAX_TOOL_WORKERS=8), and multiple
agent sessions share one process (the dashboard runs the agent in-process),
so there was no global ceiling. In prod (June 2026) a video-frame fan-out
pinned a worker thread at ~100% CPU and starved the shared asyncio event
loop that also serves the dashboard's /api/status liveness probe, flapping
the instance to UNHEALTHY even though nothing had crashed.
Add a process-global threading.BoundedSemaphore that bounds how many vision
analyses run concurrently across the whole process, held across the entire
analysis (image load + encode + LLM call) in the single _handle_vision_analyze
chokepoint (covers both the native fast path and the legacy aux-LLM path).
It is a threading semaphore, NOT asyncio: each vision call is dispatched
through model_tools._run_async on a per-thread event loop, so an asyncio
primitive bound to one loop cannot coordinate across them. The acquire is
offloaded via run_in_executor so waiting for a slot never blocks the calling
loop.
Default: min(host CPUs, 4), floored at 1 — respect the host's concurrency,
or lower. Override via auxiliary.vision.max_concurrency (config.yaml) or
HERMES_VISION_MAX_CONCURRENCY (env). Values < 1 are ignored so the cap can
never be disabled into an unbounded fan-out.
Tests: bounded-fan-out regression guard + a control proving it would fail
without the cap; resolver tests for host-cpu default, ceiling clamp, low-cpu
host, env override, and sub-1 rejection. Pre-existing handler tests updated
for the now-async _handle_vision_analyze. Verified via the real
registry.dispatch -> _run_async per-thread-loop path (16 concurrent calls,
peak bounded to cap).
2026-06-29 15:18:01 +10:00
|
|
|
|
import contextlib
|
|
|
|
|
|
import asyncio
|
2025-10-01 23:29:25 +00:00
|
|
|
|
import json
|
fix(vision): narrow the fan-out cap to the CPU encode burst only
The original cap held a process-global slot across the WHOLE vision
analysis (image load + encode + LLM call) with a default of min(CPUs, 4).
That serialized legitimate multi-image workflows — "compare these 6
screenshots", "read this 10-page scan", "analyze every frame" — behind a
4-wide gate, and on the native fast path it even throttled calls that make
no LLM request at all. Excess calls queued (blocking acquire, nothing
dropped), but the latency hit on real fan-out was the wrong tradeoff.
The incident was CPU exhaustion, not call count: concurrent base64/resize
bursts saturated every core and left none to service the shared event loop
serving /api/status. So cap ONLY that:
- A dedicated, bounded ThreadPoolExecutor (_vision_cpu_executor) runs the
encode/resize/dimension-check off the caller's loop, sized to the host's
usable core count with NO fixed ceiling — the cap tracks the actual
exhausted resource (cores), not a magic number. Excess encodes queue on
the executor; cores stay free for the loop.
- The LLM call is deliberately OUTSIDE the executor, so multi-image
workflows keep full request concurrency.
- Override via auxiliary.vision.max_concurrency / HERMES_VISION_MAX_CONCURRENCY
(honored verbatim, including above core count); sub-1 ignored.
- _vision_concurrency_slot() is now a no-op shim for back-compat.
Tests assert: resolver defaults to host cores with no ceiling; env/config
override (incl. above cores); sub-1 rejection; the executor is dedicated and
core-sized; encode runs on a vision-encode thread; and crucially that encode
bursts are bounded to the cap while the analyses themselves stay fully
concurrent (calls_peak > cap).
2026-06-28 22:48:37 -07:00
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
2026-02-21 03:11:11 -08:00
|
|
|
|
import logging
|
2025-10-01 23:29:25 +00:00
|
|
|
|
import os
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
from pathlib import Path
|
2026-03-05 16:11:59 +03:00
|
|
|
|
from typing import Any, Awaitable, Dict, Optional
|
|
|
|
|
|
from urllib.parse import urlparse
|
2026-02-20 23:23:32 -08:00
|
|
|
|
import httpx
|
2026-03-27 15:28:19 -07:00
|
|
|
|
from agent.auxiliary_client import async_call_llm, extract_content_or_reasoning
|
2026-04-29 20:14:02 -07:00
|
|
|
|
from hermes_constants import get_hermes_dir
|
2026-02-21 03:53:24 -08:00
|
|
|
|
from tools.debug_helpers import DebugSession
|
2026-03-29 20:55:04 -07:00
|
|
|
|
from tools.website_policy import check_website_access
|
2026-05-11 11:20:58 -07:00
|
|
|
|
import sys
|
2025-10-01 23:29:25 +00:00
|
|
|
|
|
2026-02-21 03:11:11 -08:00
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
2026-02-21 03:53:24 -08:00
|
|
|
|
_debug = DebugSession("vision_tools", env_var="VISION_TOOLS_DEBUG")
|
2025-10-01 23:29:25 +00:00
|
|
|
|
|
2026-03-30 02:59:39 -07:00
|
|
|
|
# Configurable HTTP download timeout for _download_image().
|
|
|
|
|
|
# Separate from auxiliary.vision.timeout which governs the LLM API call.
|
|
|
|
|
|
# Resolution: config.yaml auxiliary.vision.download_timeout → env var → 30s default.
|
|
|
|
|
|
def _resolve_download_timeout() -> float:
|
|
|
|
|
|
env_val = os.getenv("HERMES_VISION_DOWNLOAD_TIMEOUT", "").strip()
|
|
|
|
|
|
if env_val:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return float(env_val)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
try:
|
refactor(config): add cfg_get() helper; migrate 20 nested-get call sites (#17304)
The "cfg.get('X', {}).get('Y', default)" pattern appears 50+ times
across tools/, gateway/, and plugins/. Each call site manually handles
the same three gotchas:
1. Missing intermediate key → empty dict → chain works
2. Non-dict value at intermediate position → AttributeError
(uncaught in most sites, so a misconfigured YAML crashes the tool)
3. cfg is None → AttributeError
Introduces cfg_get(cfg, *keys, default=None) in hermes_cli/config.py
as the canonical helper. Handles all three uniformly, returns default
only when the final key is *absent* (matches dict.get semantics —
explicit None values are preserved, falsy values like 0 / False / ''
are preserved).
Named cfg_get rather than cfg_path to avoid shadowing the existing
'cfg_path = _hermes_home / "config.yaml"' local variable that appears
in gateway/run.py, cron/scheduler.py, hermes_cli/main.py, etc.
Migrated 20 call sites as the first-batch proof-of-value:
gateway/run.py 10 sites (agent/display subtrees)
tools/browser_tool.py 3 sites
tools/vision_tools.py 2 sites
tools/browser_camofox.py 1 site
tools/approval.py 1 site
tools/skills_tool.py 1 site
tools/skill_manager_tool.py 1 site
tools/credential_files.py 1 site
tools/env_passthrough.py 1 site
The remaining ~30 sites across plugins/ and smaller tool files can be
migrated opportunistically — the helper is now available and the
pattern is established.
Fixed a latent bug along the way: tools/vision_tools.py had its
cfg_get usage at line 560 inside a function that locally re-imports
'from hermes_cli.config import load_config', but the AST-based
migration script wrote the top-level cfg_get import to a different
function scope, leaving line 560's cfg_get as a NameError silently
swallowed by the surrounding try/except. Test
test_vision_uses_configured_temperature_and_timeout caught it. Fixed
by including cfg_get in the function-local import.
Verified:
- 7880/7893 tests/tools/ + tests/gateway/ + tests/hermes_cli/test_config
tests pass; all 13 failures pre-existing on main (MCP, delegate,
session_split_brain — verified earlier in the sweep).
- All 20 migrated sites AST-verified to have cfg_get in scope (either
module-level or function-local).
- Live 'hermes chat' smoke: 2 turns + /model switch + tool calls +
/quit, zero errors. Agent correctly counted 20 cfg_get hits across
8 tool files — matching the migration.
Semantic parity verified against the original pattern across 8 edge
cases (missing keys, None values, falsy values, empty strings, string
instead of dict, None cfg, nested levels).
2026-04-28 23:17:39 -07:00
|
|
|
|
from hermes_cli.config import cfg_get, load_config
|
2026-03-30 02:59:39 -07:00
|
|
|
|
cfg = load_config()
|
refactor(config): add cfg_get() helper; migrate 20 nested-get call sites (#17304)
The "cfg.get('X', {}).get('Y', default)" pattern appears 50+ times
across tools/, gateway/, and plugins/. Each call site manually handles
the same three gotchas:
1. Missing intermediate key → empty dict → chain works
2. Non-dict value at intermediate position → AttributeError
(uncaught in most sites, so a misconfigured YAML crashes the tool)
3. cfg is None → AttributeError
Introduces cfg_get(cfg, *keys, default=None) in hermes_cli/config.py
as the canonical helper. Handles all three uniformly, returns default
only when the final key is *absent* (matches dict.get semantics —
explicit None values are preserved, falsy values like 0 / False / ''
are preserved).
Named cfg_get rather than cfg_path to avoid shadowing the existing
'cfg_path = _hermes_home / "config.yaml"' local variable that appears
in gateway/run.py, cron/scheduler.py, hermes_cli/main.py, etc.
Migrated 20 call sites as the first-batch proof-of-value:
gateway/run.py 10 sites (agent/display subtrees)
tools/browser_tool.py 3 sites
tools/vision_tools.py 2 sites
tools/browser_camofox.py 1 site
tools/approval.py 1 site
tools/skills_tool.py 1 site
tools/skill_manager_tool.py 1 site
tools/credential_files.py 1 site
tools/env_passthrough.py 1 site
The remaining ~30 sites across plugins/ and smaller tool files can be
migrated opportunistically — the helper is now available and the
pattern is established.
Fixed a latent bug along the way: tools/vision_tools.py had its
cfg_get usage at line 560 inside a function that locally re-imports
'from hermes_cli.config import load_config', but the AST-based
migration script wrote the top-level cfg_get import to a different
function scope, leaving line 560's cfg_get as a NameError silently
swallowed by the surrounding try/except. Test
test_vision_uses_configured_temperature_and_timeout caught it. Fixed
by including cfg_get in the function-local import.
Verified:
- 7880/7893 tests/tools/ + tests/gateway/ + tests/hermes_cli/test_config
tests pass; all 13 failures pre-existing on main (MCP, delegate,
session_split_brain — verified earlier in the sweep).
- All 20 migrated sites AST-verified to have cfg_get in scope (either
module-level or function-local).
- Live 'hermes chat' smoke: 2 turns + /model switch + tool calls +
/quit, zero errors. Agent correctly counted 20 cfg_get hits across
8 tool files — matching the migration.
Semantic parity verified against the original pattern across 8 edge
cases (missing keys, None values, falsy values, empty strings, string
instead of dict, None cfg, nested levels).
2026-04-28 23:17:39 -07:00
|
|
|
|
val = cfg_get(cfg, "auxiliary", "vision", "download_timeout")
|
2026-03-30 02:59:39 -07:00
|
|
|
|
if val is not None:
|
|
|
|
|
|
return float(val)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return 30.0
|
|
|
|
|
|
|
|
|
|
|
|
_VISION_DOWNLOAD_TIMEOUT = _resolve_download_timeout()
|
|
|
|
|
|
|
2026-04-10 12:13:42 +08:00
|
|
|
|
# Hard cap on downloaded image file size (50 MB). Prevents OOM from
|
|
|
|
|
|
# attacker-hosted multi-gigabyte files or decompression bombs.
|
|
|
|
|
|
_VISION_MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024
|
|
|
|
|
|
|
2025-10-01 23:29:25 +00:00
|
|
|
|
|
fix(vision): cap vision_analyze fan-out concurrency process-wide
A single agent turn can fan out N vision_analyze calls at once — the
classic trigger is "analyze every frame of this video", where ffmpeg
explodes a clip into dozens of frames and the model calls vision_analyze
on each. Every call does a CPU-heavy base64-encode/resize burst AND holds
a long-lived LLM stream open. The tool executor runs concurrent tool calls
on a per-session ThreadPoolExecutor (_MAX_TOOL_WORKERS=8), and multiple
agent sessions share one process (the dashboard runs the agent in-process),
so there was no global ceiling. In prod (June 2026) a video-frame fan-out
pinned a worker thread at ~100% CPU and starved the shared asyncio event
loop that also serves the dashboard's /api/status liveness probe, flapping
the instance to UNHEALTHY even though nothing had crashed.
Add a process-global threading.BoundedSemaphore that bounds how many vision
analyses run concurrently across the whole process, held across the entire
analysis (image load + encode + LLM call) in the single _handle_vision_analyze
chokepoint (covers both the native fast path and the legacy aux-LLM path).
It is a threading semaphore, NOT asyncio: each vision call is dispatched
through model_tools._run_async on a per-thread event loop, so an asyncio
primitive bound to one loop cannot coordinate across them. The acquire is
offloaded via run_in_executor so waiting for a slot never blocks the calling
loop.
Default: min(host CPUs, 4), floored at 1 — respect the host's concurrency,
or lower. Override via auxiliary.vision.max_concurrency (config.yaml) or
HERMES_VISION_MAX_CONCURRENCY (env). Values < 1 are ignored so the cap can
never be disabled into an unbounded fan-out.
Tests: bounded-fan-out regression guard + a control proving it would fail
without the cap; resolver tests for host-cpu default, ceiling clamp, low-cpu
host, env override, and sub-1 rejection. Pre-existing handler tests updated
for the now-async _handle_vision_analyze. Verified via the real
registry.dispatch -> _run_async per-thread-loop path (16 concurrent calls,
peak bounded to cap).
2026-06-29 15:18:01 +10:00
|
|
|
|
# ---------------------------------------------------------------------------
|
fix(vision): narrow the fan-out cap to the CPU encode burst only
The original cap held a process-global slot across the WHOLE vision
analysis (image load + encode + LLM call) with a default of min(CPUs, 4).
That serialized legitimate multi-image workflows — "compare these 6
screenshots", "read this 10-page scan", "analyze every frame" — behind a
4-wide gate, and on the native fast path it even throttled calls that make
no LLM request at all. Excess calls queued (blocking acquire, nothing
dropped), but the latency hit on real fan-out was the wrong tradeoff.
The incident was CPU exhaustion, not call count: concurrent base64/resize
bursts saturated every core and left none to service the shared event loop
serving /api/status. So cap ONLY that:
- A dedicated, bounded ThreadPoolExecutor (_vision_cpu_executor) runs the
encode/resize/dimension-check off the caller's loop, sized to the host's
usable core count with NO fixed ceiling — the cap tracks the actual
exhausted resource (cores), not a magic number. Excess encodes queue on
the executor; cores stay free for the loop.
- The LLM call is deliberately OUTSIDE the executor, so multi-image
workflows keep full request concurrency.
- Override via auxiliary.vision.max_concurrency / HERMES_VISION_MAX_CONCURRENCY
(honored verbatim, including above core count); sub-1 ignored.
- _vision_concurrency_slot() is now a no-op shim for back-compat.
Tests assert: resolver defaults to host cores with no ceiling; env/config
override (incl. above cores); sub-1 rejection; the executor is dedicated and
core-sized; encode runs on a vision-encode thread; and crucially that encode
bursts are bounded to the cap while the analyses themselves stay fully
concurrent (calls_peak > cap).
2026-06-28 22:48:37 -07:00
|
|
|
|
# CPU-burst concurrency cap (vision encode/resize)
|
fix(vision): cap vision_analyze fan-out concurrency process-wide
A single agent turn can fan out N vision_analyze calls at once — the
classic trigger is "analyze every frame of this video", where ffmpeg
explodes a clip into dozens of frames and the model calls vision_analyze
on each. Every call does a CPU-heavy base64-encode/resize burst AND holds
a long-lived LLM stream open. The tool executor runs concurrent tool calls
on a per-session ThreadPoolExecutor (_MAX_TOOL_WORKERS=8), and multiple
agent sessions share one process (the dashboard runs the agent in-process),
so there was no global ceiling. In prod (June 2026) a video-frame fan-out
pinned a worker thread at ~100% CPU and starved the shared asyncio event
loop that also serves the dashboard's /api/status liveness probe, flapping
the instance to UNHEALTHY even though nothing had crashed.
Add a process-global threading.BoundedSemaphore that bounds how many vision
analyses run concurrently across the whole process, held across the entire
analysis (image load + encode + LLM call) in the single _handle_vision_analyze
chokepoint (covers both the native fast path and the legacy aux-LLM path).
It is a threading semaphore, NOT asyncio: each vision call is dispatched
through model_tools._run_async on a per-thread event loop, so an asyncio
primitive bound to one loop cannot coordinate across them. The acquire is
offloaded via run_in_executor so waiting for a slot never blocks the calling
loop.
Default: min(host CPUs, 4), floored at 1 — respect the host's concurrency,
or lower. Override via auxiliary.vision.max_concurrency (config.yaml) or
HERMES_VISION_MAX_CONCURRENCY (env). Values < 1 are ignored so the cap can
never be disabled into an unbounded fan-out.
Tests: bounded-fan-out regression guard + a control proving it would fail
without the cap; resolver tests for host-cpu default, ceiling clamp, low-cpu
host, env override, and sub-1 rejection. Pre-existing handler tests updated
for the now-async _handle_vision_analyze. Verified via the real
registry.dispatch -> _run_async per-thread-loop path (16 concurrent calls,
peak bounded to cap).
2026-06-29 15:18:01 +10:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# A single agent turn can fan out N vision_analyze calls at once (the classic
|
|
|
|
|
|
# trigger is "analyze every frame of this video" — ffmpeg explodes a clip into
|
fix(vision): narrow the fan-out cap to the CPU encode burst only
The original cap held a process-global slot across the WHOLE vision
analysis (image load + encode + LLM call) with a default of min(CPUs, 4).
That serialized legitimate multi-image workflows — "compare these 6
screenshots", "read this 10-page scan", "analyze every frame" — behind a
4-wide gate, and on the native fast path it even throttled calls that make
no LLM request at all. Excess calls queued (blocking acquire, nothing
dropped), but the latency hit on real fan-out was the wrong tradeoff.
The incident was CPU exhaustion, not call count: concurrent base64/resize
bursts saturated every core and left none to service the shared event loop
serving /api/status. So cap ONLY that:
- A dedicated, bounded ThreadPoolExecutor (_vision_cpu_executor) runs the
encode/resize/dimension-check off the caller's loop, sized to the host's
usable core count with NO fixed ceiling — the cap tracks the actual
exhausted resource (cores), not a magic number. Excess encodes queue on
the executor; cores stay free for the loop.
- The LLM call is deliberately OUTSIDE the executor, so multi-image
workflows keep full request concurrency.
- Override via auxiliary.vision.max_concurrency / HERMES_VISION_MAX_CONCURRENCY
(honored verbatim, including above core count); sub-1 ignored.
- _vision_concurrency_slot() is now a no-op shim for back-compat.
Tests assert: resolver defaults to host cores with no ceiling; env/config
override (incl. above cores); sub-1 rejection; the executor is dedicated and
core-sized; encode runs on a vision-encode thread; and crucially that encode
bursts are bounded to the cap while the analyses themselves stay fully
concurrent (calls_peak > cap).
2026-06-28 22:48:37 -07:00
|
|
|
|
# dozens of frames, the model then calls vision_analyze on each). Each call does
|
|
|
|
|
|
# a CPU-heavy base64-encode + (sometimes) Pillow resize. The tool executor runs
|
|
|
|
|
|
# concurrent tool calls on a ThreadPoolExecutor (agent.tool_executor =
|
|
|
|
|
|
# 8 workers) PER SESSION, and several agent sessions share one process (the
|
|
|
|
|
|
# dashboard runs the agent in-process). Unbounded, a video-frame fan-out across
|
|
|
|
|
|
# one or more sessions runs *every* encode at once, saturates all cores, and
|
|
|
|
|
|
# leaves no CPU to service the shared asyncio event loop that serves the
|
|
|
|
|
|
# dashboard's /api/status liveness probe — so the instance flaps to UNHEALTHY
|
|
|
|
|
|
# even though nothing has crashed (observed in prod, June 2026).
|
fix(vision): cap vision_analyze fan-out concurrency process-wide
A single agent turn can fan out N vision_analyze calls at once — the
classic trigger is "analyze every frame of this video", where ffmpeg
explodes a clip into dozens of frames and the model calls vision_analyze
on each. Every call does a CPU-heavy base64-encode/resize burst AND holds
a long-lived LLM stream open. The tool executor runs concurrent tool calls
on a per-session ThreadPoolExecutor (_MAX_TOOL_WORKERS=8), and multiple
agent sessions share one process (the dashboard runs the agent in-process),
so there was no global ceiling. In prod (June 2026) a video-frame fan-out
pinned a worker thread at ~100% CPU and starved the shared asyncio event
loop that also serves the dashboard's /api/status liveness probe, flapping
the instance to UNHEALTHY even though nothing had crashed.
Add a process-global threading.BoundedSemaphore that bounds how many vision
analyses run concurrently across the whole process, held across the entire
analysis (image load + encode + LLM call) in the single _handle_vision_analyze
chokepoint (covers both the native fast path and the legacy aux-LLM path).
It is a threading semaphore, NOT asyncio: each vision call is dispatched
through model_tools._run_async on a per-thread event loop, so an asyncio
primitive bound to one loop cannot coordinate across them. The acquire is
offloaded via run_in_executor so waiting for a slot never blocks the calling
loop.
Default: min(host CPUs, 4), floored at 1 — respect the host's concurrency,
or lower. Override via auxiliary.vision.max_concurrency (config.yaml) or
HERMES_VISION_MAX_CONCURRENCY (env). Values < 1 are ignored so the cap can
never be disabled into an unbounded fan-out.
Tests: bounded-fan-out regression guard + a control proving it would fail
without the cap; resolver tests for host-cpu default, ceiling clamp, low-cpu
host, env override, and sub-1 rejection. Pre-existing handler tests updated
for the now-async _handle_vision_analyze. Verified via the real
registry.dispatch -> _run_async per-thread-loop path (16 concurrent calls,
peak bounded to cap).
2026-06-29 15:18:01 +10:00
|
|
|
|
#
|
fix(vision): narrow the fan-out cap to the CPU encode burst only
The original cap held a process-global slot across the WHOLE vision
analysis (image load + encode + LLM call) with a default of min(CPUs, 4).
That serialized legitimate multi-image workflows — "compare these 6
screenshots", "read this 10-page scan", "analyze every frame" — behind a
4-wide gate, and on the native fast path it even throttled calls that make
no LLM request at all. Excess calls queued (blocking acquire, nothing
dropped), but the latency hit on real fan-out was the wrong tradeoff.
The incident was CPU exhaustion, not call count: concurrent base64/resize
bursts saturated every core and left none to service the shared event loop
serving /api/status. So cap ONLY that:
- A dedicated, bounded ThreadPoolExecutor (_vision_cpu_executor) runs the
encode/resize/dimension-check off the caller's loop, sized to the host's
usable core count with NO fixed ceiling — the cap tracks the actual
exhausted resource (cores), not a magic number. Excess encodes queue on
the executor; cores stay free for the loop.
- The LLM call is deliberately OUTSIDE the executor, so multi-image
workflows keep full request concurrency.
- Override via auxiliary.vision.max_concurrency / HERMES_VISION_MAX_CONCURRENCY
(honored verbatim, including above core count); sub-1 ignored.
- _vision_concurrency_slot() is now a no-op shim for back-compat.
Tests assert: resolver defaults to host cores with no ceiling; env/config
override (incl. above cores); sub-1 rejection; the executor is dedicated and
core-sized; encode runs on a vision-encode thread; and crucially that encode
bursts are bounded to the cap while the analyses themselves stay fully
concurrent (calls_peak > cap).
2026-06-28 22:48:37 -07:00
|
|
|
|
# The fix is NOT to cap how many vision analyses run — multi-image workflows
|
|
|
|
|
|
# ("compare these 6 screenshots", "read this 10-page scan") legitimately want
|
|
|
|
|
|
# high concurrency, and the slow part (the LLM stream) is network-bound and
|
|
|
|
|
|
# harmless to the loop. We cap ONLY the CPU burst: the encode/resize is offloaded
|
|
|
|
|
|
# to a dedicated, bounded executor sized to the host's usable core count. That
|
|
|
|
|
|
# is the resource the incident actually exhausted (cores), so bounding it to
|
|
|
|
|
|
# cores is *correct*, not an arbitrary number — excess encodes queue on the
|
|
|
|
|
|
# executor instead of all running at once, the LLM calls stay fully concurrent,
|
|
|
|
|
|
# and the loop always keeps a core. No fixed ceiling: the limit tracks the host.
|
fix(vision): cap vision_analyze fan-out concurrency process-wide
A single agent turn can fan out N vision_analyze calls at once — the
classic trigger is "analyze every frame of this video", where ffmpeg
explodes a clip into dozens of frames and the model calls vision_analyze
on each. Every call does a CPU-heavy base64-encode/resize burst AND holds
a long-lived LLM stream open. The tool executor runs concurrent tool calls
on a per-session ThreadPoolExecutor (_MAX_TOOL_WORKERS=8), and multiple
agent sessions share one process (the dashboard runs the agent in-process),
so there was no global ceiling. In prod (June 2026) a video-frame fan-out
pinned a worker thread at ~100% CPU and starved the shared asyncio event
loop that also serves the dashboard's /api/status liveness probe, flapping
the instance to UNHEALTHY even though nothing had crashed.
Add a process-global threading.BoundedSemaphore that bounds how many vision
analyses run concurrently across the whole process, held across the entire
analysis (image load + encode + LLM call) in the single _handle_vision_analyze
chokepoint (covers both the native fast path and the legacy aux-LLM path).
It is a threading semaphore, NOT asyncio: each vision call is dispatched
through model_tools._run_async on a per-thread event loop, so an asyncio
primitive bound to one loop cannot coordinate across them. The acquire is
offloaded via run_in_executor so waiting for a slot never blocks the calling
loop.
Default: min(host CPUs, 4), floored at 1 — respect the host's concurrency,
or lower. Override via auxiliary.vision.max_concurrency (config.yaml) or
HERMES_VISION_MAX_CONCURRENCY (env). Values < 1 are ignored so the cap can
never be disabled into an unbounded fan-out.
Tests: bounded-fan-out regression guard + a control proving it would fail
without the cap; resolver tests for host-cpu default, ceiling clamp, low-cpu
host, env override, and sub-1 rejection. Pre-existing handler tests updated
for the now-async _handle_vision_analyze. Verified via the real
registry.dispatch -> _run_async per-thread-loop path (16 concurrent calls,
peak bounded to cap).
2026-06-29 15:18:01 +10:00
|
|
|
|
#
|
fix(vision): narrow the fan-out cap to the CPU encode burst only
The original cap held a process-global slot across the WHOLE vision
analysis (image load + encode + LLM call) with a default of min(CPUs, 4).
That serialized legitimate multi-image workflows — "compare these 6
screenshots", "read this 10-page scan", "analyze every frame" — behind a
4-wide gate, and on the native fast path it even throttled calls that make
no LLM request at all. Excess calls queued (blocking acquire, nothing
dropped), but the latency hit on real fan-out was the wrong tradeoff.
The incident was CPU exhaustion, not call count: concurrent base64/resize
bursts saturated every core and left none to service the shared event loop
serving /api/status. So cap ONLY that:
- A dedicated, bounded ThreadPoolExecutor (_vision_cpu_executor) runs the
encode/resize/dimension-check off the caller's loop, sized to the host's
usable core count with NO fixed ceiling — the cap tracks the actual
exhausted resource (cores), not a magic number. Excess encodes queue on
the executor; cores stay free for the loop.
- The LLM call is deliberately OUTSIDE the executor, so multi-image
workflows keep full request concurrency.
- Override via auxiliary.vision.max_concurrency / HERMES_VISION_MAX_CONCURRENCY
(honored verbatim, including above core count); sub-1 ignored.
- _vision_concurrency_slot() is now a no-op shim for back-compat.
Tests assert: resolver defaults to host cores with no ceiling; env/config
override (incl. above cores); sub-1 rejection; the executor is dedicated and
core-sized; encode runs on a vision-encode thread; and crucially that encode
bursts are bounded to the cap while the analyses themselves stay fully
concurrent (calls_peak > cap).
2026-06-28 22:48:37 -07:00
|
|
|
|
# A threading primitive (NOT asyncio) is required: each vision call is dispatched
|
|
|
|
|
|
# through model_tools._run_async on a PER-THREAD event loop, so an asyncio
|
|
|
|
|
|
# executor/semaphore bound to one loop cannot coordinate across them. A
|
|
|
|
|
|
# ThreadPoolExecutor is loop- and thread-agnostic.
|
|
|
|
|
|
import threading # noqa: F401 (kept for downstream importers / patch targets)
|
fix(vision): cap vision_analyze fan-out concurrency process-wide
A single agent turn can fan out N vision_analyze calls at once — the
classic trigger is "analyze every frame of this video", where ffmpeg
explodes a clip into dozens of frames and the model calls vision_analyze
on each. Every call does a CPU-heavy base64-encode/resize burst AND holds
a long-lived LLM stream open. The tool executor runs concurrent tool calls
on a per-session ThreadPoolExecutor (_MAX_TOOL_WORKERS=8), and multiple
agent sessions share one process (the dashboard runs the agent in-process),
so there was no global ceiling. In prod (June 2026) a video-frame fan-out
pinned a worker thread at ~100% CPU and starved the shared asyncio event
loop that also serves the dashboard's /api/status liveness probe, flapping
the instance to UNHEALTHY even though nothing had crashed.
Add a process-global threading.BoundedSemaphore that bounds how many vision
analyses run concurrently across the whole process, held across the entire
analysis (image load + encode + LLM call) in the single _handle_vision_analyze
chokepoint (covers both the native fast path and the legacy aux-LLM path).
It is a threading semaphore, NOT asyncio: each vision call is dispatched
through model_tools._run_async on a per-thread event loop, so an asyncio
primitive bound to one loop cannot coordinate across them. The acquire is
offloaded via run_in_executor so waiting for a slot never blocks the calling
loop.
Default: min(host CPUs, 4), floored at 1 — respect the host's concurrency,
or lower. Override via auxiliary.vision.max_concurrency (config.yaml) or
HERMES_VISION_MAX_CONCURRENCY (env). Values < 1 are ignored so the cap can
never be disabled into an unbounded fan-out.
Tests: bounded-fan-out regression guard + a control proving it would fail
without the cap; resolver tests for host-cpu default, ceiling clamp, low-cpu
host, env override, and sub-1 rejection. Pre-existing handler tests updated
for the now-async _handle_vision_analyze. Verified via the real
registry.dispatch -> _run_async per-thread-loop path (16 concurrent calls,
peak bounded to cap).
2026-06-29 15:18:01 +10:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _detect_host_cpus() -> int:
|
|
|
|
|
|
"""Best-effort host CPU count, honoring cgroup/affinity limits when set.
|
|
|
|
|
|
|
|
|
|
|
|
Prefers ``os.sched_getaffinity`` (the CPUs this process may actually run
|
|
|
|
|
|
on — respects container/cpuset pinning) and falls back to
|
|
|
|
|
|
``os.cpu_count()``. Returns at least 1.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
return max(1, len(os.sched_getaffinity(0))) # type: ignore[attr-defined]
|
|
|
|
|
|
except (AttributeError, OSError):
|
|
|
|
|
|
return max(1, os.cpu_count() or 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
fix(vision): narrow the fan-out cap to the CPU encode burst only
The original cap held a process-global slot across the WHOLE vision
analysis (image load + encode + LLM call) with a default of min(CPUs, 4).
That serialized legitimate multi-image workflows — "compare these 6
screenshots", "read this 10-page scan", "analyze every frame" — behind a
4-wide gate, and on the native fast path it even throttled calls that make
no LLM request at all. Excess calls queued (blocking acquire, nothing
dropped), but the latency hit on real fan-out was the wrong tradeoff.
The incident was CPU exhaustion, not call count: concurrent base64/resize
bursts saturated every core and left none to service the shared event loop
serving /api/status. So cap ONLY that:
- A dedicated, bounded ThreadPoolExecutor (_vision_cpu_executor) runs the
encode/resize/dimension-check off the caller's loop, sized to the host's
usable core count with NO fixed ceiling — the cap tracks the actual
exhausted resource (cores), not a magic number. Excess encodes queue on
the executor; cores stay free for the loop.
- The LLM call is deliberately OUTSIDE the executor, so multi-image
workflows keep full request concurrency.
- Override via auxiliary.vision.max_concurrency / HERMES_VISION_MAX_CONCURRENCY
(honored verbatim, including above core count); sub-1 ignored.
- _vision_concurrency_slot() is now a no-op shim for back-compat.
Tests assert: resolver defaults to host cores with no ceiling; env/config
override (incl. above cores); sub-1 rejection; the executor is dedicated and
core-sized; encode runs on a vision-encode thread; and crucially that encode
bursts are bounded to the cap while the analyses themselves stay fully
concurrent (calls_peak > cap).
2026-06-28 22:48:37 -07:00
|
|
|
|
def _resolve_vision_cpu_workers() -> int:
|
|
|
|
|
|
"""Resolve how many vision encode/resize bursts may run concurrently.
|
fix(vision): cap vision_analyze fan-out concurrency process-wide
A single agent turn can fan out N vision_analyze calls at once — the
classic trigger is "analyze every frame of this video", where ffmpeg
explodes a clip into dozens of frames and the model calls vision_analyze
on each. Every call does a CPU-heavy base64-encode/resize burst AND holds
a long-lived LLM stream open. The tool executor runs concurrent tool calls
on a per-session ThreadPoolExecutor (_MAX_TOOL_WORKERS=8), and multiple
agent sessions share one process (the dashboard runs the agent in-process),
so there was no global ceiling. In prod (June 2026) a video-frame fan-out
pinned a worker thread at ~100% CPU and starved the shared asyncio event
loop that also serves the dashboard's /api/status liveness probe, flapping
the instance to UNHEALTHY even though nothing had crashed.
Add a process-global threading.BoundedSemaphore that bounds how many vision
analyses run concurrently across the whole process, held across the entire
analysis (image load + encode + LLM call) in the single _handle_vision_analyze
chokepoint (covers both the native fast path and the legacy aux-LLM path).
It is a threading semaphore, NOT asyncio: each vision call is dispatched
through model_tools._run_async on a per-thread event loop, so an asyncio
primitive bound to one loop cannot coordinate across them. The acquire is
offloaded via run_in_executor so waiting for a slot never blocks the calling
loop.
Default: min(host CPUs, 4), floored at 1 — respect the host's concurrency,
or lower. Override via auxiliary.vision.max_concurrency (config.yaml) or
HERMES_VISION_MAX_CONCURRENCY (env). Values < 1 are ignored so the cap can
never be disabled into an unbounded fan-out.
Tests: bounded-fan-out regression guard + a control proving it would fail
without the cap; resolver tests for host-cpu default, ceiling clamp, low-cpu
host, env override, and sub-1 rejection. Pre-existing handler tests updated
for the now-async _handle_vision_analyze. Verified via the real
registry.dispatch -> _run_async per-thread-loop path (16 concurrent calls,
peak bounded to cap).
2026-06-29 15:18:01 +10:00
|
|
|
|
|
fix(vision): narrow the fan-out cap to the CPU encode burst only
The original cap held a process-global slot across the WHOLE vision
analysis (image load + encode + LLM call) with a default of min(CPUs, 4).
That serialized legitimate multi-image workflows — "compare these 6
screenshots", "read this 10-page scan", "analyze every frame" — behind a
4-wide gate, and on the native fast path it even throttled calls that make
no LLM request at all. Excess calls queued (blocking acquire, nothing
dropped), but the latency hit on real fan-out was the wrong tradeoff.
The incident was CPU exhaustion, not call count: concurrent base64/resize
bursts saturated every core and left none to service the shared event loop
serving /api/status. So cap ONLY that:
- A dedicated, bounded ThreadPoolExecutor (_vision_cpu_executor) runs the
encode/resize/dimension-check off the caller's loop, sized to the host's
usable core count with NO fixed ceiling — the cap tracks the actual
exhausted resource (cores), not a magic number. Excess encodes queue on
the executor; cores stay free for the loop.
- The LLM call is deliberately OUTSIDE the executor, so multi-image
workflows keep full request concurrency.
- Override via auxiliary.vision.max_concurrency / HERMES_VISION_MAX_CONCURRENCY
(honored verbatim, including above core count); sub-1 ignored.
- _vision_concurrency_slot() is now a no-op shim for back-compat.
Tests assert: resolver defaults to host cores with no ceiling; env/config
override (incl. above cores); sub-1 rejection; the executor is dedicated and
core-sized; encode runs on a vision-encode thread; and crucially that encode
bursts are bounded to the cap while the analyses themselves stay fully
concurrent (calls_peak > cap).
2026-06-28 22:48:37 -07:00
|
|
|
|
Defaults to the host's usable core count (``_detect_host_cpus``) — no fixed
|
|
|
|
|
|
ceiling, because the cap tracks the actual exhausted resource (CPU cores),
|
|
|
|
|
|
not a magic number. The LLM call is NOT covered by this limit, so legitimate
|
|
|
|
|
|
multi-image fan-out keeps full request concurrency; only the simultaneous
|
|
|
|
|
|
CPU bursts are bounded so the event loop always keeps a core.
|
fix(vision): cap vision_analyze fan-out concurrency process-wide
A single agent turn can fan out N vision_analyze calls at once — the
classic trigger is "analyze every frame of this video", where ffmpeg
explodes a clip into dozens of frames and the model calls vision_analyze
on each. Every call does a CPU-heavy base64-encode/resize burst AND holds
a long-lived LLM stream open. The tool executor runs concurrent tool calls
on a per-session ThreadPoolExecutor (_MAX_TOOL_WORKERS=8), and multiple
agent sessions share one process (the dashboard runs the agent in-process),
so there was no global ceiling. In prod (June 2026) a video-frame fan-out
pinned a worker thread at ~100% CPU and starved the shared asyncio event
loop that also serves the dashboard's /api/status liveness probe, flapping
the instance to UNHEALTHY even though nothing had crashed.
Add a process-global threading.BoundedSemaphore that bounds how many vision
analyses run concurrently across the whole process, held across the entire
analysis (image load + encode + LLM call) in the single _handle_vision_analyze
chokepoint (covers both the native fast path and the legacy aux-LLM path).
It is a threading semaphore, NOT asyncio: each vision call is dispatched
through model_tools._run_async on a per-thread event loop, so an asyncio
primitive bound to one loop cannot coordinate across them. The acquire is
offloaded via run_in_executor so waiting for a slot never blocks the calling
loop.
Default: min(host CPUs, 4), floored at 1 — respect the host's concurrency,
or lower. Override via auxiliary.vision.max_concurrency (config.yaml) or
HERMES_VISION_MAX_CONCURRENCY (env). Values < 1 are ignored so the cap can
never be disabled into an unbounded fan-out.
Tests: bounded-fan-out regression guard + a control proving it would fail
without the cap; resolver tests for host-cpu default, ceiling clamp, low-cpu
host, env override, and sub-1 rejection. Pre-existing handler tests updated
for the now-async _handle_vision_analyze. Verified via the real
registry.dispatch -> _run_async per-thread-loop path (16 concurrent calls,
peak bounded to cap).
2026-06-29 15:18:01 +10:00
|
|
|
|
|
fix(vision): narrow the fan-out cap to the CPU encode burst only
The original cap held a process-global slot across the WHOLE vision
analysis (image load + encode + LLM call) with a default of min(CPUs, 4).
That serialized legitimate multi-image workflows — "compare these 6
screenshots", "read this 10-page scan", "analyze every frame" — behind a
4-wide gate, and on the native fast path it even throttled calls that make
no LLM request at all. Excess calls queued (blocking acquire, nothing
dropped), but the latency hit on real fan-out was the wrong tradeoff.
The incident was CPU exhaustion, not call count: concurrent base64/resize
bursts saturated every core and left none to service the shared event loop
serving /api/status. So cap ONLY that:
- A dedicated, bounded ThreadPoolExecutor (_vision_cpu_executor) runs the
encode/resize/dimension-check off the caller's loop, sized to the host's
usable core count with NO fixed ceiling — the cap tracks the actual
exhausted resource (cores), not a magic number. Excess encodes queue on
the executor; cores stay free for the loop.
- The LLM call is deliberately OUTSIDE the executor, so multi-image
workflows keep full request concurrency.
- Override via auxiliary.vision.max_concurrency / HERMES_VISION_MAX_CONCURRENCY
(honored verbatim, including above core count); sub-1 ignored.
- _vision_concurrency_slot() is now a no-op shim for back-compat.
Tests assert: resolver defaults to host cores with no ceiling; env/config
override (incl. above cores); sub-1 rejection; the executor is dedicated and
core-sized; encode runs on a vision-encode thread; and crucially that encode
bursts are bounded to the cap while the analyses themselves stay fully
concurrent (calls_peak > cap).
2026-06-28 22:48:37 -07:00
|
|
|
|
Resolution order: HERMES_VISION_MAX_CONCURRENCY env →
|
|
|
|
|
|
config.yaml auxiliary.vision.max_concurrency → host core count. Any value
|
|
|
|
|
|
that parses to < 1 is ignored in favor of the next source so the cap can
|
|
|
|
|
|
never be disabled into an unbounded encode storm.
|
fix(vision): cap vision_analyze fan-out concurrency process-wide
A single agent turn can fan out N vision_analyze calls at once — the
classic trigger is "analyze every frame of this video", where ffmpeg
explodes a clip into dozens of frames and the model calls vision_analyze
on each. Every call does a CPU-heavy base64-encode/resize burst AND holds
a long-lived LLM stream open. The tool executor runs concurrent tool calls
on a per-session ThreadPoolExecutor (_MAX_TOOL_WORKERS=8), and multiple
agent sessions share one process (the dashboard runs the agent in-process),
so there was no global ceiling. In prod (June 2026) a video-frame fan-out
pinned a worker thread at ~100% CPU and starved the shared asyncio event
loop that also serves the dashboard's /api/status liveness probe, flapping
the instance to UNHEALTHY even though nothing had crashed.
Add a process-global threading.BoundedSemaphore that bounds how many vision
analyses run concurrently across the whole process, held across the entire
analysis (image load + encode + LLM call) in the single _handle_vision_analyze
chokepoint (covers both the native fast path and the legacy aux-LLM path).
It is a threading semaphore, NOT asyncio: each vision call is dispatched
through model_tools._run_async on a per-thread event loop, so an asyncio
primitive bound to one loop cannot coordinate across them. The acquire is
offloaded via run_in_executor so waiting for a slot never blocks the calling
loop.
Default: min(host CPUs, 4), floored at 1 — respect the host's concurrency,
or lower. Override via auxiliary.vision.max_concurrency (config.yaml) or
HERMES_VISION_MAX_CONCURRENCY (env). Values < 1 are ignored so the cap can
never be disabled into an unbounded fan-out.
Tests: bounded-fan-out regression guard + a control proving it would fail
without the cap; resolver tests for host-cpu default, ceiling clamp, low-cpu
host, env override, and sub-1 rejection. Pre-existing handler tests updated
for the now-async _handle_vision_analyze. Verified via the real
registry.dispatch -> _run_async per-thread-loop path (16 concurrent calls,
peak bounded to cap).
2026-06-29 15:18:01 +10:00
|
|
|
|
"""
|
|
|
|
|
|
env_val = os.getenv("HERMES_VISION_MAX_CONCURRENCY", "").strip()
|
|
|
|
|
|
if env_val:
|
|
|
|
|
|
try:
|
|
|
|
|
|
parsed = int(env_val)
|
|
|
|
|
|
if parsed >= 1:
|
|
|
|
|
|
return parsed
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
try:
|
|
|
|
|
|
from hermes_cli.config import cfg_get, load_config
|
|
|
|
|
|
cfg = load_config()
|
|
|
|
|
|
val = cfg_get(cfg, "auxiliary", "vision", "max_concurrency")
|
|
|
|
|
|
if val is not None:
|
|
|
|
|
|
parsed = int(val)
|
|
|
|
|
|
if parsed >= 1:
|
|
|
|
|
|
return parsed
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
fix(vision): narrow the fan-out cap to the CPU encode burst only
The original cap held a process-global slot across the WHOLE vision
analysis (image load + encode + LLM call) with a default of min(CPUs, 4).
That serialized legitimate multi-image workflows — "compare these 6
screenshots", "read this 10-page scan", "analyze every frame" — behind a
4-wide gate, and on the native fast path it even throttled calls that make
no LLM request at all. Excess calls queued (blocking acquire, nothing
dropped), but the latency hit on real fan-out was the wrong tradeoff.
The incident was CPU exhaustion, not call count: concurrent base64/resize
bursts saturated every core and left none to service the shared event loop
serving /api/status. So cap ONLY that:
- A dedicated, bounded ThreadPoolExecutor (_vision_cpu_executor) runs the
encode/resize/dimension-check off the caller's loop, sized to the host's
usable core count with NO fixed ceiling — the cap tracks the actual
exhausted resource (cores), not a magic number. Excess encodes queue on
the executor; cores stay free for the loop.
- The LLM call is deliberately OUTSIDE the executor, so multi-image
workflows keep full request concurrency.
- Override via auxiliary.vision.max_concurrency / HERMES_VISION_MAX_CONCURRENCY
(honored verbatim, including above core count); sub-1 ignored.
- _vision_concurrency_slot() is now a no-op shim for back-compat.
Tests assert: resolver defaults to host cores with no ceiling; env/config
override (incl. above cores); sub-1 rejection; the executor is dedicated and
core-sized; encode runs on a vision-encode thread; and crucially that encode
bursts are bounded to the cap while the analyses themselves stay fully
concurrent (calls_peak > cap).
2026-06-28 22:48:37 -07:00
|
|
|
|
return _detect_host_cpus()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_VISION_CPU_WORKERS = _resolve_vision_cpu_workers()
|
|
|
|
|
|
|
|
|
|
|
|
# Dedicated, bounded executor for the CPU-bound encode/resize burst ONLY. We do
|
|
|
|
|
|
# NOT use the default executor (run_in_executor(None, ...)) — that pool is shared
|
|
|
|
|
|
# with the gateway and web server, so a fan-out would park encode work there and
|
|
|
|
|
|
# starve those callers. Sizing it to the usable core count means at most
|
|
|
|
|
|
# _VISION_CPU_WORKERS encodes run at once; further encodes queue on this
|
|
|
|
|
|
# executor's work queue, leaving cores free for the event loop. The LLM call is
|
|
|
|
|
|
# deliberately left OUTSIDE this executor so multi-image workflows keep full
|
|
|
|
|
|
# request concurrency.
|
|
|
|
|
|
_vision_cpu_executor = ThreadPoolExecutor(
|
|
|
|
|
|
max_workers=_VISION_CPU_WORKERS,
|
|
|
|
|
|
thread_name_prefix="vision-encode",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
fix(vision): cap vision_analyze fan-out concurrency process-wide
A single agent turn can fan out N vision_analyze calls at once — the
classic trigger is "analyze every frame of this video", where ffmpeg
explodes a clip into dozens of frames and the model calls vision_analyze
on each. Every call does a CPU-heavy base64-encode/resize burst AND holds
a long-lived LLM stream open. The tool executor runs concurrent tool calls
on a per-session ThreadPoolExecutor (_MAX_TOOL_WORKERS=8), and multiple
agent sessions share one process (the dashboard runs the agent in-process),
so there was no global ceiling. In prod (June 2026) a video-frame fan-out
pinned a worker thread at ~100% CPU and starved the shared asyncio event
loop that also serves the dashboard's /api/status liveness probe, flapping
the instance to UNHEALTHY even though nothing had crashed.
Add a process-global threading.BoundedSemaphore that bounds how many vision
analyses run concurrently across the whole process, held across the entire
analysis (image load + encode + LLM call) in the single _handle_vision_analyze
chokepoint (covers both the native fast path and the legacy aux-LLM path).
It is a threading semaphore, NOT asyncio: each vision call is dispatched
through model_tools._run_async on a per-thread event loop, so an asyncio
primitive bound to one loop cannot coordinate across them. The acquire is
offloaded via run_in_executor so waiting for a slot never blocks the calling
loop.
Default: min(host CPUs, 4), floored at 1 — respect the host's concurrency,
or lower. Override via auxiliary.vision.max_concurrency (config.yaml) or
HERMES_VISION_MAX_CONCURRENCY (env). Values < 1 are ignored so the cap can
never be disabled into an unbounded fan-out.
Tests: bounded-fan-out regression guard + a control proving it would fail
without the cap; resolver tests for host-cpu default, ceiling clamp, low-cpu
host, env override, and sub-1 rejection. Pre-existing handler tests updated
for the now-async _handle_vision_analyze. Verified via the real
registry.dispatch -> _run_async per-thread-loop path (16 concurrent calls,
peak bounded to cap).
2026-06-29 15:18:01 +10:00
|
|
|
|
|
fix(vision): narrow the fan-out cap to the CPU encode burst only
The original cap held a process-global slot across the WHOLE vision
analysis (image load + encode + LLM call) with a default of min(CPUs, 4).
That serialized legitimate multi-image workflows — "compare these 6
screenshots", "read this 10-page scan", "analyze every frame" — behind a
4-wide gate, and on the native fast path it even throttled calls that make
no LLM request at all. Excess calls queued (blocking acquire, nothing
dropped), but the latency hit on real fan-out was the wrong tradeoff.
The incident was CPU exhaustion, not call count: concurrent base64/resize
bursts saturated every core and left none to service the shared event loop
serving /api/status. So cap ONLY that:
- A dedicated, bounded ThreadPoolExecutor (_vision_cpu_executor) runs the
encode/resize/dimension-check off the caller's loop, sized to the host's
usable core count with NO fixed ceiling — the cap tracks the actual
exhausted resource (cores), not a magic number. Excess encodes queue on
the executor; cores stay free for the loop.
- The LLM call is deliberately OUTSIDE the executor, so multi-image
workflows keep full request concurrency.
- Override via auxiliary.vision.max_concurrency / HERMES_VISION_MAX_CONCURRENCY
(honored verbatim, including above core count); sub-1 ignored.
- _vision_concurrency_slot() is now a no-op shim for back-compat.
Tests assert: resolver defaults to host cores with no ceiling; env/config
override (incl. above cores); sub-1 rejection; the executor is dedicated and
core-sized; encode runs on a vision-encode thread; and crucially that encode
bursts are bounded to the cap while the analyses themselves stay fully
concurrent (calls_peak > cap).
2026-06-28 22:48:37 -07:00
|
|
|
|
async def _run_encode_on_cpu_executor(fn, *args, **kwargs):
|
|
|
|
|
|
"""Run a sync encode/resize callable on the bounded vision CPU executor.
|
fix(vision): cap vision_analyze fan-out concurrency process-wide
A single agent turn can fan out N vision_analyze calls at once — the
classic trigger is "analyze every frame of this video", where ffmpeg
explodes a clip into dozens of frames and the model calls vision_analyze
on each. Every call does a CPU-heavy base64-encode/resize burst AND holds
a long-lived LLM stream open. The tool executor runs concurrent tool calls
on a per-session ThreadPoolExecutor (_MAX_TOOL_WORKERS=8), and multiple
agent sessions share one process (the dashboard runs the agent in-process),
so there was no global ceiling. In prod (June 2026) a video-frame fan-out
pinned a worker thread at ~100% CPU and starved the shared asyncio event
loop that also serves the dashboard's /api/status liveness probe, flapping
the instance to UNHEALTHY even though nothing had crashed.
Add a process-global threading.BoundedSemaphore that bounds how many vision
analyses run concurrently across the whole process, held across the entire
analysis (image load + encode + LLM call) in the single _handle_vision_analyze
chokepoint (covers both the native fast path and the legacy aux-LLM path).
It is a threading semaphore, NOT asyncio: each vision call is dispatched
through model_tools._run_async on a per-thread event loop, so an asyncio
primitive bound to one loop cannot coordinate across them. The acquire is
offloaded via run_in_executor so waiting for a slot never blocks the calling
loop.
Default: min(host CPUs, 4), floored at 1 — respect the host's concurrency,
or lower. Override via auxiliary.vision.max_concurrency (config.yaml) or
HERMES_VISION_MAX_CONCURRENCY (env). Values < 1 are ignored so the cap can
never be disabled into an unbounded fan-out.
Tests: bounded-fan-out regression guard + a control proving it would fail
without the cap; resolver tests for host-cpu default, ceiling clamp, low-cpu
host, env override, and sub-1 rejection. Pre-existing handler tests updated
for the now-async _handle_vision_analyze. Verified via the real
registry.dispatch -> _run_async per-thread-loop path (16 concurrent calls,
peak bounded to cap).
2026-06-29 15:18:01 +10:00
|
|
|
|
|
fix(vision): narrow the fan-out cap to the CPU encode burst only
The original cap held a process-global slot across the WHOLE vision
analysis (image load + encode + LLM call) with a default of min(CPUs, 4).
That serialized legitimate multi-image workflows — "compare these 6
screenshots", "read this 10-page scan", "analyze every frame" — behind a
4-wide gate, and on the native fast path it even throttled calls that make
no LLM request at all. Excess calls queued (blocking acquire, nothing
dropped), but the latency hit on real fan-out was the wrong tradeoff.
The incident was CPU exhaustion, not call count: concurrent base64/resize
bursts saturated every core and left none to service the shared event loop
serving /api/status. So cap ONLY that:
- A dedicated, bounded ThreadPoolExecutor (_vision_cpu_executor) runs the
encode/resize/dimension-check off the caller's loop, sized to the host's
usable core count with NO fixed ceiling — the cap tracks the actual
exhausted resource (cores), not a magic number. Excess encodes queue on
the executor; cores stay free for the loop.
- The LLM call is deliberately OUTSIDE the executor, so multi-image
workflows keep full request concurrency.
- Override via auxiliary.vision.max_concurrency / HERMES_VISION_MAX_CONCURRENCY
(honored verbatim, including above core count); sub-1 ignored.
- _vision_concurrency_slot() is now a no-op shim for back-compat.
Tests assert: resolver defaults to host cores with no ceiling; env/config
override (incl. above cores); sub-1 rejection; the executor is dedicated and
core-sized; encode runs on a vision-encode thread; and crucially that encode
bursts are bounded to the cap while the analyses themselves stay fully
concurrent (calls_peak > cap).
2026-06-28 22:48:37 -07:00
|
|
|
|
Offloads CPU-bound image work to :data:`_vision_cpu_executor` so it (a)
|
|
|
|
|
|
never runs on the caller's event-loop thread and (b) is bounded to the
|
|
|
|
|
|
host's usable core count process-wide. Excess encodes queue on the
|
|
|
|
|
|
executor instead of all running at once, leaving cores free for the loop.
|
|
|
|
|
|
The LLM call must NOT be routed through here — only the encode/resize.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import functools
|
|
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
|
return await loop.run_in_executor(
|
|
|
|
|
|
_vision_cpu_executor, functools.partial(fn, *args, **kwargs)
|
|
|
|
|
|
)
|
fix(vision): cap vision_analyze fan-out concurrency process-wide
A single agent turn can fan out N vision_analyze calls at once — the
classic trigger is "analyze every frame of this video", where ffmpeg
explodes a clip into dozens of frames and the model calls vision_analyze
on each. Every call does a CPU-heavy base64-encode/resize burst AND holds
a long-lived LLM stream open. The tool executor runs concurrent tool calls
on a per-session ThreadPoolExecutor (_MAX_TOOL_WORKERS=8), and multiple
agent sessions share one process (the dashboard runs the agent in-process),
so there was no global ceiling. In prod (June 2026) a video-frame fan-out
pinned a worker thread at ~100% CPU and starved the shared asyncio event
loop that also serves the dashboard's /api/status liveness probe, flapping
the instance to UNHEALTHY even though nothing had crashed.
Add a process-global threading.BoundedSemaphore that bounds how many vision
analyses run concurrently across the whole process, held across the entire
analysis (image load + encode + LLM call) in the single _handle_vision_analyze
chokepoint (covers both the native fast path and the legacy aux-LLM path).
It is a threading semaphore, NOT asyncio: each vision call is dispatched
through model_tools._run_async on a per-thread event loop, so an asyncio
primitive bound to one loop cannot coordinate across them. The acquire is
offloaded via run_in_executor so waiting for a slot never blocks the calling
loop.
Default: min(host CPUs, 4), floored at 1 — respect the host's concurrency,
or lower. Override via auxiliary.vision.max_concurrency (config.yaml) or
HERMES_VISION_MAX_CONCURRENCY (env). Values < 1 are ignored so the cap can
never be disabled into an unbounded fan-out.
Tests: bounded-fan-out regression guard + a control proving it would fail
without the cap; resolver tests for host-cpu default, ceiling clamp, low-cpu
host, env override, and sub-1 rejection. Pre-existing handler tests updated
for the now-async _handle_vision_analyze. Verified via the real
registry.dispatch -> _run_async per-thread-loop path (16 concurrent calls,
peak bounded to cap).
2026-06-29 15:18:01 +10:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 05:57:11 -07:00
|
|
|
|
def _image_url_shape_ok(url: str) -> bool:
|
|
|
|
|
|
"""HTTP(S) shape check only (scheme, netloc). No DNS."""
|
2025-10-01 23:29:25 +00:00
|
|
|
|
if not url or not isinstance(url, str):
|
|
|
|
|
|
return False
|
2026-03-05 16:11:59 +03:00
|
|
|
|
# Basic HTTP/HTTPS URL check
|
refactor: codebase-wide lint cleanup — unused imports, dead code, and inefficient patterns (#5821)
Comprehensive cleanup across 80 files based on automated (ruff, pyflakes, vulture)
and manual analysis of the entire codebase.
Changes by category:
Unused imports removed (~95 across 55 files):
- Removed genuinely unused imports from all major subsystems
- agent/, hermes_cli/, tools/, gateway/, plugins/, cron/
- Includes imports in try/except blocks that were truly unused
(vs availability checks which were left alone)
Unused variables removed (~25):
- Removed dead variables: connected, inner, channels, last_exc,
source, new_server_names, verify, pconfig, default_terminal,
result, pending_handled, temperature, loop
- Dropped unused argparse subparser assignments in hermes_cli/main.py
(12 instances of add_parser() where result was never used)
Dead code removed:
- run_agent.py: Removed dead ternary (None if False else None) and
surrounding unreachable branch in identity fallback
- run_agent.py: Removed write-only attribute _last_reported_tool
- hermes_cli/providers.py: Removed dead @property decorator on
module-level function (decorator has no effect outside a class)
- gateway/run.py: Removed unused MCP config load before reconnect
- gateway/platforms/slack.py: Removed dead SessionSource construction
Undefined name bugs fixed (would cause NameError at runtime):
- batch_runner.py: Added missing logger = logging.getLogger(__name__)
- tools/environments/daytona.py: Added missing Dict and Path imports
Unnecessary global statements removed (14):
- tools/terminal_tool.py: 5 functions declared global for dicts
they only mutated via .pop()/[key]=value (no rebinding)
- tools/browser_tool.py: cleanup thread loop only reads flag
- tools/rl_training_tool.py: 4 functions only do dict mutations
- tools/mcp_oauth.py: only reads the global
- hermes_time.py: only reads cached values
Inefficient patterns fixed:
- startswith/endswith tuple form: 15 instances of
x.startswith('a') or x.startswith('b') consolidated to
x.startswith(('a', 'b'))
- len(x)==0 / len(x)>0: 13 instances replaced with pythonic
truthiness checks (not x / bool(x))
- in dict.keys(): 5 instances simplified to in dict
- Redefined unused name: removed duplicate _strip_mdv2 import in
send_message_tool.py
Other fixes:
- hermes_cli/doctor.py: Replaced undefined logger.debug() with pass
- hermes_cli/config.py: Consolidated chained .endswith() calls
Test results: 3934 passed, 17 failed (all pre-existing on main),
19 skipped. Zero regressions.
2026-04-07 10:25:31 -07:00
|
|
|
|
if not url.startswith(("http://", "https://")):
|
2025-10-01 23:29:25 +00:00
|
|
|
|
return False
|
2026-03-05 16:11:59 +03:00
|
|
|
|
# Parse to ensure we at least have a network location; still allow URLs
|
|
|
|
|
|
# without file extensions (e.g. CDN endpoints that redirect to images).
|
|
|
|
|
|
parsed = urlparse(url)
|
|
|
|
|
|
if not parsed.netloc:
|
|
|
|
|
|
return False
|
2026-06-04 05:57:11 -07:00
|
|
|
|
return True
|
|
|
|
|
|
|
2026-03-05 16:11:59 +03:00
|
|
|
|
|
2026-06-04 05:57:11 -07:00
|
|
|
|
def _validate_image_url(url: str) -> bool:
|
|
|
|
|
|
"""Validate image URL for sync callers and tests (SSRF via sync DNS check)."""
|
|
|
|
|
|
if not _image_url_shape_ok(url):
|
|
|
|
|
|
return False
|
fix(security): add SSRF protection to vision_tools and web_tools (hardened)
* fix(security): add SSRF protection to vision_tools and web_tools
Both vision_analyze and web_extract/web_crawl accept arbitrary URLs
without checking if they target private/internal network addresses.
A prompt-injected or malicious skill could use this to access cloud
metadata endpoints (169.254.169.254), localhost services, or private
network hosts.
Adds a shared url_safety.is_safe_url() that resolves hostnames and
blocks private, loopback, link-local, and reserved IP ranges. Also
blocks known internal hostnames (metadata.google.internal).
Integrated at the URL validation layer in vision_tools and before
each website_policy check in web_tools (extract, crawl).
* test(vision): update localhost test to reflect SSRF protection
The existing test_valid_url_with_port asserted localhost URLs pass
validation. With SSRF protection, localhost is now correctly blocked.
Update the test to verify the block, and add a separate test for
valid URLs with ports using a public hostname.
* fix(security): harden SSRF protection — fail-closed, CGNAT, multicast, redirect guard
Follow-up hardening on top of dieutx's SSRF protection (PR #2630):
- Change fail-open to fail-closed: DNS errors and unexpected exceptions
now block the request instead of allowing it (OWASP best practice)
- Block CGNAT range (100.64.0.0/10): Python's ipaddress.is_private
does NOT cover this range (returns False for both is_private and
is_global). Used by Tailscale/WireGuard and carrier infrastructure.
- Add is_multicast and is_unspecified checks: multicast (224.0.0.0/4)
and unspecified (0.0.0.0) addresses were not caught by the original
four-check chain
- Add redirect guard for vision_tools: httpx event hook re-validates
each redirect target against SSRF checks, preventing the classic
redirect-based SSRF bypass (302 to internal IP)
- Move SSRF filtering before backend dispatch in web_extract: now
covers Parallel and Tavily backends, not just Firecrawl
- Extract _is_blocked_ip() helper for cleaner IP range checking
- Add 24 new tests (CGNAT, multicast, IPv4-mapped IPv6, fail-closed
behavior, parametrized blocked/allowed IP lists)
- Fix existing tests to mock DNS resolution for test hostnames
---------
Co-authored-by: dieutx <dangtc94@gmail.com>
2026-03-23 15:40:42 -07:00
|
|
|
|
# Block private/internal addresses to prevent SSRF
|
|
|
|
|
|
from tools.url_safety import is_safe_url
|
2026-06-04 05:57:11 -07:00
|
|
|
|
return is_safe_url(url)
|
fix(security): add SSRF protection to vision_tools and web_tools (hardened)
* fix(security): add SSRF protection to vision_tools and web_tools
Both vision_analyze and web_extract/web_crawl accept arbitrary URLs
without checking if they target private/internal network addresses.
A prompt-injected or malicious skill could use this to access cloud
metadata endpoints (169.254.169.254), localhost services, or private
network hosts.
Adds a shared url_safety.is_safe_url() that resolves hostnames and
blocks private, loopback, link-local, and reserved IP ranges. Also
blocks known internal hostnames (metadata.google.internal).
Integrated at the URL validation layer in vision_tools and before
each website_policy check in web_tools (extract, crawl).
* test(vision): update localhost test to reflect SSRF protection
The existing test_valid_url_with_port asserted localhost URLs pass
validation. With SSRF protection, localhost is now correctly blocked.
Update the test to verify the block, and add a separate test for
valid URLs with ports using a public hostname.
* fix(security): harden SSRF protection — fail-closed, CGNAT, multicast, redirect guard
Follow-up hardening on top of dieutx's SSRF protection (PR #2630):
- Change fail-open to fail-closed: DNS errors and unexpected exceptions
now block the request instead of allowing it (OWASP best practice)
- Block CGNAT range (100.64.0.0/10): Python's ipaddress.is_private
does NOT cover this range (returns False for both is_private and
is_global). Used by Tailscale/WireGuard and carrier infrastructure.
- Add is_multicast and is_unspecified checks: multicast (224.0.0.0/4)
and unspecified (0.0.0.0) addresses were not caught by the original
four-check chain
- Add redirect guard for vision_tools: httpx event hook re-validates
each redirect target against SSRF checks, preventing the classic
redirect-based SSRF bypass (302 to internal IP)
- Move SSRF filtering before backend dispatch in web_extract: now
covers Parallel and Tavily backends, not just Firecrawl
- Extract _is_blocked_ip() helper for cleaner IP range checking
- Add 24 new tests (CGNAT, multicast, IPv4-mapped IPv6, fail-closed
behavior, parametrized blocked/allowed IP lists)
- Fix existing tests to mock DNS resolution for test hostnames
---------
Co-authored-by: dieutx <dangtc94@gmail.com>
2026-03-23 15:40:42 -07:00
|
|
|
|
|
2026-06-04 05:57:11 -07:00
|
|
|
|
|
|
|
|
|
|
async def _validate_image_url_async(url: str) -> bool:
|
|
|
|
|
|
"""Validate remote image URL without blocking the event loop on DNS."""
|
|
|
|
|
|
if not _image_url_shape_ok(url):
|
|
|
|
|
|
return False
|
|
|
|
|
|
from tools.url_safety import async_is_safe_url
|
|
|
|
|
|
return await async_is_safe_url(url)
|
2025-10-01 23:29:25 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-29 20:55:04 -07:00
|
|
|
|
def _detect_image_mime_type(image_path: Path) -> Optional[str]:
|
|
|
|
|
|
"""Return a MIME type when the file looks like a supported image."""
|
|
|
|
|
|
with image_path.open("rb") as f:
|
|
|
|
|
|
header = f.read(64)
|
|
|
|
|
|
|
|
|
|
|
|
if header.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
|
|
|
|
return "image/png"
|
|
|
|
|
|
if header.startswith(b"\xff\xd8\xff"):
|
|
|
|
|
|
return "image/jpeg"
|
|
|
|
|
|
if header.startswith((b"GIF87a", b"GIF89a")):
|
|
|
|
|
|
return "image/gif"
|
|
|
|
|
|
if header.startswith(b"BM"):
|
|
|
|
|
|
return "image/bmp"
|
|
|
|
|
|
if len(header) >= 12 and header[:4] == b"RIFF" and header[8:12] == b"WEBP":
|
|
|
|
|
|
return "image/webp"
|
|
|
|
|
|
if image_path.suffix.lower() == ".svg":
|
|
|
|
|
|
head = image_path.read_text(encoding="utf-8", errors="ignore")[:4096].lower()
|
|
|
|
|
|
if "<svg" in head:
|
|
|
|
|
|
return "image/svg+xml"
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-30 01:40:39 -07:00
|
|
|
|
def _is_retryable_download_error(error: Exception) -> bool:
|
|
|
|
|
|
"""Return True only for transient image-download failures worth retrying.
|
|
|
|
|
|
|
|
|
|
|
|
Non-retryable (fail-fast):
|
|
|
|
|
|
- httpx.HTTPStatusError with a 4xx status other than 429 (404/403/410/...):
|
|
|
|
|
|
the resource is missing or forbidden; retrying can't change that.
|
|
|
|
|
|
- PermissionError: blocked by website policy / SSRF guard.
|
|
|
|
|
|
- ValueError: image too large or blocked redirect — deterministic.
|
|
|
|
|
|
|
|
|
|
|
|
Retryable (transient):
|
|
|
|
|
|
- httpx 429 (rate limited) and 5xx (server-side) errors.
|
|
|
|
|
|
- Connection/timeout/transport errors (httpx.TransportError) and any
|
|
|
|
|
|
other unclassified exception, which may be a flaky network blip.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if isinstance(error, (PermissionError, ValueError)):
|
|
|
|
|
|
return False
|
|
|
|
|
|
if isinstance(error, httpx.HTTPStatusError):
|
|
|
|
|
|
status = error.response.status_code
|
|
|
|
|
|
if 400 <= status < 500 and status != 429:
|
|
|
|
|
|
return False
|
|
|
|
|
|
return True
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-18 10:11:59 +00:00
|
|
|
|
async def _download_image(image_url: str, destination: Path, max_retries: int = 3) -> Path:
|
2025-10-08 02:38:04 +00:00
|
|
|
|
"""
|
2026-01-18 10:11:59 +00:00
|
|
|
|
Download an image from a URL to a local destination (async) with retry logic.
|
2025-10-08 02:38:04 +00:00
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
image_url (str): The URL of the image to download
|
|
|
|
|
|
destination (Path): The path where the image should be saved
|
2026-01-18 10:11:59 +00:00
|
|
|
|
max_retries (int): Maximum number of retry attempts (default: 3)
|
2025-10-08 02:38:04 +00:00
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Path: The path to the downloaded image
|
|
|
|
|
|
|
|
|
|
|
|
Raises:
|
2026-01-18 10:11:59 +00:00
|
|
|
|
Exception: If download fails after all retries
|
2025-10-08 02:38:04 +00:00
|
|
|
|
"""
|
2026-01-18 10:11:59 +00:00
|
|
|
|
import asyncio
|
|
|
|
|
|
|
2025-10-08 02:38:04 +00:00
|
|
|
|
# Create parent directories if they don't exist
|
|
|
|
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
2026-03-23 15:44:52 -07:00
|
|
|
|
async def _ssrf_redirect_guard(response):
|
fix(security): add SSRF protection to vision_tools and web_tools (hardened)
* fix(security): add SSRF protection to vision_tools and web_tools
Both vision_analyze and web_extract/web_crawl accept arbitrary URLs
without checking if they target private/internal network addresses.
A prompt-injected or malicious skill could use this to access cloud
metadata endpoints (169.254.169.254), localhost services, or private
network hosts.
Adds a shared url_safety.is_safe_url() that resolves hostnames and
blocks private, loopback, link-local, and reserved IP ranges. Also
blocks known internal hostnames (metadata.google.internal).
Integrated at the URL validation layer in vision_tools and before
each website_policy check in web_tools (extract, crawl).
* test(vision): update localhost test to reflect SSRF protection
The existing test_valid_url_with_port asserted localhost URLs pass
validation. With SSRF protection, localhost is now correctly blocked.
Update the test to verify the block, and add a separate test for
valid URLs with ports using a public hostname.
* fix(security): harden SSRF protection — fail-closed, CGNAT, multicast, redirect guard
Follow-up hardening on top of dieutx's SSRF protection (PR #2630):
- Change fail-open to fail-closed: DNS errors and unexpected exceptions
now block the request instead of allowing it (OWASP best practice)
- Block CGNAT range (100.64.0.0/10): Python's ipaddress.is_private
does NOT cover this range (returns False for both is_private and
is_global). Used by Tailscale/WireGuard and carrier infrastructure.
- Add is_multicast and is_unspecified checks: multicast (224.0.0.0/4)
and unspecified (0.0.0.0) addresses were not caught by the original
four-check chain
- Add redirect guard for vision_tools: httpx event hook re-validates
each redirect target against SSRF checks, preventing the classic
redirect-based SSRF bypass (302 to internal IP)
- Move SSRF filtering before backend dispatch in web_extract: now
covers Parallel and Tavily backends, not just Firecrawl
- Extract _is_blocked_ip() helper for cleaner IP range checking
- Add 24 new tests (CGNAT, multicast, IPv4-mapped IPv6, fail-closed
behavior, parametrized blocked/allowed IP lists)
- Fix existing tests to mock DNS resolution for test hostnames
---------
Co-authored-by: dieutx <dangtc94@gmail.com>
2026-03-23 15:40:42 -07:00
|
|
|
|
"""Re-validate each redirect target to prevent redirect-based SSRF.
|
|
|
|
|
|
|
|
|
|
|
|
Without this, an attacker can host a public URL that 302-redirects
|
|
|
|
|
|
to http://169.254.169.254/ and bypass the pre-flight is_safe_url check.
|
2026-03-23 15:44:52 -07:00
|
|
|
|
|
|
|
|
|
|
Must be async because httpx.AsyncClient awaits event hooks.
|
fix(security): add SSRF protection to vision_tools and web_tools (hardened)
* fix(security): add SSRF protection to vision_tools and web_tools
Both vision_analyze and web_extract/web_crawl accept arbitrary URLs
without checking if they target private/internal network addresses.
A prompt-injected or malicious skill could use this to access cloud
metadata endpoints (169.254.169.254), localhost services, or private
network hosts.
Adds a shared url_safety.is_safe_url() that resolves hostnames and
blocks private, loopback, link-local, and reserved IP ranges. Also
blocks known internal hostnames (metadata.google.internal).
Integrated at the URL validation layer in vision_tools and before
each website_policy check in web_tools (extract, crawl).
* test(vision): update localhost test to reflect SSRF protection
The existing test_valid_url_with_port asserted localhost URLs pass
validation. With SSRF protection, localhost is now correctly blocked.
Update the test to verify the block, and add a separate test for
valid URLs with ports using a public hostname.
* fix(security): harden SSRF protection — fail-closed, CGNAT, multicast, redirect guard
Follow-up hardening on top of dieutx's SSRF protection (PR #2630):
- Change fail-open to fail-closed: DNS errors and unexpected exceptions
now block the request instead of allowing it (OWASP best practice)
- Block CGNAT range (100.64.0.0/10): Python's ipaddress.is_private
does NOT cover this range (returns False for both is_private and
is_global). Used by Tailscale/WireGuard and carrier infrastructure.
- Add is_multicast and is_unspecified checks: multicast (224.0.0.0/4)
and unspecified (0.0.0.0) addresses were not caught by the original
four-check chain
- Add redirect guard for vision_tools: httpx event hook re-validates
each redirect target against SSRF checks, preventing the classic
redirect-based SSRF bypass (302 to internal IP)
- Move SSRF filtering before backend dispatch in web_extract: now
covers Parallel and Tavily backends, not just Firecrawl
- Extract _is_blocked_ip() helper for cleaner IP range checking
- Add 24 new tests (CGNAT, multicast, IPv4-mapped IPv6, fail-closed
behavior, parametrized blocked/allowed IP lists)
- Fix existing tests to mock DNS resolution for test hostnames
---------
Co-authored-by: dieutx <dangtc94@gmail.com>
2026-03-23 15:40:42 -07:00
|
|
|
|
"""
|
|
|
|
|
|
if response.is_redirect and response.next_request:
|
|
|
|
|
|
redirect_url = str(response.next_request.url)
|
2026-06-04 05:57:11 -07:00
|
|
|
|
from tools.url_safety import async_is_safe_url
|
|
|
|
|
|
if not await async_is_safe_url(redirect_url):
|
fix(security): add SSRF protection to vision_tools and web_tools (hardened)
* fix(security): add SSRF protection to vision_tools and web_tools
Both vision_analyze and web_extract/web_crawl accept arbitrary URLs
without checking if they target private/internal network addresses.
A prompt-injected or malicious skill could use this to access cloud
metadata endpoints (169.254.169.254), localhost services, or private
network hosts.
Adds a shared url_safety.is_safe_url() that resolves hostnames and
blocks private, loopback, link-local, and reserved IP ranges. Also
blocks known internal hostnames (metadata.google.internal).
Integrated at the URL validation layer in vision_tools and before
each website_policy check in web_tools (extract, crawl).
* test(vision): update localhost test to reflect SSRF protection
The existing test_valid_url_with_port asserted localhost URLs pass
validation. With SSRF protection, localhost is now correctly blocked.
Update the test to verify the block, and add a separate test for
valid URLs with ports using a public hostname.
* fix(security): harden SSRF protection — fail-closed, CGNAT, multicast, redirect guard
Follow-up hardening on top of dieutx's SSRF protection (PR #2630):
- Change fail-open to fail-closed: DNS errors and unexpected exceptions
now block the request instead of allowing it (OWASP best practice)
- Block CGNAT range (100.64.0.0/10): Python's ipaddress.is_private
does NOT cover this range (returns False for both is_private and
is_global). Used by Tailscale/WireGuard and carrier infrastructure.
- Add is_multicast and is_unspecified checks: multicast (224.0.0.0/4)
and unspecified (0.0.0.0) addresses were not caught by the original
four-check chain
- Add redirect guard for vision_tools: httpx event hook re-validates
each redirect target against SSRF checks, preventing the classic
redirect-based SSRF bypass (302 to internal IP)
- Move SSRF filtering before backend dispatch in web_extract: now
covers Parallel and Tavily backends, not just Firecrawl
- Extract _is_blocked_ip() helper for cleaner IP range checking
- Add 24 new tests (CGNAT, multicast, IPv4-mapped IPv6, fail-closed
behavior, parametrized blocked/allowed IP lists)
- Fix existing tests to mock DNS resolution for test hostnames
---------
Co-authored-by: dieutx <dangtc94@gmail.com>
2026-03-23 15:40:42 -07:00
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"Blocked redirect to private/internal address: {redirect_url}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-18 10:11:59 +00:00
|
|
|
|
last_error = None
|
|
|
|
|
|
for attempt in range(max_retries):
|
|
|
|
|
|
try:
|
2026-03-29 20:55:04 -07:00
|
|
|
|
blocked = check_website_access(image_url)
|
|
|
|
|
|
if blocked:
|
|
|
|
|
|
raise PermissionError(blocked["message"])
|
|
|
|
|
|
|
2026-01-18 10:11:59 +00:00
|
|
|
|
# Download the image with appropriate headers using async httpx
|
2026-01-29 06:10:24 +00:00
|
|
|
|
# Enable follow_redirects to handle image CDNs that redirect (e.g., Imgur, Picsum)
|
fix(security): add SSRF protection to vision_tools and web_tools (hardened)
* fix(security): add SSRF protection to vision_tools and web_tools
Both vision_analyze and web_extract/web_crawl accept arbitrary URLs
without checking if they target private/internal network addresses.
A prompt-injected or malicious skill could use this to access cloud
metadata endpoints (169.254.169.254), localhost services, or private
network hosts.
Adds a shared url_safety.is_safe_url() that resolves hostnames and
blocks private, loopback, link-local, and reserved IP ranges. Also
blocks known internal hostnames (metadata.google.internal).
Integrated at the URL validation layer in vision_tools and before
each website_policy check in web_tools (extract, crawl).
* test(vision): update localhost test to reflect SSRF protection
The existing test_valid_url_with_port asserted localhost URLs pass
validation. With SSRF protection, localhost is now correctly blocked.
Update the test to verify the block, and add a separate test for
valid URLs with ports using a public hostname.
* fix(security): harden SSRF protection — fail-closed, CGNAT, multicast, redirect guard
Follow-up hardening on top of dieutx's SSRF protection (PR #2630):
- Change fail-open to fail-closed: DNS errors and unexpected exceptions
now block the request instead of allowing it (OWASP best practice)
- Block CGNAT range (100.64.0.0/10): Python's ipaddress.is_private
does NOT cover this range (returns False for both is_private and
is_global). Used by Tailscale/WireGuard and carrier infrastructure.
- Add is_multicast and is_unspecified checks: multicast (224.0.0.0/4)
and unspecified (0.0.0.0) addresses were not caught by the original
four-check chain
- Add redirect guard for vision_tools: httpx event hook re-validates
each redirect target against SSRF checks, preventing the classic
redirect-based SSRF bypass (302 to internal IP)
- Move SSRF filtering before backend dispatch in web_extract: now
covers Parallel and Tavily backends, not just Firecrawl
- Extract _is_blocked_ip() helper for cleaner IP range checking
- Add 24 new tests (CGNAT, multicast, IPv4-mapped IPv6, fail-closed
behavior, parametrized blocked/allowed IP lists)
- Fix existing tests to mock DNS resolution for test hostnames
---------
Co-authored-by: dieutx <dangtc94@gmail.com>
2026-03-23 15:40:42 -07:00
|
|
|
|
# SSRF: event_hooks validates each redirect target against private IP ranges
|
|
|
|
|
|
async with httpx.AsyncClient(
|
2026-03-30 02:59:39 -07:00
|
|
|
|
timeout=_VISION_DOWNLOAD_TIMEOUT,
|
fix(security): add SSRF protection to vision_tools and web_tools (hardened)
* fix(security): add SSRF protection to vision_tools and web_tools
Both vision_analyze and web_extract/web_crawl accept arbitrary URLs
without checking if they target private/internal network addresses.
A prompt-injected or malicious skill could use this to access cloud
metadata endpoints (169.254.169.254), localhost services, or private
network hosts.
Adds a shared url_safety.is_safe_url() that resolves hostnames and
blocks private, loopback, link-local, and reserved IP ranges. Also
blocks known internal hostnames (metadata.google.internal).
Integrated at the URL validation layer in vision_tools and before
each website_policy check in web_tools (extract, crawl).
* test(vision): update localhost test to reflect SSRF protection
The existing test_valid_url_with_port asserted localhost URLs pass
validation. With SSRF protection, localhost is now correctly blocked.
Update the test to verify the block, and add a separate test for
valid URLs with ports using a public hostname.
* fix(security): harden SSRF protection — fail-closed, CGNAT, multicast, redirect guard
Follow-up hardening on top of dieutx's SSRF protection (PR #2630):
- Change fail-open to fail-closed: DNS errors and unexpected exceptions
now block the request instead of allowing it (OWASP best practice)
- Block CGNAT range (100.64.0.0/10): Python's ipaddress.is_private
does NOT cover this range (returns False for both is_private and
is_global). Used by Tailscale/WireGuard and carrier infrastructure.
- Add is_multicast and is_unspecified checks: multicast (224.0.0.0/4)
and unspecified (0.0.0.0) addresses were not caught by the original
four-check chain
- Add redirect guard for vision_tools: httpx event hook re-validates
each redirect target against SSRF checks, preventing the classic
redirect-based SSRF bypass (302 to internal IP)
- Move SSRF filtering before backend dispatch in web_extract: now
covers Parallel and Tavily backends, not just Firecrawl
- Extract _is_blocked_ip() helper for cleaner IP range checking
- Add 24 new tests (CGNAT, multicast, IPv4-mapped IPv6, fail-closed
behavior, parametrized blocked/allowed IP lists)
- Fix existing tests to mock DNS resolution for test hostnames
---------
Co-authored-by: dieutx <dangtc94@gmail.com>
2026-03-23 15:40:42 -07:00
|
|
|
|
follow_redirects=True,
|
|
|
|
|
|
event_hooks={"response": [_ssrf_redirect_guard]},
|
|
|
|
|
|
) as client:
|
2026-01-18 10:11:59 +00:00
|
|
|
|
response = await client.get(
|
|
|
|
|
|
image_url,
|
2026-01-29 06:10:24 +00:00
|
|
|
|
headers={
|
|
|
|
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
|
|
|
|
"Accept": "image/*,*/*;q=0.8",
|
|
|
|
|
|
},
|
2026-01-18 10:11:59 +00:00
|
|
|
|
)
|
|
|
|
|
|
response.raise_for_status()
|
2026-03-29 20:55:04 -07:00
|
|
|
|
|
2026-04-10 12:13:42 +08:00
|
|
|
|
# Reject overly large images early via Content-Length header.
|
|
|
|
|
|
cl = response.headers.get("content-length")
|
|
|
|
|
|
if cl and int(cl) > _VISION_MAX_DOWNLOAD_BYTES:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"Image too large ({int(cl)} bytes, max {_VISION_MAX_DOWNLOAD_BYTES})"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-29 20:55:04 -07:00
|
|
|
|
final_url = str(response.url)
|
|
|
|
|
|
blocked = check_website_access(final_url)
|
|
|
|
|
|
if blocked:
|
|
|
|
|
|
raise PermissionError(blocked["message"])
|
2026-01-18 10:11:59 +00:00
|
|
|
|
|
2026-04-10 12:13:42 +08:00
|
|
|
|
# Save the image content (double-check actual size)
|
|
|
|
|
|
body = response.content
|
|
|
|
|
|
if len(body) > _VISION_MAX_DOWNLOAD_BYTES:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"Image too large ({len(body)} bytes, max {_VISION_MAX_DOWNLOAD_BYTES})"
|
|
|
|
|
|
)
|
|
|
|
|
|
destination.write_bytes(body)
|
2026-01-18 10:11:59 +00:00
|
|
|
|
|
|
|
|
|
|
return destination
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
last_error = e
|
2026-05-30 01:40:39 -07:00
|
|
|
|
# Error-class-aware retry: only retry transient failures. A 4xx
|
|
|
|
|
|
# client error (404/403/410, etc.) will never succeed on retry —
|
|
|
|
|
|
# the resource isn't there or we're not allowed — so burning 3
|
|
|
|
|
|
# attempts with 2s/4s/8s backoff just inflates latency. 429 (rate
|
|
|
|
|
|
# limit) and 5xx remain retryable. PermissionError (policy block)
|
|
|
|
|
|
# and ValueError (too-large / SSRF redirect) are also terminal.
|
|
|
|
|
|
if not _is_retryable_download_error(e) or attempt >= max_retries - 1:
|
2026-03-05 16:11:59 +03:00
|
|
|
|
logger.error(
|
2026-05-30 01:40:39 -07:00
|
|
|
|
"Image download failed after %s attempt(s): %s",
|
|
|
|
|
|
attempt + 1,
|
2026-03-05 16:11:59 +03:00
|
|
|
|
str(e)[:100],
|
|
|
|
|
|
exc_info=True,
|
|
|
|
|
|
)
|
2026-05-30 01:40:39 -07:00
|
|
|
|
raise
|
|
|
|
|
|
wait_time = 2 ** (attempt + 1) # 2s, 4s, 8s
|
|
|
|
|
|
logger.warning("Image download failed (attempt %s/%s): %s", attempt + 1, max_retries, str(e)[:50])
|
|
|
|
|
|
logger.warning("Retrying in %ss...", wait_time)
|
|
|
|
|
|
await asyncio.sleep(wait_time)
|
|
|
|
|
|
|
|
|
|
|
|
# The loop always returns on success or re-raises on the final/non-retryable
|
|
|
|
|
|
# attempt, so reaching here means max_retries was non-positive.
|
|
|
|
|
|
if last_error is not None:
|
|
|
|
|
|
raise last_error
|
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
f"_download_image exited retry loop without attempting (max_retries={max_retries})"
|
|
|
|
|
|
)
|
2025-10-08 02:38:04 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _determine_mime_type(image_path: Path) -> str:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Determine the MIME type of an image based on its file extension.
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
image_path (Path): Path to the image file
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
str: The MIME type (defaults to image/jpeg if unknown)
|
|
|
|
|
|
"""
|
|
|
|
|
|
extension = image_path.suffix.lower()
|
|
|
|
|
|
mime_types = {
|
|
|
|
|
|
'.jpg': 'image/jpeg',
|
|
|
|
|
|
'.jpeg': 'image/jpeg',
|
|
|
|
|
|
'.png': 'image/png',
|
|
|
|
|
|
'.gif': 'image/gif',
|
|
|
|
|
|
'.bmp': 'image/bmp',
|
|
|
|
|
|
'.webp': 'image/webp',
|
|
|
|
|
|
'.svg': 'image/svg+xml'
|
|
|
|
|
|
}
|
|
|
|
|
|
return mime_types.get(extension, 'image/jpeg')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _image_to_base64_data_url(image_path: Path, mime_type: Optional[str] = None) -> str:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Convert an image file to a base64-encoded data URL.
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
image_path (Path): Path to the image file
|
|
|
|
|
|
mime_type (Optional[str]): MIME type of the image (auto-detected if None)
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
str: Base64-encoded data URL (e.g., "data:image/jpeg;base64,...")
|
|
|
|
|
|
"""
|
|
|
|
|
|
# Read the image as bytes
|
|
|
|
|
|
data = image_path.read_bytes()
|
|
|
|
|
|
|
|
|
|
|
|
# Encode to base64
|
|
|
|
|
|
encoded = base64.b64encode(data).decode("ascii")
|
|
|
|
|
|
|
|
|
|
|
|
# Determine MIME type
|
|
|
|
|
|
mime = mime_type or _determine_mime_type(image_path)
|
|
|
|
|
|
|
|
|
|
|
|
# Create data URL
|
|
|
|
|
|
data_url = f"data:{mime};base64,{encoded}"
|
|
|
|
|
|
|
|
|
|
|
|
return data_url
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-31 00:12:09 -07:00
|
|
|
|
# Absolute hard ceiling for vision API payloads (20 MB) — above this, no major
|
|
|
|
|
|
# provider accepts the image and we reject outright.
|
2026-04-11 11:07:18 -07:00
|
|
|
|
_MAX_BASE64_BYTES = 20 * 1024 * 1024
|
|
|
|
|
|
|
2026-05-31 00:12:09 -07:00
|
|
|
|
# Proactive embed cap (4 MB). This is the size we resize an image DOWN to
|
|
|
|
|
|
# before embedding it into conversation history, regardless of the 20 MB hard
|
|
|
|
|
|
# ceiling. Anthropic's per-image base64 limit is 5 MB; once an oversized image
|
|
|
|
|
|
# is baked into history (e.g. a vision tool-result), it is re-sent on every
|
|
|
|
|
|
# subsequent turn and permanently wedges the session with a 400 that retries
|
|
|
|
|
|
# can't clear (the bad bytes are immutable history). Capping at embed time —
|
|
|
|
|
|
# with headroom under 5 MB — is the only durable fix. Matches the post-failure
|
|
|
|
|
|
# shrink target in agent.conversation_compression so behaviour is consistent
|
|
|
|
|
|
# whether we resize proactively or reactively.
|
|
|
|
|
|
_EMBED_TARGET_BYTES = 4 * 1024 * 1024
|
|
|
|
|
|
|
2026-06-04 05:46:54 -07:00
|
|
|
|
# Proactive embed dimension cap (px, longest side). Anthropic enforces an
|
|
|
|
|
|
# 8000px per-side ceiling INDEPENDENTLY of the 5 MB byte cap — a tall full-page
|
|
|
|
|
|
# screenshot can be well under 5 MB yet far over 8000px (e.g. 1200×12000 at
|
|
|
|
|
|
# 0.06 MB), so the byte-only embed check above lets it slip into immutable
|
|
|
|
|
|
# history un-resized and the session bricks on a non-retryable 400. We cap at
|
|
|
|
|
|
# 7900 (headroom under 8000) so the proactive resize shrinks tall small-byte
|
|
|
|
|
|
# images before they are embedded.
|
|
|
|
|
|
_EMBED_MAX_DIMENSION = 7900
|
|
|
|
|
|
|
2026-04-11 11:07:18 -07:00
|
|
|
|
# Target size when auto-resizing on API failure (5 MB). After a provider
|
|
|
|
|
|
# rejects an image, we downscale to this target and retry once.
|
|
|
|
|
|
_RESIZE_TARGET_BYTES = 5 * 1024 * 1024
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_image_size_error(error: Exception) -> bool:
|
|
|
|
|
|
"""Detect if an API error is related to image or payload size."""
|
|
|
|
|
|
err_str = str(error).lower()
|
|
|
|
|
|
return any(hint in err_str for hint in (
|
|
|
|
|
|
"too large", "payload", "413", "content_too_large",
|
|
|
|
|
|
"request_too_large", "image_url", "invalid_request",
|
|
|
|
|
|
"exceeds", "size limit",
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-04 05:46:54 -07:00
|
|
|
|
def _image_exceeds_dimension(image_path: Path, max_dimension: int) -> bool:
|
|
|
|
|
|
"""True if the image's longest side exceeds ``max_dimension`` px.
|
|
|
|
|
|
|
|
|
|
|
|
Anthropic enforces an 8000px per-side cap independently of the 5 MB byte
|
|
|
|
|
|
cap, so a tall small-byte screenshot can pass every byte check yet trip a
|
|
|
|
|
|
non-retryable 400. Returns False (don't force a resize) when Pillow is
|
|
|
|
|
|
unavailable or the file can't be read as an image — the byte-based checks
|
|
|
|
|
|
still apply, and we never want a missing soft dependency to break the
|
|
|
|
|
|
embed path.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from PIL import Image as _PILImage
|
|
|
|
|
|
with _PILImage.open(image_path) as _img:
|
|
|
|
|
|
return max(_img.size) > max_dimension
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-11 11:07:18 -07:00
|
|
|
|
def _resize_image_for_vision(image_path: Path, mime_type: Optional[str] = None,
|
2026-06-02 23:45:22 +00:00
|
|
|
|
max_base64_bytes: int = _RESIZE_TARGET_BYTES,
|
|
|
|
|
|
max_dimension: Optional[int] = None) -> str:
|
2026-04-11 11:07:18 -07:00
|
|
|
|
"""Convert an image to a base64 data URL, auto-resizing if too large.
|
|
|
|
|
|
|
|
|
|
|
|
Tries Pillow first to progressively downscale oversized images. If Pillow
|
|
|
|
|
|
is not installed or resizing still exceeds the limit, falls back to the raw
|
|
|
|
|
|
bytes and lets the caller handle the size check.
|
|
|
|
|
|
|
2026-06-02 23:45:22 +00:00
|
|
|
|
Args:
|
|
|
|
|
|
max_dimension: If set, images whose longest side exceeds this pixel
|
|
|
|
|
|
count are forcibly downscaled even if they're under the byte
|
|
|
|
|
|
budget. Anthropic enforces an 8000 px per-side cap independently
|
|
|
|
|
|
of the 5 MB byte cap.
|
|
|
|
|
|
|
2026-04-11 11:07:18 -07:00
|
|
|
|
Returns the base64 data URL string.
|
|
|
|
|
|
"""
|
|
|
|
|
|
# Quick file-size estimate: base64 expands by ~4/3, plus data URL header.
|
|
|
|
|
|
# Skip the expensive full-read + encode if Pillow can resize directly.
|
|
|
|
|
|
file_size = image_path.stat().st_size
|
|
|
|
|
|
estimated_b64 = (file_size * 4) // 3 + 100 # ~header overhead
|
2026-06-02 23:45:22 +00:00
|
|
|
|
needs_resize_for_bytes = estimated_b64 > max_base64_bytes
|
|
|
|
|
|
|
|
|
|
|
|
# Check pixel dimensions even if bytes are fine.
|
|
|
|
|
|
needs_resize_for_dims = False
|
|
|
|
|
|
if max_dimension is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
from PIL import Image as _PILQuick
|
|
|
|
|
|
with _PILQuick.open(image_path) as _quick_img:
|
|
|
|
|
|
if max(_quick_img.size) > max_dimension:
|
|
|
|
|
|
needs_resize_for_dims = True
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass # can't check; Pillow path below will handle or skip
|
|
|
|
|
|
|
|
|
|
|
|
if not needs_resize_for_bytes and not needs_resize_for_dims:
|
2026-04-11 11:07:18 -07:00
|
|
|
|
# Small enough — just encode directly.
|
|
|
|
|
|
data_url = _image_to_base64_data_url(image_path, mime_type=mime_type)
|
|
|
|
|
|
if len(data_url) <= max_base64_bytes:
|
|
|
|
|
|
return data_url
|
|
|
|
|
|
else:
|
|
|
|
|
|
data_url = None # defer full encode; try Pillow resize first
|
|
|
|
|
|
|
|
|
|
|
|
# Attempt auto-resize with Pillow (soft dependency)
|
|
|
|
|
|
try:
|
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
import io as _io
|
|
|
|
|
|
except ImportError:
|
2026-06-04 05:46:54 -07:00
|
|
|
|
# Pillow is a lazy-installable soft dependency. Try a best-effort
|
|
|
|
|
|
# install (respects security.allow_lazy_installs; no-op if disabled or
|
|
|
|
|
|
# offline), then re-import. If it still isn't importable, fall back to
|
|
|
|
|
|
# the raw bytes and let the caller raise the size error.
|
|
|
|
|
|
try:
|
|
|
|
|
|
from tools.lazy_deps import ensure as _ensure_dep
|
2026-06-06 18:30:51 -07:00
|
|
|
|
# prompt=False: never raise a blocking input() prompt mid-session.
|
|
|
|
|
|
# Under the interactive CLI prompt_toolkit owns stdin, so a bare
|
|
|
|
|
|
# input() deadlocks the terminal (#40490). The install is already
|
|
|
|
|
|
# gated by security.allow_lazy_installs, so reaching here is opt-in.
|
|
|
|
|
|
_ensure_dep("tool.vision", prompt=False)
|
2026-06-04 05:46:54 -07:00
|
|
|
|
from PIL import Image
|
|
|
|
|
|
import io as _io
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logger.info("Pillow not installed — cannot auto-resize oversized image")
|
|
|
|
|
|
if data_url is None:
|
|
|
|
|
|
data_url = _image_to_base64_data_url(image_path, mime_type=mime_type)
|
|
|
|
|
|
return data_url # caller will raise the size error
|
2026-04-11 11:07:18 -07:00
|
|
|
|
|
2026-06-02 23:45:22 +00:00
|
|
|
|
logger.info("Image file is %.1f MB (estimated base64 %.1f MB, limit %.1f MB, max_dimension=%s), auto-resizing...",
|
2026-04-11 11:07:18 -07:00
|
|
|
|
file_size / (1024 * 1024), estimated_b64 / (1024 * 1024),
|
2026-06-02 23:45:22 +00:00
|
|
|
|
max_base64_bytes / (1024 * 1024), max_dimension)
|
2026-04-11 11:07:18 -07:00
|
|
|
|
|
|
|
|
|
|
mime = mime_type or _determine_mime_type(image_path)
|
|
|
|
|
|
# Choose output format: JPEG for photos (smaller), PNG for transparency
|
|
|
|
|
|
pil_format = "PNG" if mime == "image/png" else "JPEG"
|
|
|
|
|
|
out_mime = "image/png" if pil_format == "PNG" else "image/jpeg"
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
img = Image.open(image_path)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.info("Pillow cannot open image for resizing: %s", exc)
|
|
|
|
|
|
if data_url is None:
|
|
|
|
|
|
data_url = _image_to_base64_data_url(image_path, mime_type=mime_type)
|
|
|
|
|
|
return data_url # fall through to size-check in caller
|
|
|
|
|
|
# Convert RGBA to RGB for JPEG output
|
2026-05-11 11:13:25 -07:00
|
|
|
|
if pil_format == "JPEG" and img.mode in {"RGBA", "P"}:
|
2026-04-11 11:07:18 -07:00
|
|
|
|
img = img.convert("RGB")
|
|
|
|
|
|
|
2026-06-02 23:45:22 +00:00
|
|
|
|
# Strategy: halve dimensions until both base64 fits AND pixel dimensions
|
|
|
|
|
|
# are within limits, up to 4 rounds.
|
2026-04-11 11:07:18 -07:00
|
|
|
|
# For JPEG, also try reducing quality at each size step.
|
|
|
|
|
|
# For PNG, quality is irrelevant — only dimension reduction helps.
|
|
|
|
|
|
quality_steps = (85, 70, 50) if pil_format == "JPEG" else (None,)
|
|
|
|
|
|
prev_dims = (img.width, img.height)
|
|
|
|
|
|
candidate = None # will be set on first loop iteration
|
|
|
|
|
|
|
2026-06-02 23:45:22 +00:00
|
|
|
|
def _dims_ok(w: int, h: int) -> bool:
|
|
|
|
|
|
"""True if both pixel dimensions are within the limit."""
|
|
|
|
|
|
if max_dimension is None:
|
|
|
|
|
|
return True
|
|
|
|
|
|
return max(w, h) <= max_dimension
|
|
|
|
|
|
|
2026-04-11 11:07:18 -07:00
|
|
|
|
for attempt in range(5):
|
|
|
|
|
|
if attempt > 0:
|
2026-04-11 21:42:47 +03:00
|
|
|
|
# Proportional scaling: halve the longer side and scale the
|
|
|
|
|
|
# shorter side to preserve aspect ratio (min dimension 64).
|
|
|
|
|
|
scale = 0.5
|
|
|
|
|
|
new_w = max(int(img.width * scale), 64)
|
|
|
|
|
|
new_h = max(int(img.height * scale), 64)
|
|
|
|
|
|
# Re-derive the scale from whichever dimension hit the floor
|
|
|
|
|
|
# so both axes shrink by the same factor.
|
|
|
|
|
|
if new_w == 64 and img.width > 0:
|
|
|
|
|
|
effective_scale = 64 / img.width
|
|
|
|
|
|
new_h = max(int(img.height * effective_scale), 64)
|
|
|
|
|
|
elif new_h == 64 and img.height > 0:
|
|
|
|
|
|
effective_scale = 64 / img.height
|
|
|
|
|
|
new_w = max(int(img.width * effective_scale), 64)
|
2026-04-11 11:07:18 -07:00
|
|
|
|
# Stop if dimensions can't shrink further
|
|
|
|
|
|
if (new_w, new_h) == prev_dims:
|
|
|
|
|
|
break
|
|
|
|
|
|
img = img.resize((new_w, new_h), Image.LANCZOS)
|
|
|
|
|
|
prev_dims = (new_w, new_h)
|
|
|
|
|
|
logger.info("Resized to %dx%d (attempt %d)", new_w, new_h, attempt)
|
|
|
|
|
|
|
|
|
|
|
|
for q in quality_steps:
|
|
|
|
|
|
buf = _io.BytesIO()
|
|
|
|
|
|
save_kwargs = {"format": pil_format}
|
|
|
|
|
|
if q is not None:
|
|
|
|
|
|
save_kwargs["quality"] = q
|
|
|
|
|
|
img.save(buf, **save_kwargs)
|
|
|
|
|
|
encoded = base64.b64encode(buf.getvalue()).decode("ascii")
|
|
|
|
|
|
candidate = f"data:{out_mime};base64,{encoded}"
|
2026-06-02 23:45:22 +00:00
|
|
|
|
if len(candidate) <= max_base64_bytes and _dims_ok(img.width, img.height):
|
2026-04-11 11:07:18 -07:00
|
|
|
|
logger.info("Auto-resized image fits: %.1f MB (quality=%s, %dx%d)",
|
|
|
|
|
|
len(candidate) / (1024 * 1024), q,
|
|
|
|
|
|
img.width, img.height)
|
|
|
|
|
|
return candidate
|
|
|
|
|
|
|
|
|
|
|
|
# If we still can't get it small enough, return the best attempt
|
|
|
|
|
|
# and let the caller decide
|
|
|
|
|
|
if candidate is not None:
|
|
|
|
|
|
logger.warning("Auto-resize could not fit image under %.1f MB (best: %.1f MB)",
|
|
|
|
|
|
max_base64_bytes / (1024 * 1024), len(candidate) / (1024 * 1024))
|
|
|
|
|
|
return candidate
|
|
|
|
|
|
|
|
|
|
|
|
# Shouldn't reach here, but fall back to full encode
|
|
|
|
|
|
return data_url or _image_to_base64_data_url(image_path, mime_type=mime_type)
|
|
|
|
|
|
|
|
|
|
|
|
|
feat(vision): vision_analyze returns pixels to vision-capable models, not aux text (#22955)
When the active main model has native vision and the provider supports
multimodal tool results (Anthropic, OpenAI Chat, Codex Responses, Gemini
3, OpenRouter, Nous), vision_analyze loads the image bytes and returns
them to the model as a multimodal tool-result envelope. The model then
sees the pixels directly on its next turn instead of receiving a lossy
text description from an auxiliary LLM.
Falls back to the legacy aux-LLM text path for non-vision models and
unverified providers.
Mirrors the architecture used in OpenCode, Claude Code, Codex CLI, and
Cline. All four converge on the same pattern: tool results carry image
content blocks for vision-capable provider/model combinations.
Changes
- tools/vision_tools.py: _vision_analyze_native fast path + provider
capability table (_supports_media_in_tool_results). Schema description
updated to reflect new behaviour.
- agent/codex_responses_adapter.py: function_call_output.output now
accepts the array form for multimodal tool results (was string-only).
Preflight validates input_text/input_image parts.
- agent/auxiliary_client.py: _RUNTIME_MAIN_PROVIDER/_MODEL globals so
tools see the live CLI/gateway override, not the stale config.yaml
default. set_runtime_main()/clear_runtime_main() helpers.
- run_agent.py: AIAgent.run_conversation calls set_runtime_main at turn
start so vision_analyze's fast-path check sees the actual runtime.
- tests/conftest.py: clear runtime-main override between tests.
Tests
- tests/tools/test_vision_native_fast_path.py: provider capability
table, envelope shape, fast-path gating (vision-capable model uses
fast path; non-vision model falls through to aux).
- tests/run_agent/test_codex_multimodal_tool_result.py: list tool
content becomes function_call_output.output array; preflight
preserves arrays and drops unknown part types.
Live verified
- Opus 4.6 + Sonnet 4.6 on OpenRouter: model calls vision_analyze on a
typed filepath, gets pixels back, reads exact text from images that
no aux description could capture (font color irony, multi-line
fruit-count list, etc.).
PR replaces the closed prior efforts (#16506 shipped the inbound user-
attached path; this PR closes the gap for tool-discovered images).
2026-05-09 21:06:19 -07:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Native fast path: short-circuit the auxiliary LLM when the active main model
|
|
|
|
|
|
# supports native vision. Instead of asking a separate LLM to describe the
|
|
|
|
|
|
# image and returning text, we load the image, base64-encode it, and return a
|
|
|
|
|
|
# multimodal tool-result envelope. The agent loop unwraps the envelope into an
|
|
|
|
|
|
# OpenAI-style content list on the `tool` role; provider adapters (anthropic,
|
|
|
|
|
|
# codex_responses, chat_completions) translate that into Anthropic
|
|
|
|
|
|
# tool_result image blocks / Responses input_image / OpenAI image_url tool
|
|
|
|
|
|
# content. The main model then "sees" the pixels directly on its next turn.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _supports_media_in_tool_results(provider: str, model: str) -> bool:
|
|
|
|
|
|
"""Whether the given provider+model combination accepts image content
|
|
|
|
|
|
inside a tool-result message.
|
|
|
|
|
|
|
|
|
|
|
|
Providers covered today (per spec docs verified Apr-2026):
|
|
|
|
|
|
|
|
|
|
|
|
* Anthropic Messages API (``anthropic`` provider, plus aggregators that
|
|
|
|
|
|
proxy Claude — ``openrouter``, ``nous``, ``vertex``, ``bedrock``):
|
|
|
|
|
|
``tool_result`` blocks accept ``image`` content blocks.
|
|
|
|
|
|
* OpenAI Chat Completions: tool messages accept array content with
|
|
|
|
|
|
``image_url`` parts.
|
|
|
|
|
|
* OpenAI Responses (``openai-codex``): ``function_call_output.output``
|
|
|
|
|
|
accepts an array of ``input_text``/``input_image`` items.
|
|
|
|
|
|
* Gemini 3 (and proxied via aggregators): supports multimodal tool
|
|
|
|
|
|
results. Older Gemini does NOT.
|
|
|
|
|
|
|
|
|
|
|
|
For unknown / legacy providers we conservatively return False — the
|
2026-05-15 16:56:05 +03:00
|
|
|
|
caller falls back to the legacy aux-LLM text path. The check is relaxed
|
2026-06-04 17:31:51 -07:00
|
|
|
|
when the provider's ``ProviderProfile`` declares ``supports_vision=True``.
|
feat(vision): vision_analyze returns pixels to vision-capable models, not aux text (#22955)
When the active main model has native vision and the provider supports
multimodal tool results (Anthropic, OpenAI Chat, Codex Responses, Gemini
3, OpenRouter, Nous), vision_analyze loads the image bytes and returns
them to the model as a multimodal tool-result envelope. The model then
sees the pixels directly on its next turn instead of receiving a lossy
text description from an auxiliary LLM.
Falls back to the legacy aux-LLM text path for non-vision models and
unverified providers.
Mirrors the architecture used in OpenCode, Claude Code, Codex CLI, and
Cline. All four converge on the same pattern: tool results carry image
content blocks for vision-capable provider/model combinations.
Changes
- tools/vision_tools.py: _vision_analyze_native fast path + provider
capability table (_supports_media_in_tool_results). Schema description
updated to reflect new behaviour.
- agent/codex_responses_adapter.py: function_call_output.output now
accepts the array form for multimodal tool results (was string-only).
Preflight validates input_text/input_image parts.
- agent/auxiliary_client.py: _RUNTIME_MAIN_PROVIDER/_MODEL globals so
tools see the live CLI/gateway override, not the stale config.yaml
default. set_runtime_main()/clear_runtime_main() helpers.
- run_agent.py: AIAgent.run_conversation calls set_runtime_main at turn
start so vision_analyze's fast-path check sees the actual runtime.
- tests/conftest.py: clear runtime-main override between tests.
Tests
- tests/tools/test_vision_native_fast_path.py: provider capability
table, envelope shape, fast-path gating (vision-capable model uses
fast path; non-vision model falls through to aux).
- tests/run_agent/test_codex_multimodal_tool_result.py: list tool
content becomes function_call_output.output array; preflight
preserves arrays and drops unknown part types.
Live verified
- Opus 4.6 + Sonnet 4.6 on OpenRouter: model calls vision_analyze on a
typed filepath, gets pixels back, reads exact text from images that
no aux description could capture (font color irony, multi-line
fruit-count list, etc.).
PR replaces the closed prior efforts (#16506 shipped the inbound user-
attached path; this PR closes the gap for tool-discovered images).
2026-05-09 21:06:19 -07:00
|
|
|
|
"""
|
|
|
|
|
|
if not isinstance(provider, str):
|
|
|
|
|
|
return False
|
|
|
|
|
|
p = provider.strip().lower()
|
|
|
|
|
|
if not p:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# Aggregators that route to multiple vendors — assume support since
|
|
|
|
|
|
# users on these aggregators are typically using vision-capable
|
|
|
|
|
|
# frontier models. Falling back to text would be a regression for
|
|
|
|
|
|
# them.
|
|
|
|
|
|
_AGGREGATORS = {
|
|
|
|
|
|
"openrouter", "nous", "vertex", "bedrock", "anthropic-vertex",
|
|
|
|
|
|
"google-vertex",
|
|
|
|
|
|
}
|
|
|
|
|
|
if p in _AGGREGATORS:
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
# Native Anthropic
|
|
|
|
|
|
if p in {"anthropic", "claude", "anthropic-direct"}:
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
# OpenAI Chat Completions and Responses
|
|
|
|
|
|
if p in {"openai", "openai-chat", "openai-codex", "azure-openai"}:
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
# Gemini — gate on model name; older Gemini variants did not support
|
|
|
|
|
|
# multimodal functionResponse. Gemini 3.x does.
|
|
|
|
|
|
if p in {"google", "gemini", "google-gemini", "google-vertex-gemini"}:
|
|
|
|
|
|
if not isinstance(model, str):
|
|
|
|
|
|
return False
|
|
|
|
|
|
m = model.strip().lower()
|
|
|
|
|
|
if "gemini-3" in m or "gemini-pro-3" in m or "gemini-flash-3" in m:
|
|
|
|
|
|
return True
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
2026-05-15 16:56:05 +03:00
|
|
|
|
# Check the provider's registered profile for the supports_vision flag.
|
|
|
|
|
|
# This covers vision-capable providers like xiaomi, minimax, etc. that
|
|
|
|
|
|
# aren't in the hardcoded list above.
|
|
|
|
|
|
try:
|
|
|
|
|
|
from providers import get_provider_profile
|
|
|
|
|
|
profile = get_provider_profile(p)
|
|
|
|
|
|
if profile is not None and profile.supports_vision:
|
|
|
|
|
|
return True
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
feat(vision): vision_analyze returns pixels to vision-capable models, not aux text (#22955)
When the active main model has native vision and the provider supports
multimodal tool results (Anthropic, OpenAI Chat, Codex Responses, Gemini
3, OpenRouter, Nous), vision_analyze loads the image bytes and returns
them to the model as a multimodal tool-result envelope. The model then
sees the pixels directly on its next turn instead of receiving a lossy
text description from an auxiliary LLM.
Falls back to the legacy aux-LLM text path for non-vision models and
unverified providers.
Mirrors the architecture used in OpenCode, Claude Code, Codex CLI, and
Cline. All four converge on the same pattern: tool results carry image
content blocks for vision-capable provider/model combinations.
Changes
- tools/vision_tools.py: _vision_analyze_native fast path + provider
capability table (_supports_media_in_tool_results). Schema description
updated to reflect new behaviour.
- agent/codex_responses_adapter.py: function_call_output.output now
accepts the array form for multimodal tool results (was string-only).
Preflight validates input_text/input_image parts.
- agent/auxiliary_client.py: _RUNTIME_MAIN_PROVIDER/_MODEL globals so
tools see the live CLI/gateway override, not the stale config.yaml
default. set_runtime_main()/clear_runtime_main() helpers.
- run_agent.py: AIAgent.run_conversation calls set_runtime_main at turn
start so vision_analyze's fast-path check sees the actual runtime.
- tests/conftest.py: clear runtime-main override between tests.
Tests
- tests/tools/test_vision_native_fast_path.py: provider capability
table, envelope shape, fast-path gating (vision-capable model uses
fast path; non-vision model falls through to aux).
- tests/run_agent/test_codex_multimodal_tool_result.py: list tool
content becomes function_call_output.output array; preflight
preserves arrays and drops unknown part types.
Live verified
- Opus 4.6 + Sonnet 4.6 on OpenRouter: model calls vision_analyze on a
typed filepath, gets pixels back, reads exact text from images that
no aux description could capture (font color irony, multi-line
fruit-count list, etc.).
PR replaces the closed prior efforts (#16506 shipped the inbound user-
attached path; this PR closes the gap for tool-discovered images).
2026-05-09 21:06:19 -07:00
|
|
|
|
# Other vision-capable provider stacks. Conservative default: False.
|
|
|
|
|
|
# Add explicit entries here as we verify each provider's tool-result
|
|
|
|
|
|
# multimodal support empirically.
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-29 03:40:26 -07:00
|
|
|
|
def _should_use_native_vision_fast_path() -> bool:
|
|
|
|
|
|
"""Whether vision tools should attach the image to the main model directly
|
|
|
|
|
|
instead of routing through the auxiliary vision LLM.
|
|
|
|
|
|
|
|
|
|
|
|
True when image routing resolves to ``native`` AND either the provider is
|
|
|
|
|
|
known to accept images inside tool results, or the user explicitly declared
|
|
|
|
|
|
the model vision-capable via the ``model.supports_vision`` config override.
|
|
|
|
|
|
The override is the escape hatch for custom/local providers that aren't in
|
|
|
|
|
|
the static allowlist. Best-effort: any resolution failure returns False so
|
|
|
|
|
|
the caller falls back to the legacy aux-LLM path.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from agent.auxiliary_client import _read_main_provider, _read_main_model
|
|
|
|
|
|
from agent.image_routing import decide_image_input_mode, _lookup_supports_vision
|
|
|
|
|
|
from hermes_cli.config import load_config
|
|
|
|
|
|
|
|
|
|
|
|
provider = _read_main_provider()
|
|
|
|
|
|
model = _read_main_model()
|
|
|
|
|
|
cfg = load_config()
|
|
|
|
|
|
if decide_image_input_mode(provider, model, cfg) != "native":
|
|
|
|
|
|
return False
|
|
|
|
|
|
return (
|
|
|
|
|
|
_supports_media_in_tool_results(provider, model)
|
|
|
|
|
|
or _lookup_supports_vision(provider, model, cfg) is True
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.debug("Native vision fast-path check failed: %s", exc)
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
feat(vision): vision_analyze returns pixels to vision-capable models, not aux text (#22955)
When the active main model has native vision and the provider supports
multimodal tool results (Anthropic, OpenAI Chat, Codex Responses, Gemini
3, OpenRouter, Nous), vision_analyze loads the image bytes and returns
them to the model as a multimodal tool-result envelope. The model then
sees the pixels directly on its next turn instead of receiving a lossy
text description from an auxiliary LLM.
Falls back to the legacy aux-LLM text path for non-vision models and
unverified providers.
Mirrors the architecture used in OpenCode, Claude Code, Codex CLI, and
Cline. All four converge on the same pattern: tool results carry image
content blocks for vision-capable provider/model combinations.
Changes
- tools/vision_tools.py: _vision_analyze_native fast path + provider
capability table (_supports_media_in_tool_results). Schema description
updated to reflect new behaviour.
- agent/codex_responses_adapter.py: function_call_output.output now
accepts the array form for multimodal tool results (was string-only).
Preflight validates input_text/input_image parts.
- agent/auxiliary_client.py: _RUNTIME_MAIN_PROVIDER/_MODEL globals so
tools see the live CLI/gateway override, not the stale config.yaml
default. set_runtime_main()/clear_runtime_main() helpers.
- run_agent.py: AIAgent.run_conversation calls set_runtime_main at turn
start so vision_analyze's fast-path check sees the actual runtime.
- tests/conftest.py: clear runtime-main override between tests.
Tests
- tests/tools/test_vision_native_fast_path.py: provider capability
table, envelope shape, fast-path gating (vision-capable model uses
fast path; non-vision model falls through to aux).
- tests/run_agent/test_codex_multimodal_tool_result.py: list tool
content becomes function_call_output.output array; preflight
preserves arrays and drops unknown part types.
Live verified
- Opus 4.6 + Sonnet 4.6 on OpenRouter: model calls vision_analyze on a
typed filepath, gets pixels back, reads exact text from images that
no aux description could capture (font color irony, multi-line
fruit-count list, etc.).
PR replaces the closed prior efforts (#16506 shipped the inbound user-
attached path; this PR closes the gap for tool-discovered images).
2026-05-09 21:06:19 -07:00
|
|
|
|
def _build_native_vision_tool_result(
|
|
|
|
|
|
image_url: str,
|
|
|
|
|
|
question: str,
|
|
|
|
|
|
image_data_url: str,
|
|
|
|
|
|
image_size_bytes: int,
|
|
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
|
"""Build the multimodal tool-result envelope returned by the fast path.
|
|
|
|
|
|
|
|
|
|
|
|
Shape:
|
|
|
|
|
|
{
|
|
|
|
|
|
"_multimodal": True,
|
|
|
|
|
|
"content": [
|
|
|
|
|
|
{"type": "text", "text": "<short note + the user's question>"},
|
|
|
|
|
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}
|
|
|
|
|
|
],
|
|
|
|
|
|
"text_summary": "<plain-text fallback>",
|
|
|
|
|
|
"meta": {"image_url": ..., "size_bytes": N},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
The text part exists for two reasons: (1) it gives the model an
|
|
|
|
|
|
instruction to act on now that the pixels are in context, and
|
|
|
|
|
|
(2) providers that don't support multimodal tool results can fall back
|
|
|
|
|
|
to ``text_summary``.
|
|
|
|
|
|
"""
|
|
|
|
|
|
# The tool-result text part is intentionally minimal. The model already
|
|
|
|
|
|
# has the user's original question in context; this just acknowledges
|
|
|
|
|
|
# the image is now visible and reminds it what it was asked.
|
|
|
|
|
|
text_part = (
|
|
|
|
|
|
"Image loaded into your context — you can see it natively now. "
|
|
|
|
|
|
"Use your built-in vision to answer the user."
|
|
|
|
|
|
)
|
|
|
|
|
|
if isinstance(question, str) and question.strip():
|
|
|
|
|
|
text_part += f"\n\nQuestion: {question.strip()}"
|
|
|
|
|
|
|
|
|
|
|
|
summary = (
|
|
|
|
|
|
f"Image attached natively for the main model "
|
|
|
|
|
|
f"({image_size_bytes / 1024:.1f} KB). "
|
|
|
|
|
|
"Answer using built-in vision."
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"_multimodal": True,
|
|
|
|
|
|
"content": [
|
|
|
|
|
|
{"type": "text", "text": text_part},
|
|
|
|
|
|
{"type": "image_url", "image_url": {"url": image_data_url}},
|
|
|
|
|
|
],
|
|
|
|
|
|
"text_summary": summary,
|
|
|
|
|
|
"meta": {
|
|
|
|
|
|
"image_url": image_url[:200],
|
|
|
|
|
|
"size_bytes": image_size_bytes,
|
|
|
|
|
|
"native_vision": True,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
fix(vision): cap vision_analyze fan-out concurrency process-wide
A single agent turn can fan out N vision_analyze calls at once — the
classic trigger is "analyze every frame of this video", where ffmpeg
explodes a clip into dozens of frames and the model calls vision_analyze
on each. Every call does a CPU-heavy base64-encode/resize burst AND holds
a long-lived LLM stream open. The tool executor runs concurrent tool calls
on a per-session ThreadPoolExecutor (_MAX_TOOL_WORKERS=8), and multiple
agent sessions share one process (the dashboard runs the agent in-process),
so there was no global ceiling. In prod (June 2026) a video-frame fan-out
pinned a worker thread at ~100% CPU and starved the shared asyncio event
loop that also serves the dashboard's /api/status liveness probe, flapping
the instance to UNHEALTHY even though nothing had crashed.
Add a process-global threading.BoundedSemaphore that bounds how many vision
analyses run concurrently across the whole process, held across the entire
analysis (image load + encode + LLM call) in the single _handle_vision_analyze
chokepoint (covers both the native fast path and the legacy aux-LLM path).
It is a threading semaphore, NOT asyncio: each vision call is dispatched
through model_tools._run_async on a per-thread event loop, so an asyncio
primitive bound to one loop cannot coordinate across them. The acquire is
offloaded via run_in_executor so waiting for a slot never blocks the calling
loop.
Default: min(host CPUs, 4), floored at 1 — respect the host's concurrency,
or lower. Override via auxiliary.vision.max_concurrency (config.yaml) or
HERMES_VISION_MAX_CONCURRENCY (env). Values < 1 are ignored so the cap can
never be disabled into an unbounded fan-out.
Tests: bounded-fan-out regression guard + a control proving it would fail
without the cap; resolver tests for host-cpu default, ceiling clamp, low-cpu
host, env override, and sub-1 rejection. Pre-existing handler tests updated
for the now-async _handle_vision_analyze. Verified via the real
registry.dispatch -> _run_async per-thread-loop path (16 concurrent calls,
peak bounded to cap).
2026-06-29 15:18:01 +10:00
|
|
|
|
@contextlib.asynccontextmanager
|
|
|
|
|
|
async def _vision_concurrency_slot():
|
fix(vision): narrow the fan-out cap to the CPU encode burst only
The original cap held a process-global slot across the WHOLE vision
analysis (image load + encode + LLM call) with a default of min(CPUs, 4).
That serialized legitimate multi-image workflows — "compare these 6
screenshots", "read this 10-page scan", "analyze every frame" — behind a
4-wide gate, and on the native fast path it even throttled calls that make
no LLM request at all. Excess calls queued (blocking acquire, nothing
dropped), but the latency hit on real fan-out was the wrong tradeoff.
The incident was CPU exhaustion, not call count: concurrent base64/resize
bursts saturated every core and left none to service the shared event loop
serving /api/status. So cap ONLY that:
- A dedicated, bounded ThreadPoolExecutor (_vision_cpu_executor) runs the
encode/resize/dimension-check off the caller's loop, sized to the host's
usable core count with NO fixed ceiling — the cap tracks the actual
exhausted resource (cores), not a magic number. Excess encodes queue on
the executor; cores stay free for the loop.
- The LLM call is deliberately OUTSIDE the executor, so multi-image
workflows keep full request concurrency.
- Override via auxiliary.vision.max_concurrency / HERMES_VISION_MAX_CONCURRENCY
(honored verbatim, including above core count); sub-1 ignored.
- _vision_concurrency_slot() is now a no-op shim for back-compat.
Tests assert: resolver defaults to host cores with no ceiling; env/config
override (incl. above cores); sub-1 rejection; the executor is dedicated and
core-sized; encode runs on a vision-encode thread; and crucially that encode
bursts are bounded to the cap while the analyses themselves stay fully
concurrent (calls_peak > cap).
2026-06-28 22:48:37 -07:00
|
|
|
|
"""Deprecated no-op shim kept for backward compatibility.
|
|
|
|
|
|
|
|
|
|
|
|
The fan-out cap was narrowed to the CPU-bound encode/resize burst only
|
|
|
|
|
|
(see :data:`_vision_cpu_executor` / :func:`_run_encode_on_cpu_executor`).
|
|
|
|
|
|
Holding a slot across the whole analysis serialized legitimate multi-image
|
|
|
|
|
|
workflows behind the slow LLM call, which is exactly what we don't want.
|
|
|
|
|
|
This context manager no longer gates anything; encode/resize is bounded
|
|
|
|
|
|
where it actually runs. Retained only so any external caller importing it
|
|
|
|
|
|
keeps working.
|
fix(vision): cap vision_analyze fan-out concurrency process-wide
A single agent turn can fan out N vision_analyze calls at once — the
classic trigger is "analyze every frame of this video", where ffmpeg
explodes a clip into dozens of frames and the model calls vision_analyze
on each. Every call does a CPU-heavy base64-encode/resize burst AND holds
a long-lived LLM stream open. The tool executor runs concurrent tool calls
on a per-session ThreadPoolExecutor (_MAX_TOOL_WORKERS=8), and multiple
agent sessions share one process (the dashboard runs the agent in-process),
so there was no global ceiling. In prod (June 2026) a video-frame fan-out
pinned a worker thread at ~100% CPU and starved the shared asyncio event
loop that also serves the dashboard's /api/status liveness probe, flapping
the instance to UNHEALTHY even though nothing had crashed.
Add a process-global threading.BoundedSemaphore that bounds how many vision
analyses run concurrently across the whole process, held across the entire
analysis (image load + encode + LLM call) in the single _handle_vision_analyze
chokepoint (covers both the native fast path and the legacy aux-LLM path).
It is a threading semaphore, NOT asyncio: each vision call is dispatched
through model_tools._run_async on a per-thread event loop, so an asyncio
primitive bound to one loop cannot coordinate across them. The acquire is
offloaded via run_in_executor so waiting for a slot never blocks the calling
loop.
Default: min(host CPUs, 4), floored at 1 — respect the host's concurrency,
or lower. Override via auxiliary.vision.max_concurrency (config.yaml) or
HERMES_VISION_MAX_CONCURRENCY (env). Values < 1 are ignored so the cap can
never be disabled into an unbounded fan-out.
Tests: bounded-fan-out regression guard + a control proving it would fail
without the cap; resolver tests for host-cpu default, ceiling clamp, low-cpu
host, env override, and sub-1 rejection. Pre-existing handler tests updated
for the now-async _handle_vision_analyze. Verified via the real
registry.dispatch -> _run_async per-thread-loop path (16 concurrent calls,
peak bounded to cap).
2026-06-29 15:18:01 +10:00
|
|
|
|
"""
|
fix(vision): narrow the fan-out cap to the CPU encode burst only
The original cap held a process-global slot across the WHOLE vision
analysis (image load + encode + LLM call) with a default of min(CPUs, 4).
That serialized legitimate multi-image workflows — "compare these 6
screenshots", "read this 10-page scan", "analyze every frame" — behind a
4-wide gate, and on the native fast path it even throttled calls that make
no LLM request at all. Excess calls queued (blocking acquire, nothing
dropped), but the latency hit on real fan-out was the wrong tradeoff.
The incident was CPU exhaustion, not call count: concurrent base64/resize
bursts saturated every core and left none to service the shared event loop
serving /api/status. So cap ONLY that:
- A dedicated, bounded ThreadPoolExecutor (_vision_cpu_executor) runs the
encode/resize/dimension-check off the caller's loop, sized to the host's
usable core count with NO fixed ceiling — the cap tracks the actual
exhausted resource (cores), not a magic number. Excess encodes queue on
the executor; cores stay free for the loop.
- The LLM call is deliberately OUTSIDE the executor, so multi-image
workflows keep full request concurrency.
- Override via auxiliary.vision.max_concurrency / HERMES_VISION_MAX_CONCURRENCY
(honored verbatim, including above core count); sub-1 ignored.
- _vision_concurrency_slot() is now a no-op shim for back-compat.
Tests assert: resolver defaults to host cores with no ceiling; env/config
override (incl. above cores); sub-1 rejection; the executor is dedicated and
core-sized; encode runs on a vision-encode thread; and crucially that encode
bursts are bounded to the cap while the analyses themselves stay fully
concurrent (calls_peak > cap).
2026-06-28 22:48:37 -07:00
|
|
|
|
yield
|
fix(vision): cap vision_analyze fan-out concurrency process-wide
A single agent turn can fan out N vision_analyze calls at once — the
classic trigger is "analyze every frame of this video", where ffmpeg
explodes a clip into dozens of frames and the model calls vision_analyze
on each. Every call does a CPU-heavy base64-encode/resize burst AND holds
a long-lived LLM stream open. The tool executor runs concurrent tool calls
on a per-session ThreadPoolExecutor (_MAX_TOOL_WORKERS=8), and multiple
agent sessions share one process (the dashboard runs the agent in-process),
so there was no global ceiling. In prod (June 2026) a video-frame fan-out
pinned a worker thread at ~100% CPU and starved the shared asyncio event
loop that also serves the dashboard's /api/status liveness probe, flapping
the instance to UNHEALTHY even though nothing had crashed.
Add a process-global threading.BoundedSemaphore that bounds how many vision
analyses run concurrently across the whole process, held across the entire
analysis (image load + encode + LLM call) in the single _handle_vision_analyze
chokepoint (covers both the native fast path and the legacy aux-LLM path).
It is a threading semaphore, NOT asyncio: each vision call is dispatched
through model_tools._run_async on a per-thread event loop, so an asyncio
primitive bound to one loop cannot coordinate across them. The acquire is
offloaded via run_in_executor so waiting for a slot never blocks the calling
loop.
Default: min(host CPUs, 4), floored at 1 — respect the host's concurrency,
or lower. Override via auxiliary.vision.max_concurrency (config.yaml) or
HERMES_VISION_MAX_CONCURRENCY (env). Values < 1 are ignored so the cap can
never be disabled into an unbounded fan-out.
Tests: bounded-fan-out regression guard + a control proving it would fail
without the cap; resolver tests for host-cpu default, ceiling clamp, low-cpu
host, env override, and sub-1 rejection. Pre-existing handler tests updated
for the now-async _handle_vision_analyze. Verified via the real
registry.dispatch -> _run_async per-thread-loop path (16 concurrent calls,
peak bounded to cap).
2026-06-29 15:18:01 +10:00
|
|
|
|
|
|
|
|
|
|
|
feat(vision): vision_analyze returns pixels to vision-capable models, not aux text (#22955)
When the active main model has native vision and the provider supports
multimodal tool results (Anthropic, OpenAI Chat, Codex Responses, Gemini
3, OpenRouter, Nous), vision_analyze loads the image bytes and returns
them to the model as a multimodal tool-result envelope. The model then
sees the pixels directly on its next turn instead of receiving a lossy
text description from an auxiliary LLM.
Falls back to the legacy aux-LLM text path for non-vision models and
unverified providers.
Mirrors the architecture used in OpenCode, Claude Code, Codex CLI, and
Cline. All four converge on the same pattern: tool results carry image
content blocks for vision-capable provider/model combinations.
Changes
- tools/vision_tools.py: _vision_analyze_native fast path + provider
capability table (_supports_media_in_tool_results). Schema description
updated to reflect new behaviour.
- agent/codex_responses_adapter.py: function_call_output.output now
accepts the array form for multimodal tool results (was string-only).
Preflight validates input_text/input_image parts.
- agent/auxiliary_client.py: _RUNTIME_MAIN_PROVIDER/_MODEL globals so
tools see the live CLI/gateway override, not the stale config.yaml
default. set_runtime_main()/clear_runtime_main() helpers.
- run_agent.py: AIAgent.run_conversation calls set_runtime_main at turn
start so vision_analyze's fast-path check sees the actual runtime.
- tests/conftest.py: clear runtime-main override between tests.
Tests
- tests/tools/test_vision_native_fast_path.py: provider capability
table, envelope shape, fast-path gating (vision-capable model uses
fast path; non-vision model falls through to aux).
- tests/run_agent/test_codex_multimodal_tool_result.py: list tool
content becomes function_call_output.output array; preflight
preserves arrays and drops unknown part types.
Live verified
- Opus 4.6 + Sonnet 4.6 on OpenRouter: model calls vision_analyze on a
typed filepath, gets pixels back, reads exact text from images that
no aux description could capture (font color irony, multi-line
fruit-count list, etc.).
PR replaces the closed prior efforts (#16506 shipped the inbound user-
attached path; this PR closes the gap for tool-discovered images).
2026-05-09 21:06:19 -07:00
|
|
|
|
async def _vision_analyze_native(
|
|
|
|
|
|
image_url: str,
|
|
|
|
|
|
question: str,
|
|
|
|
|
|
) -> Any:
|
|
|
|
|
|
"""Fast path for vision-capable main models.
|
|
|
|
|
|
|
|
|
|
|
|
Loads the image (local file OR remote URL), base64-encodes it, and
|
|
|
|
|
|
returns a multimodal tool-result envelope. The agent loop unwraps it;
|
|
|
|
|
|
provider adapters serialize it into the right tool-result-with-image
|
|
|
|
|
|
shape for each backend.
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
A ``_multimodal`` envelope dict on success.
|
|
|
|
|
|
A JSON error string on failure (matches the existing tool-result
|
|
|
|
|
|
contract so the agent loop displays errors normally).
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not isinstance(image_url, str) or not image_url.strip():
|
|
|
|
|
|
return tool_error("image_url is required", success=False)
|
|
|
|
|
|
|
|
|
|
|
|
temp_image_path: Optional[Path] = None
|
|
|
|
|
|
should_cleanup = False
|
|
|
|
|
|
try:
|
|
|
|
|
|
from tools.interrupt import is_interrupted
|
|
|
|
|
|
if is_interrupted():
|
|
|
|
|
|
return tool_error("Interrupted", success=False)
|
|
|
|
|
|
|
|
|
|
|
|
# Resolve the image source (mirrors vision_analyze_tool's logic
|
|
|
|
|
|
# exactly so behaviour is consistent).
|
|
|
|
|
|
resolved_url = image_url
|
|
|
|
|
|
if resolved_url.startswith("file://"):
|
|
|
|
|
|
resolved_url = resolved_url[len("file://"):]
|
|
|
|
|
|
local_path = Path(os.path.expanduser(resolved_url))
|
|
|
|
|
|
|
|
|
|
|
|
if local_path.is_file():
|
|
|
|
|
|
temp_image_path = local_path
|
|
|
|
|
|
should_cleanup = False
|
2026-06-04 05:57:11 -07:00
|
|
|
|
elif await _validate_image_url_async(image_url):
|
feat(vision): vision_analyze returns pixels to vision-capable models, not aux text (#22955)
When the active main model has native vision and the provider supports
multimodal tool results (Anthropic, OpenAI Chat, Codex Responses, Gemini
3, OpenRouter, Nous), vision_analyze loads the image bytes and returns
them to the model as a multimodal tool-result envelope. The model then
sees the pixels directly on its next turn instead of receiving a lossy
text description from an auxiliary LLM.
Falls back to the legacy aux-LLM text path for non-vision models and
unverified providers.
Mirrors the architecture used in OpenCode, Claude Code, Codex CLI, and
Cline. All four converge on the same pattern: tool results carry image
content blocks for vision-capable provider/model combinations.
Changes
- tools/vision_tools.py: _vision_analyze_native fast path + provider
capability table (_supports_media_in_tool_results). Schema description
updated to reflect new behaviour.
- agent/codex_responses_adapter.py: function_call_output.output now
accepts the array form for multimodal tool results (was string-only).
Preflight validates input_text/input_image parts.
- agent/auxiliary_client.py: _RUNTIME_MAIN_PROVIDER/_MODEL globals so
tools see the live CLI/gateway override, not the stale config.yaml
default. set_runtime_main()/clear_runtime_main() helpers.
- run_agent.py: AIAgent.run_conversation calls set_runtime_main at turn
start so vision_analyze's fast-path check sees the actual runtime.
- tests/conftest.py: clear runtime-main override between tests.
Tests
- tests/tools/test_vision_native_fast_path.py: provider capability
table, envelope shape, fast-path gating (vision-capable model uses
fast path; non-vision model falls through to aux).
- tests/run_agent/test_codex_multimodal_tool_result.py: list tool
content becomes function_call_output.output array; preflight
preserves arrays and drops unknown part types.
Live verified
- Opus 4.6 + Sonnet 4.6 on OpenRouter: model calls vision_analyze on a
typed filepath, gets pixels back, reads exact text from images that
no aux description could capture (font color irony, multi-line
fruit-count list, etc.).
PR replaces the closed prior efforts (#16506 shipped the inbound user-
attached path; this PR closes the gap for tool-discovered images).
2026-05-09 21:06:19 -07:00
|
|
|
|
blocked = check_website_access(image_url)
|
|
|
|
|
|
if blocked:
|
|
|
|
|
|
return tool_error(blocked["message"], success=False)
|
|
|
|
|
|
temp_dir = get_hermes_dir("cache/vision", "temp_vision_images")
|
|
|
|
|
|
temp_image_path = temp_dir / f"temp_image_{uuid.uuid4()}.jpg"
|
|
|
|
|
|
await _download_image(image_url, temp_image_path)
|
|
|
|
|
|
should_cleanup = True
|
|
|
|
|
|
else:
|
|
|
|
|
|
return tool_error(
|
|
|
|
|
|
"Invalid image source. Provide an HTTP/HTTPS URL or a "
|
|
|
|
|
|
"valid local file path.",
|
|
|
|
|
|
success=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
image_size_bytes = temp_image_path.stat().st_size
|
|
|
|
|
|
detected_mime_type = _detect_image_mime_type(temp_image_path)
|
|
|
|
|
|
if not detected_mime_type:
|
|
|
|
|
|
return tool_error(
|
|
|
|
|
|
"Only real image files are supported for vision analysis.",
|
|
|
|
|
|
success=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
fix(vision): narrow the fan-out cap to the CPU encode burst only
The original cap held a process-global slot across the WHOLE vision
analysis (image load + encode + LLM call) with a default of min(CPUs, 4).
That serialized legitimate multi-image workflows — "compare these 6
screenshots", "read this 10-page scan", "analyze every frame" — behind a
4-wide gate, and on the native fast path it even throttled calls that make
no LLM request at all. Excess calls queued (blocking acquire, nothing
dropped), but the latency hit on real fan-out was the wrong tradeoff.
The incident was CPU exhaustion, not call count: concurrent base64/resize
bursts saturated every core and left none to service the shared event loop
serving /api/status. So cap ONLY that:
- A dedicated, bounded ThreadPoolExecutor (_vision_cpu_executor) runs the
encode/resize/dimension-check off the caller's loop, sized to the host's
usable core count with NO fixed ceiling — the cap tracks the actual
exhausted resource (cores), not a magic number. Excess encodes queue on
the executor; cores stay free for the loop.
- The LLM call is deliberately OUTSIDE the executor, so multi-image
workflows keep full request concurrency.
- Override via auxiliary.vision.max_concurrency / HERMES_VISION_MAX_CONCURRENCY
(honored verbatim, including above core count); sub-1 ignored.
- _vision_concurrency_slot() is now a no-op shim for back-compat.
Tests assert: resolver defaults to host cores with no ceiling; env/config
override (incl. above cores); sub-1 rejection; the executor is dedicated and
core-sized; encode runs on a vision-encode thread; and crucially that encode
bursts are bounded to the cap while the analyses themselves stay fully
concurrent (calls_peak > cap).
2026-06-28 22:48:37 -07:00
|
|
|
|
image_data_url = await _run_encode_on_cpu_executor(
|
|
|
|
|
|
_image_to_base64_data_url,
|
feat(vision): vision_analyze returns pixels to vision-capable models, not aux text (#22955)
When the active main model has native vision and the provider supports
multimodal tool results (Anthropic, OpenAI Chat, Codex Responses, Gemini
3, OpenRouter, Nous), vision_analyze loads the image bytes and returns
them to the model as a multimodal tool-result envelope. The model then
sees the pixels directly on its next turn instead of receiving a lossy
text description from an auxiliary LLM.
Falls back to the legacy aux-LLM text path for non-vision models and
unverified providers.
Mirrors the architecture used in OpenCode, Claude Code, Codex CLI, and
Cline. All four converge on the same pattern: tool results carry image
content blocks for vision-capable provider/model combinations.
Changes
- tools/vision_tools.py: _vision_analyze_native fast path + provider
capability table (_supports_media_in_tool_results). Schema description
updated to reflect new behaviour.
- agent/codex_responses_adapter.py: function_call_output.output now
accepts the array form for multimodal tool results (was string-only).
Preflight validates input_text/input_image parts.
- agent/auxiliary_client.py: _RUNTIME_MAIN_PROVIDER/_MODEL globals so
tools see the live CLI/gateway override, not the stale config.yaml
default. set_runtime_main()/clear_runtime_main() helpers.
- run_agent.py: AIAgent.run_conversation calls set_runtime_main at turn
start so vision_analyze's fast-path check sees the actual runtime.
- tests/conftest.py: clear runtime-main override between tests.
Tests
- tests/tools/test_vision_native_fast_path.py: provider capability
table, envelope shape, fast-path gating (vision-capable model uses
fast path; non-vision model falls through to aux).
- tests/run_agent/test_codex_multimodal_tool_result.py: list tool
content becomes function_call_output.output array; preflight
preserves arrays and drops unknown part types.
Live verified
- Opus 4.6 + Sonnet 4.6 on OpenRouter: model calls vision_analyze on a
typed filepath, gets pixels back, reads exact text from images that
no aux description could capture (font color irony, multi-line
fruit-count list, etc.).
PR replaces the closed prior efforts (#16506 shipped the inbound user-
attached path; this PR closes the gap for tool-discovered images).
2026-05-09 21:06:19 -07:00
|
|
|
|
temp_image_path, mime_type=detected_mime_type,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-31 00:12:09 -07:00
|
|
|
|
# Proactive embed cap: this image gets baked into conversation
|
|
|
|
|
|
# history and re-sent on every subsequent turn. Anthropic rejects
|
2026-06-04 05:46:54 -07:00
|
|
|
|
# any single base64 image over 5 MB OR over 8000px per side with a
|
|
|
|
|
|
# 400, and because history is immutable, an oversized embed
|
|
|
|
|
|
# permanently wedges the session — retries can't clear bytes (or
|
|
|
|
|
|
# pixels) that are already in the request. Resize DOWN to the embed
|
|
|
|
|
|
# target (4 MB / 7900px, headroom under both ceilings) whenever the
|
|
|
|
|
|
# payload exceeds either limit, not just at the 20 MB hard ceiling.
|
|
|
|
|
|
_over_bytes = len(image_data_url) > _EMBED_TARGET_BYTES
|
fix(vision): narrow the fan-out cap to the CPU encode burst only
The original cap held a process-global slot across the WHOLE vision
analysis (image load + encode + LLM call) with a default of min(CPUs, 4).
That serialized legitimate multi-image workflows — "compare these 6
screenshots", "read this 10-page scan", "analyze every frame" — behind a
4-wide gate, and on the native fast path it even throttled calls that make
no LLM request at all. Excess calls queued (blocking acquire, nothing
dropped), but the latency hit on real fan-out was the wrong tradeoff.
The incident was CPU exhaustion, not call count: concurrent base64/resize
bursts saturated every core and left none to service the shared event loop
serving /api/status. So cap ONLY that:
- A dedicated, bounded ThreadPoolExecutor (_vision_cpu_executor) runs the
encode/resize/dimension-check off the caller's loop, sized to the host's
usable core count with NO fixed ceiling — the cap tracks the actual
exhausted resource (cores), not a magic number. Excess encodes queue on
the executor; cores stay free for the loop.
- The LLM call is deliberately OUTSIDE the executor, so multi-image
workflows keep full request concurrency.
- Override via auxiliary.vision.max_concurrency / HERMES_VISION_MAX_CONCURRENCY
(honored verbatim, including above core count); sub-1 ignored.
- _vision_concurrency_slot() is now a no-op shim for back-compat.
Tests assert: resolver defaults to host cores with no ceiling; env/config
override (incl. above cores); sub-1 rejection; the executor is dedicated and
core-sized; encode runs on a vision-encode thread; and crucially that encode
bursts are bounded to the cap while the analyses themselves stay fully
concurrent (calls_peak > cap).
2026-06-28 22:48:37 -07:00
|
|
|
|
_over_dims = await _run_encode_on_cpu_executor(
|
|
|
|
|
|
_image_exceeds_dimension, temp_image_path, _EMBED_MAX_DIMENSION,
|
|
|
|
|
|
)
|
2026-06-04 05:46:54 -07:00
|
|
|
|
if _over_bytes or _over_dims:
|
fix(vision): narrow the fan-out cap to the CPU encode burst only
The original cap held a process-global slot across the WHOLE vision
analysis (image load + encode + LLM call) with a default of min(CPUs, 4).
That serialized legitimate multi-image workflows — "compare these 6
screenshots", "read this 10-page scan", "analyze every frame" — behind a
4-wide gate, and on the native fast path it even throttled calls that make
no LLM request at all. Excess calls queued (blocking acquire, nothing
dropped), but the latency hit on real fan-out was the wrong tradeoff.
The incident was CPU exhaustion, not call count: concurrent base64/resize
bursts saturated every core and left none to service the shared event loop
serving /api/status. So cap ONLY that:
- A dedicated, bounded ThreadPoolExecutor (_vision_cpu_executor) runs the
encode/resize/dimension-check off the caller's loop, sized to the host's
usable core count with NO fixed ceiling — the cap tracks the actual
exhausted resource (cores), not a magic number. Excess encodes queue on
the executor; cores stay free for the loop.
- The LLM call is deliberately OUTSIDE the executor, so multi-image
workflows keep full request concurrency.
- Override via auxiliary.vision.max_concurrency / HERMES_VISION_MAX_CONCURRENCY
(honored verbatim, including above core count); sub-1 ignored.
- _vision_concurrency_slot() is now a no-op shim for back-compat.
Tests assert: resolver defaults to host cores with no ceiling; env/config
override (incl. above cores); sub-1 rejection; the executor is dedicated and
core-sized; encode runs on a vision-encode thread; and crucially that encode
bursts are bounded to the cap while the analyses themselves stay fully
concurrent (calls_peak > cap).
2026-06-28 22:48:37 -07:00
|
|
|
|
image_data_url = await _run_encode_on_cpu_executor(
|
|
|
|
|
|
_resize_image_for_vision,
|
feat(vision): vision_analyze returns pixels to vision-capable models, not aux text (#22955)
When the active main model has native vision and the provider supports
multimodal tool results (Anthropic, OpenAI Chat, Codex Responses, Gemini
3, OpenRouter, Nous), vision_analyze loads the image bytes and returns
them to the model as a multimodal tool-result envelope. The model then
sees the pixels directly on its next turn instead of receiving a lossy
text description from an auxiliary LLM.
Falls back to the legacy aux-LLM text path for non-vision models and
unverified providers.
Mirrors the architecture used in OpenCode, Claude Code, Codex CLI, and
Cline. All four converge on the same pattern: tool results carry image
content blocks for vision-capable provider/model combinations.
Changes
- tools/vision_tools.py: _vision_analyze_native fast path + provider
capability table (_supports_media_in_tool_results). Schema description
updated to reflect new behaviour.
- agent/codex_responses_adapter.py: function_call_output.output now
accepts the array form for multimodal tool results (was string-only).
Preflight validates input_text/input_image parts.
- agent/auxiliary_client.py: _RUNTIME_MAIN_PROVIDER/_MODEL globals so
tools see the live CLI/gateway override, not the stale config.yaml
default. set_runtime_main()/clear_runtime_main() helpers.
- run_agent.py: AIAgent.run_conversation calls set_runtime_main at turn
start so vision_analyze's fast-path check sees the actual runtime.
- tests/conftest.py: clear runtime-main override between tests.
Tests
- tests/tools/test_vision_native_fast_path.py: provider capability
table, envelope shape, fast-path gating (vision-capable model uses
fast path; non-vision model falls through to aux).
- tests/run_agent/test_codex_multimodal_tool_result.py: list tool
content becomes function_call_output.output array; preflight
preserves arrays and drops unknown part types.
Live verified
- Opus 4.6 + Sonnet 4.6 on OpenRouter: model calls vision_analyze on a
typed filepath, gets pixels back, reads exact text from images that
no aux description could capture (font color irony, multi-line
fruit-count list, etc.).
PR replaces the closed prior efforts (#16506 shipped the inbound user-
attached path; this PR closes the gap for tool-discovered images).
2026-05-09 21:06:19 -07:00
|
|
|
|
temp_image_path, mime_type=detected_mime_type,
|
2026-05-31 00:12:09 -07:00
|
|
|
|
max_base64_bytes=_EMBED_TARGET_BYTES,
|
2026-06-04 05:46:54 -07:00
|
|
|
|
max_dimension=_EMBED_MAX_DIMENSION,
|
feat(vision): vision_analyze returns pixels to vision-capable models, not aux text (#22955)
When the active main model has native vision and the provider supports
multimodal tool results (Anthropic, OpenAI Chat, Codex Responses, Gemini
3, OpenRouter, Nous), vision_analyze loads the image bytes and returns
them to the model as a multimodal tool-result envelope. The model then
sees the pixels directly on its next turn instead of receiving a lossy
text description from an auxiliary LLM.
Falls back to the legacy aux-LLM text path for non-vision models and
unverified providers.
Mirrors the architecture used in OpenCode, Claude Code, Codex CLI, and
Cline. All four converge on the same pattern: tool results carry image
content blocks for vision-capable provider/model combinations.
Changes
- tools/vision_tools.py: _vision_analyze_native fast path + provider
capability table (_supports_media_in_tool_results). Schema description
updated to reflect new behaviour.
- agent/codex_responses_adapter.py: function_call_output.output now
accepts the array form for multimodal tool results (was string-only).
Preflight validates input_text/input_image parts.
- agent/auxiliary_client.py: _RUNTIME_MAIN_PROVIDER/_MODEL globals so
tools see the live CLI/gateway override, not the stale config.yaml
default. set_runtime_main()/clear_runtime_main() helpers.
- run_agent.py: AIAgent.run_conversation calls set_runtime_main at turn
start so vision_analyze's fast-path check sees the actual runtime.
- tests/conftest.py: clear runtime-main override between tests.
Tests
- tests/tools/test_vision_native_fast_path.py: provider capability
table, envelope shape, fast-path gating (vision-capable model uses
fast path; non-vision model falls through to aux).
- tests/run_agent/test_codex_multimodal_tool_result.py: list tool
content becomes function_call_output.output array; preflight
preserves arrays and drops unknown part types.
Live verified
- Opus 4.6 + Sonnet 4.6 on OpenRouter: model calls vision_analyze on a
typed filepath, gets pixels back, reads exact text from images that
no aux description could capture (font color irony, multi-line
fruit-count list, etc.).
PR replaces the closed prior efforts (#16506 shipped the inbound user-
attached path; this PR closes the gap for tool-discovered images).
2026-05-09 21:06:19 -07:00
|
|
|
|
)
|
2026-05-31 00:12:09 -07:00
|
|
|
|
# If even resizing can't get under the absolute hard ceiling,
|
|
|
|
|
|
# there's nothing more we can do — reject rather than embed a
|
|
|
|
|
|
# session-wedging payload.
|
feat(vision): vision_analyze returns pixels to vision-capable models, not aux text (#22955)
When the active main model has native vision and the provider supports
multimodal tool results (Anthropic, OpenAI Chat, Codex Responses, Gemini
3, OpenRouter, Nous), vision_analyze loads the image bytes and returns
them to the model as a multimodal tool-result envelope. The model then
sees the pixels directly on its next turn instead of receiving a lossy
text description from an auxiliary LLM.
Falls back to the legacy aux-LLM text path for non-vision models and
unverified providers.
Mirrors the architecture used in OpenCode, Claude Code, Codex CLI, and
Cline. All four converge on the same pattern: tool results carry image
content blocks for vision-capable provider/model combinations.
Changes
- tools/vision_tools.py: _vision_analyze_native fast path + provider
capability table (_supports_media_in_tool_results). Schema description
updated to reflect new behaviour.
- agent/codex_responses_adapter.py: function_call_output.output now
accepts the array form for multimodal tool results (was string-only).
Preflight validates input_text/input_image parts.
- agent/auxiliary_client.py: _RUNTIME_MAIN_PROVIDER/_MODEL globals so
tools see the live CLI/gateway override, not the stale config.yaml
default. set_runtime_main()/clear_runtime_main() helpers.
- run_agent.py: AIAgent.run_conversation calls set_runtime_main at turn
start so vision_analyze's fast-path check sees the actual runtime.
- tests/conftest.py: clear runtime-main override between tests.
Tests
- tests/tools/test_vision_native_fast_path.py: provider capability
table, envelope shape, fast-path gating (vision-capable model uses
fast path; non-vision model falls through to aux).
- tests/run_agent/test_codex_multimodal_tool_result.py: list tool
content becomes function_call_output.output array; preflight
preserves arrays and drops unknown part types.
Live verified
- Opus 4.6 + Sonnet 4.6 on OpenRouter: model calls vision_analyze on a
typed filepath, gets pixels back, reads exact text from images that
no aux description could capture (font color irony, multi-line
fruit-count list, etc.).
PR replaces the closed prior efforts (#16506 shipped the inbound user-
attached path; this PR closes the gap for tool-discovered images).
2026-05-09 21:06:19 -07:00
|
|
|
|
if len(image_data_url) > _MAX_BASE64_BYTES:
|
|
|
|
|
|
return tool_error(
|
|
|
|
|
|
f"Image too large for vision API: base64 payload is "
|
|
|
|
|
|
f"{len(image_data_url) / (1024 * 1024):.1f} MB "
|
|
|
|
|
|
f"(limit {_MAX_BASE64_BYTES / (1024 * 1024):.0f} MB) "
|
|
|
|
|
|
f"even after resizing. Install Pillow "
|
|
|
|
|
|
f"(`pip install Pillow`) for better auto-resize, "
|
|
|
|
|
|
f"or compress the image manually.",
|
|
|
|
|
|
success=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return _build_native_vision_tool_result(
|
|
|
|
|
|
image_url=image_url,
|
|
|
|
|
|
question=question,
|
|
|
|
|
|
image_data_url=image_data_url,
|
|
|
|
|
|
image_size_bytes=image_size_bytes,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning("Native vision fast path failed: %s", exc)
|
|
|
|
|
|
return tool_error(f"Native vision failed: {exc}", success=False)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
# Only delete temp files we created — never user-provided paths.
|
|
|
|
|
|
if should_cleanup and temp_image_path is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
if temp_image_path.exists():
|
|
|
|
|
|
temp_image_path.unlink()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-10-01 23:29:25 +00:00
|
|
|
|
async def vision_analyze_tool(
|
|
|
|
|
|
image_url: str,
|
|
|
|
|
|
user_prompt: str,
|
2026-03-11 20:52:19 -07:00
|
|
|
|
model: str = None,
|
2025-10-01 23:29:25 +00:00
|
|
|
|
) -> str:
|
|
|
|
|
|
"""
|
2026-02-15 16:10:50 -08:00
|
|
|
|
Analyze an image from a URL or local file path using vision AI.
|
2025-10-01 23:29:25 +00:00
|
|
|
|
|
2026-02-15 16:10:50 -08:00
|
|
|
|
This tool accepts either an HTTP/HTTPS URL or a local file path. For URLs,
|
|
|
|
|
|
it downloads the image first. In both cases, the image is converted to base64
|
|
|
|
|
|
and processed using Gemini 3 Flash Preview via OpenRouter API.
|
2025-10-08 02:38:04 +00:00
|
|
|
|
|
2025-10-01 23:29:25 +00:00
|
|
|
|
The user_prompt parameter is expected to be pre-formatted by the calling
|
|
|
|
|
|
function (typically model_tools.py) to include both full description
|
|
|
|
|
|
requests and specific questions.
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
2026-02-15 16:10:50 -08:00
|
|
|
|
image_url (str): The URL or local file path of the image to analyze.
|
|
|
|
|
|
Accepts http://, https:// URLs or absolute/relative file paths.
|
2025-10-01 23:29:25 +00:00
|
|
|
|
user_prompt (str): The pre-formatted prompt for the vision model
|
2026-01-14 13:40:10 +00:00
|
|
|
|
model (str): The vision model to use (default: google/gemini-3-flash-preview)
|
2025-10-01 23:29:25 +00:00
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
str: JSON string containing the analysis results with the following structure:
|
|
|
|
|
|
{
|
|
|
|
|
|
"success": bool,
|
|
|
|
|
|
"analysis": str (defaults to error message if None)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Raises:
|
2025-10-08 02:38:04 +00:00
|
|
|
|
Exception: If download fails, analysis fails, or API key is not set
|
|
|
|
|
|
|
|
|
|
|
|
Note:
|
2026-04-29 20:14:02 -07:00
|
|
|
|
- For URLs, temporary images are stored under $HERMES_HOME/cache/vision/ and cleaned up
|
2026-02-15 16:10:50 -08:00
|
|
|
|
- For local file paths, the file is used directly and NOT deleted
|
2025-10-08 02:38:04 +00:00
|
|
|
|
- Supports common image formats (JPEG, PNG, GIF, WebP, etc.)
|
2025-10-01 23:29:25 +00:00
|
|
|
|
"""
|
2026-05-04 00:32:53 +03:00
|
|
|
|
if not isinstance(user_prompt, str):
|
|
|
|
|
|
user_prompt = str(user_prompt) if user_prompt is not None else ""
|
2025-10-01 23:29:25 +00:00
|
|
|
|
debug_call_data = {
|
|
|
|
|
|
"parameters": {
|
|
|
|
|
|
"image_url": image_url,
|
2025-10-15 18:07:06 +00:00
|
|
|
|
"user_prompt": user_prompt[:200] + "..." if len(user_prompt) > 200 else user_prompt,
|
2025-10-01 23:29:25 +00:00
|
|
|
|
"model": model
|
|
|
|
|
|
},
|
|
|
|
|
|
"error": None,
|
|
|
|
|
|
"success": False,
|
|
|
|
|
|
"analysis_length": 0,
|
2025-10-15 18:07:06 +00:00
|
|
|
|
"model_used": model,
|
|
|
|
|
|
"image_size_bytes": 0
|
2025-10-01 23:29:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-08 02:38:04 +00:00
|
|
|
|
temp_image_path = None
|
2026-02-15 16:10:50 -08:00
|
|
|
|
# Track whether we should clean up the file after processing.
|
|
|
|
|
|
# Local files (e.g. from the image cache) should NOT be deleted.
|
|
|
|
|
|
should_cleanup = True
|
2026-03-29 20:55:04 -07:00
|
|
|
|
detected_mime_type = None
|
2025-10-08 02:38:04 +00:00
|
|
|
|
|
2025-10-01 23:29:25 +00:00
|
|
|
|
try:
|
2026-02-23 02:11:33 -08:00
|
|
|
|
from tools.interrupt import is_interrupted
|
|
|
|
|
|
if is_interrupted():
|
refactor: add tool_error/tool_result helpers + read_raw_config, migrate 129 callsites
Add three reusable helpers to eliminate pervasive boilerplate:
tools/registry.py — tool_error() and tool_result():
Every tool handler returns JSON strings. The pattern
json.dumps({"error": msg}, ensure_ascii=False) appeared 106 times,
and json.dumps({"success": False, "error": msg}, ...) another 23.
Now: tool_error(msg) or tool_error(msg, success=False).
tool_result() handles arbitrary result dicts:
tool_result(success=True, data=payload) or tool_result(some_dict).
hermes_cli/config.py — read_raw_config():
Lightweight YAML reader that returns the raw config dict without
load_config()'s deep-merge + migration overhead. Available for
callsites that just need a single config value.
Migration (129 callsites across 32 files):
- tools/: browser_camofox (18), file_tools (10), homeassistant (8),
web_tools (7), skill_manager (7), cronjob (11), code_execution (4),
delegate (5), send_message (4), tts (4), memory (7), session_search (3),
mcp (2), clarify (2), skills_tool (3), todo (1), vision (1),
browser (1), process_registry (2), image_gen (1)
- plugins/memory/: honcho (9), supermemory (9), hindsight (8),
holographic (7), openviking (7), mem0 (7), byterover (6), retaindb (2)
- agent/: memory_manager (2), builtin_memory_provider (1)
2026-04-07 13:36:20 -07:00
|
|
|
|
return tool_error("Interrupted", success=False)
|
2026-02-23 02:11:33 -08:00
|
|
|
|
|
2026-02-21 03:11:11 -08:00
|
|
|
|
logger.info("Analyzing image: %s", image_url[:60])
|
|
|
|
|
|
logger.info("User prompt: %s", user_prompt[:100])
|
2025-10-01 23:29:25 +00:00
|
|
|
|
|
2026-02-15 16:10:50 -08:00
|
|
|
|
# Determine if this is a local file path or a remote URL
|
2026-04-10 15:11:14 +10:00
|
|
|
|
# Strip file:// scheme so file URIs resolve as local paths.
|
|
|
|
|
|
resolved_url = image_url
|
|
|
|
|
|
if resolved_url.startswith("file://"):
|
|
|
|
|
|
resolved_url = resolved_url[len("file://"):]
|
|
|
|
|
|
local_path = Path(os.path.expanduser(resolved_url))
|
2026-02-15 16:10:50 -08:00
|
|
|
|
if local_path.is_file():
|
|
|
|
|
|
# Local file path (e.g. from platform image cache) -- skip download
|
2026-02-21 03:11:11 -08:00
|
|
|
|
logger.info("Using local image file: %s", image_url)
|
2026-02-15 16:10:50 -08:00
|
|
|
|
temp_image_path = local_path
|
|
|
|
|
|
should_cleanup = False # Don't delete cached/local files
|
2026-06-04 05:57:11 -07:00
|
|
|
|
elif await _validate_image_url_async(image_url):
|
2026-02-15 16:10:50 -08:00
|
|
|
|
# Remote URL -- download to a temporary location
|
2026-03-29 20:55:04 -07:00
|
|
|
|
blocked = check_website_access(image_url)
|
|
|
|
|
|
if blocked:
|
|
|
|
|
|
raise PermissionError(blocked["message"])
|
2026-02-21 03:11:11 -08:00
|
|
|
|
logger.info("Downloading image from URL...")
|
2026-04-29 20:14:02 -07:00
|
|
|
|
temp_dir = get_hermes_dir("cache/vision", "temp_vision_images")
|
2026-02-15 16:10:50 -08:00
|
|
|
|
temp_image_path = temp_dir / f"temp_image_{uuid.uuid4()}.jpg"
|
|
|
|
|
|
await _download_image(image_url, temp_image_path)
|
|
|
|
|
|
should_cleanup = True
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"Invalid image source. Provide an HTTP/HTTPS URL or a valid local file path."
|
|
|
|
|
|
)
|
2025-10-15 18:07:06 +00:00
|
|
|
|
|
|
|
|
|
|
# Get image file size for logging
|
|
|
|
|
|
image_size_bytes = temp_image_path.stat().st_size
|
|
|
|
|
|
image_size_kb = image_size_bytes / 1024
|
2026-02-21 03:11:11 -08:00
|
|
|
|
logger.info("Image ready (%.1f KB)", image_size_kb)
|
2026-03-29 20:55:04 -07:00
|
|
|
|
|
|
|
|
|
|
detected_mime_type = _detect_image_mime_type(temp_image_path)
|
|
|
|
|
|
if not detected_mime_type:
|
|
|
|
|
|
raise ValueError("Only real image files are supported for vision analysis.")
|
2025-10-08 02:38:04 +00:00
|
|
|
|
|
2026-04-11 11:07:18 -07:00
|
|
|
|
# Convert image to base64 — send at full resolution first.
|
|
|
|
|
|
# If the provider rejects it as too large, we auto-resize and retry.
|
fix(vision): narrow the fan-out cap to the CPU encode burst only
The original cap held a process-global slot across the WHOLE vision
analysis (image load + encode + LLM call) with a default of min(CPUs, 4).
That serialized legitimate multi-image workflows — "compare these 6
screenshots", "read this 10-page scan", "analyze every frame" — behind a
4-wide gate, and on the native fast path it even throttled calls that make
no LLM request at all. Excess calls queued (blocking acquire, nothing
dropped), but the latency hit on real fan-out was the wrong tradeoff.
The incident was CPU exhaustion, not call count: concurrent base64/resize
bursts saturated every core and left none to service the shared event loop
serving /api/status. So cap ONLY that:
- A dedicated, bounded ThreadPoolExecutor (_vision_cpu_executor) runs the
encode/resize/dimension-check off the caller's loop, sized to the host's
usable core count with NO fixed ceiling — the cap tracks the actual
exhausted resource (cores), not a magic number. Excess encodes queue on
the executor; cores stay free for the loop.
- The LLM call is deliberately OUTSIDE the executor, so multi-image
workflows keep full request concurrency.
- Override via auxiliary.vision.max_concurrency / HERMES_VISION_MAX_CONCURRENCY
(honored verbatim, including above core count); sub-1 ignored.
- _vision_concurrency_slot() is now a no-op shim for back-compat.
Tests assert: resolver defaults to host cores with no ceiling; env/config
override (incl. above cores); sub-1 rejection; the executor is dedicated and
core-sized; encode runs on a vision-encode thread; and crucially that encode
bursts are bounded to the cap while the analyses themselves stay fully
concurrent (calls_peak > cap).
2026-06-28 22:48:37 -07:00
|
|
|
|
# Offloaded to the bounded vision CPU executor so a fan-out of encodes
|
|
|
|
|
|
# can't saturate every core and starve the event loop.
|
2026-02-21 03:11:11 -08:00
|
|
|
|
logger.info("Converting image to base64...")
|
fix(vision): narrow the fan-out cap to the CPU encode burst only
The original cap held a process-global slot across the WHOLE vision
analysis (image load + encode + LLM call) with a default of min(CPUs, 4).
That serialized legitimate multi-image workflows — "compare these 6
screenshots", "read this 10-page scan", "analyze every frame" — behind a
4-wide gate, and on the native fast path it even throttled calls that make
no LLM request at all. Excess calls queued (blocking acquire, nothing
dropped), but the latency hit on real fan-out was the wrong tradeoff.
The incident was CPU exhaustion, not call count: concurrent base64/resize
bursts saturated every core and left none to service the shared event loop
serving /api/status. So cap ONLY that:
- A dedicated, bounded ThreadPoolExecutor (_vision_cpu_executor) runs the
encode/resize/dimension-check off the caller's loop, sized to the host's
usable core count with NO fixed ceiling — the cap tracks the actual
exhausted resource (cores), not a magic number. Excess encodes queue on
the executor; cores stay free for the loop.
- The LLM call is deliberately OUTSIDE the executor, so multi-image
workflows keep full request concurrency.
- Override via auxiliary.vision.max_concurrency / HERMES_VISION_MAX_CONCURRENCY
(honored verbatim, including above core count); sub-1 ignored.
- _vision_concurrency_slot() is now a no-op shim for back-compat.
Tests assert: resolver defaults to host cores with no ceiling; env/config
override (incl. above cores); sub-1 rejection; the executor is dedicated and
core-sized; encode runs on a vision-encode thread; and crucially that encode
bursts are bounded to the cap while the analyses themselves stay fully
concurrent (calls_peak > cap).
2026-06-28 22:48:37 -07:00
|
|
|
|
image_data_url = await _run_encode_on_cpu_executor(
|
|
|
|
|
|
_image_to_base64_data_url, temp_image_path, mime_type=detected_mime_type)
|
2025-10-15 18:07:06 +00:00
|
|
|
|
data_size_kb = len(image_data_url) / 1024
|
2026-02-21 03:11:11 -08:00
|
|
|
|
logger.info("Image converted to base64 (%.1f KB)", data_size_kb)
|
2026-04-10 15:11:14 +10:00
|
|
|
|
|
2026-04-11 11:07:18 -07:00
|
|
|
|
# Hard limit (20 MB) — no provider accepts payloads this large.
|
2026-04-10 15:11:14 +10:00
|
|
|
|
if len(image_data_url) > _MAX_BASE64_BYTES:
|
2026-04-11 11:07:18 -07:00
|
|
|
|
# Try to resize down to 5 MB before giving up.
|
fix(vision): narrow the fan-out cap to the CPU encode burst only
The original cap held a process-global slot across the WHOLE vision
analysis (image load + encode + LLM call) with a default of min(CPUs, 4).
That serialized legitimate multi-image workflows — "compare these 6
screenshots", "read this 10-page scan", "analyze every frame" — behind a
4-wide gate, and on the native fast path it even throttled calls that make
no LLM request at all. Excess calls queued (blocking acquire, nothing
dropped), but the latency hit on real fan-out was the wrong tradeoff.
The incident was CPU exhaustion, not call count: concurrent base64/resize
bursts saturated every core and left none to service the shared event loop
serving /api/status. So cap ONLY that:
- A dedicated, bounded ThreadPoolExecutor (_vision_cpu_executor) runs the
encode/resize/dimension-check off the caller's loop, sized to the host's
usable core count with NO fixed ceiling — the cap tracks the actual
exhausted resource (cores), not a magic number. Excess encodes queue on
the executor; cores stay free for the loop.
- The LLM call is deliberately OUTSIDE the executor, so multi-image
workflows keep full request concurrency.
- Override via auxiliary.vision.max_concurrency / HERMES_VISION_MAX_CONCURRENCY
(honored verbatim, including above core count); sub-1 ignored.
- _vision_concurrency_slot() is now a no-op shim for back-compat.
Tests assert: resolver defaults to host cores with no ceiling; env/config
override (incl. above cores); sub-1 rejection; the executor is dedicated and
core-sized; encode runs on a vision-encode thread; and crucially that encode
bursts are bounded to the cap while the analyses themselves stay fully
concurrent (calls_peak > cap).
2026-06-28 22:48:37 -07:00
|
|
|
|
image_data_url = await _run_encode_on_cpu_executor(
|
|
|
|
|
|
_resize_image_for_vision,
|
2026-04-11 11:07:18 -07:00
|
|
|
|
temp_image_path, mime_type=detected_mime_type)
|
|
|
|
|
|
if len(image_data_url) > _MAX_BASE64_BYTES:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"Image too large for vision API: base64 payload is "
|
|
|
|
|
|
f"{len(image_data_url) / (1024 * 1024):.1f} MB "
|
|
|
|
|
|
f"(limit {_MAX_BASE64_BYTES / (1024 * 1024):.0f} MB) "
|
|
|
|
|
|
f"even after resizing. "
|
|
|
|
|
|
f"Install Pillow (`pip install Pillow`) for better auto-resize, "
|
|
|
|
|
|
f"or compress the image manually."
|
|
|
|
|
|
)
|
2026-04-10 15:11:14 +10:00
|
|
|
|
|
2025-10-15 18:07:06 +00:00
|
|
|
|
debug_call_data["image_size_bytes"] = image_size_bytes
|
2025-10-08 02:38:04 +00:00
|
|
|
|
|
2025-10-01 23:29:25 +00:00
|
|
|
|
# Use the prompt as provided (model_tools.py now handles full description formatting)
|
|
|
|
|
|
comprehensive_prompt = user_prompt
|
|
|
|
|
|
|
2025-10-08 02:38:04 +00:00
|
|
|
|
# Prepare the message with base64-encoded image
|
2025-10-01 23:29:25 +00:00
|
|
|
|
messages = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"role": "user",
|
|
|
|
|
|
"content": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "text",
|
|
|
|
|
|
"text": comprehensive_prompt
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "image_url",
|
|
|
|
|
|
"image_url": {
|
2025-10-08 02:38:04 +00:00
|
|
|
|
"url": image_data_url
|
2025-10-01 23:29:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-03-11 20:52:19 -07:00
|
|
|
|
logger.info("Processing image with vision model...")
|
2025-10-01 23:29:25 +00:00
|
|
|
|
|
2026-03-22 05:28:24 -07:00
|
|
|
|
# Call the vision API via centralized router.
|
fix: browser_vision ignores auxiliary.vision.timeout config (#2901)
* docs: unify hooks documentation — add plugin hooks to hooks page, add session:end event
The hooks page only documented gateway event hooks (HOOK.yaml system).
The plugins page listed plugin hooks (pre_tool_call, etc.) that weren't
referenced from the hooks page, which was confusing.
Changes:
- hooks.md: Add overview table showing both hook systems
- hooks.md: Add Plugin Hooks section with available hooks, callback
signatures, and example
- hooks.md: Add missing session:end gateway event (emitted but undocumented)
- hooks.md: Mark pre_llm_call, post_llm_call, on_session_start,
on_session_end as planned (defined in VALID_HOOKS but not yet invoked)
- hooks.md: Update info box to cross-reference plugin hooks
- hooks.md: Fix heading hierarchy (gateway content as subsections)
- plugins.md: Add cross-reference to hooks page for full details
- plugins.md: Mark planned hooks as (planned)
* fix: browser_vision ignores auxiliary.vision.timeout config
browser_vision called call_llm() without passing a timeout parameter,
so it always used the 30-second default in auxiliary_client.py. This
made vision analysis with local models (llama.cpp, ollama) impossible
since they typically need more than 30s for screenshot analysis.
Now browser_vision reads auxiliary.vision.timeout from config.yaml
(same config key that vision_analyze already uses) and passes it
through to call_llm().
Also bumped the default vision timeout from 30s to 120s in both
browser_vision and vision_analyze — 30s is too aggressive for local
models and the previous default silently failed for anyone running
vision locally.
Fixes user report from GamerGB1988.
2026-03-24 19:10:12 -07:00
|
|
|
|
# Read timeout from config.yaml (auxiliary.vision.timeout), default 120s.
|
|
|
|
|
|
# Local vision models (llama.cpp, ollama) can take well over 30s.
|
|
|
|
|
|
vision_timeout = 120.0
|
2026-04-04 11:14:53 +05:30
|
|
|
|
vision_temperature = 0.1
|
2026-03-22 05:28:24 -07:00
|
|
|
|
try:
|
refactor(config): add cfg_get() helper; migrate 20 nested-get call sites (#17304)
The "cfg.get('X', {}).get('Y', default)" pattern appears 50+ times
across tools/, gateway/, and plugins/. Each call site manually handles
the same three gotchas:
1. Missing intermediate key → empty dict → chain works
2. Non-dict value at intermediate position → AttributeError
(uncaught in most sites, so a misconfigured YAML crashes the tool)
3. cfg is None → AttributeError
Introduces cfg_get(cfg, *keys, default=None) in hermes_cli/config.py
as the canonical helper. Handles all three uniformly, returns default
only when the final key is *absent* (matches dict.get semantics —
explicit None values are preserved, falsy values like 0 / False / ''
are preserved).
Named cfg_get rather than cfg_path to avoid shadowing the existing
'cfg_path = _hermes_home / "config.yaml"' local variable that appears
in gateway/run.py, cron/scheduler.py, hermes_cli/main.py, etc.
Migrated 20 call sites as the first-batch proof-of-value:
gateway/run.py 10 sites (agent/display subtrees)
tools/browser_tool.py 3 sites
tools/vision_tools.py 2 sites
tools/browser_camofox.py 1 site
tools/approval.py 1 site
tools/skills_tool.py 1 site
tools/skill_manager_tool.py 1 site
tools/credential_files.py 1 site
tools/env_passthrough.py 1 site
The remaining ~30 sites across plugins/ and smaller tool files can be
migrated opportunistically — the helper is now available and the
pattern is established.
Fixed a latent bug along the way: tools/vision_tools.py had its
cfg_get usage at line 560 inside a function that locally re-imports
'from hermes_cli.config import load_config', but the AST-based
migration script wrote the top-level cfg_get import to a different
function scope, leaving line 560's cfg_get as a NameError silently
swallowed by the surrounding try/except. Test
test_vision_uses_configured_temperature_and_timeout caught it. Fixed
by including cfg_get in the function-local import.
Verified:
- 7880/7893 tests/tools/ + tests/gateway/ + tests/hermes_cli/test_config
tests pass; all 13 failures pre-existing on main (MCP, delegate,
session_split_brain — verified earlier in the sweep).
- All 20 migrated sites AST-verified to have cfg_get in scope (either
module-level or function-local).
- Live 'hermes chat' smoke: 2 turns + /model switch + tool calls +
/quit, zero errors. Agent correctly counted 20 cfg_get hits across
8 tool files — matching the migration.
Semantic parity verified against the original pattern across 8 edge
cases (missing keys, None values, falsy values, empty strings, string
instead of dict, None cfg, nested levels).
2026-04-28 23:17:39 -07:00
|
|
|
|
from hermes_cli.config import cfg_get, load_config
|
2026-03-22 05:28:24 -07:00
|
|
|
|
_cfg = load_config()
|
refactor(config): add cfg_get() helper; migrate 20 nested-get call sites (#17304)
The "cfg.get('X', {}).get('Y', default)" pattern appears 50+ times
across tools/, gateway/, and plugins/. Each call site manually handles
the same three gotchas:
1. Missing intermediate key → empty dict → chain works
2. Non-dict value at intermediate position → AttributeError
(uncaught in most sites, so a misconfigured YAML crashes the tool)
3. cfg is None → AttributeError
Introduces cfg_get(cfg, *keys, default=None) in hermes_cli/config.py
as the canonical helper. Handles all three uniformly, returns default
only when the final key is *absent* (matches dict.get semantics —
explicit None values are preserved, falsy values like 0 / False / ''
are preserved).
Named cfg_get rather than cfg_path to avoid shadowing the existing
'cfg_path = _hermes_home / "config.yaml"' local variable that appears
in gateway/run.py, cron/scheduler.py, hermes_cli/main.py, etc.
Migrated 20 call sites as the first-batch proof-of-value:
gateway/run.py 10 sites (agent/display subtrees)
tools/browser_tool.py 3 sites
tools/vision_tools.py 2 sites
tools/browser_camofox.py 1 site
tools/approval.py 1 site
tools/skills_tool.py 1 site
tools/skill_manager_tool.py 1 site
tools/credential_files.py 1 site
tools/env_passthrough.py 1 site
The remaining ~30 sites across plugins/ and smaller tool files can be
migrated opportunistically — the helper is now available and the
pattern is established.
Fixed a latent bug along the way: tools/vision_tools.py had its
cfg_get usage at line 560 inside a function that locally re-imports
'from hermes_cli.config import load_config', but the AST-based
migration script wrote the top-level cfg_get import to a different
function scope, leaving line 560's cfg_get as a NameError silently
swallowed by the surrounding try/except. Test
test_vision_uses_configured_temperature_and_timeout caught it. Fixed
by including cfg_get in the function-local import.
Verified:
- 7880/7893 tests/tools/ + tests/gateway/ + tests/hermes_cli/test_config
tests pass; all 13 failures pre-existing on main (MCP, delegate,
session_split_brain — verified earlier in the sweep).
- All 20 migrated sites AST-verified to have cfg_get in scope (either
module-level or function-local).
- Live 'hermes chat' smoke: 2 turns + /model switch + tool calls +
/quit, zero errors. Agent correctly counted 20 cfg_get hits across
8 tool files — matching the migration.
Semantic parity verified against the original pattern across 8 edge
cases (missing keys, None values, falsy values, empty strings, string
instead of dict, None cfg, nested levels).
2026-04-28 23:17:39 -07:00
|
|
|
|
_vision_cfg = cfg_get(_cfg, "auxiliary", "vision", default={})
|
2026-04-04 11:14:53 +05:30
|
|
|
|
_vt = _vision_cfg.get("timeout")
|
2026-03-22 05:28:24 -07:00
|
|
|
|
if _vt is not None:
|
|
|
|
|
|
vision_timeout = float(_vt)
|
2026-04-04 11:14:53 +05:30
|
|
|
|
_vtemp = _vision_cfg.get("temperature")
|
|
|
|
|
|
if _vtemp is not None:
|
|
|
|
|
|
vision_temperature = float(_vtemp)
|
2026-03-22 05:28:24 -07:00
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2026-03-11 20:52:19 -07:00
|
|
|
|
call_kwargs = {
|
|
|
|
|
|
"task": "vision",
|
|
|
|
|
|
"messages": messages,
|
2026-04-04 11:14:53 +05:30
|
|
|
|
"temperature": vision_temperature,
|
2026-03-11 20:52:19 -07:00
|
|
|
|
"max_tokens": 2000,
|
2026-03-22 05:28:24 -07:00
|
|
|
|
"timeout": vision_timeout,
|
2026-03-11 20:52:19 -07:00
|
|
|
|
}
|
|
|
|
|
|
if model:
|
|
|
|
|
|
call_kwargs["model"] = model
|
2026-04-11 11:07:18 -07:00
|
|
|
|
# Try full-size image first; on size-related rejection, downscale and retry.
|
|
|
|
|
|
try:
|
|
|
|
|
|
response = await async_call_llm(**call_kwargs)
|
|
|
|
|
|
except Exception as _api_err:
|
|
|
|
|
|
if (_is_image_size_error(_api_err)
|
|
|
|
|
|
and len(image_data_url) > _RESIZE_TARGET_BYTES):
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"API rejected image (%.1f MB, likely too large); "
|
|
|
|
|
|
"auto-resizing to ~%.0f MB and retrying...",
|
|
|
|
|
|
len(image_data_url) / (1024 * 1024),
|
|
|
|
|
|
_RESIZE_TARGET_BYTES / (1024 * 1024),
|
|
|
|
|
|
)
|
fix(vision): narrow the fan-out cap to the CPU encode burst only
The original cap held a process-global slot across the WHOLE vision
analysis (image load + encode + LLM call) with a default of min(CPUs, 4).
That serialized legitimate multi-image workflows — "compare these 6
screenshots", "read this 10-page scan", "analyze every frame" — behind a
4-wide gate, and on the native fast path it even throttled calls that make
no LLM request at all. Excess calls queued (blocking acquire, nothing
dropped), but the latency hit on real fan-out was the wrong tradeoff.
The incident was CPU exhaustion, not call count: concurrent base64/resize
bursts saturated every core and left none to service the shared event loop
serving /api/status. So cap ONLY that:
- A dedicated, bounded ThreadPoolExecutor (_vision_cpu_executor) runs the
encode/resize/dimension-check off the caller's loop, sized to the host's
usable core count with NO fixed ceiling — the cap tracks the actual
exhausted resource (cores), not a magic number. Excess encodes queue on
the executor; cores stay free for the loop.
- The LLM call is deliberately OUTSIDE the executor, so multi-image
workflows keep full request concurrency.
- Override via auxiliary.vision.max_concurrency / HERMES_VISION_MAX_CONCURRENCY
(honored verbatim, including above core count); sub-1 ignored.
- _vision_concurrency_slot() is now a no-op shim for back-compat.
Tests assert: resolver defaults to host cores with no ceiling; env/config
override (incl. above cores); sub-1 rejection; the executor is dedicated and
core-sized; encode runs on a vision-encode thread; and crucially that encode
bursts are bounded to the cap while the analyses themselves stay fully
concurrent (calls_peak > cap).
2026-06-28 22:48:37 -07:00
|
|
|
|
image_data_url = await _run_encode_on_cpu_executor(
|
|
|
|
|
|
_resize_image_for_vision,
|
2026-04-11 11:07:18 -07:00
|
|
|
|
temp_image_path, mime_type=detected_mime_type)
|
|
|
|
|
|
messages[0]["content"][1]["image_url"]["url"] = image_data_url
|
|
|
|
|
|
response = await async_call_llm(**call_kwargs)
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise
|
2025-10-01 23:29:25 +00:00
|
|
|
|
|
2026-03-27 15:28:19 -07:00
|
|
|
|
# Extract the analysis — fall back to reasoning if content is empty
|
|
|
|
|
|
analysis = extract_content_or_reasoning(response)
|
|
|
|
|
|
|
|
|
|
|
|
# Retry once on empty content (reasoning-only response)
|
|
|
|
|
|
if not analysis:
|
|
|
|
|
|
logger.warning("Vision LLM returned empty content, retrying once")
|
|
|
|
|
|
response = await async_call_llm(**call_kwargs)
|
|
|
|
|
|
analysis = extract_content_or_reasoning(response)
|
|
|
|
|
|
|
2025-10-01 23:29:25 +00:00
|
|
|
|
analysis_length = len(analysis)
|
|
|
|
|
|
|
2026-02-21 03:11:11 -08:00
|
|
|
|
logger.info("Image analysis completed (%s characters)", analysis_length)
|
2025-10-01 23:29:25 +00:00
|
|
|
|
|
|
|
|
|
|
# Prepare successful response
|
|
|
|
|
|
result = {
|
|
|
|
|
|
"success": True,
|
|
|
|
|
|
"analysis": analysis or "There was a problem with the request and the image could not be analyzed."
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
debug_call_data["success"] = True
|
|
|
|
|
|
debug_call_data["analysis_length"] = analysis_length
|
|
|
|
|
|
|
|
|
|
|
|
# Log debug information
|
2026-02-21 03:53:24 -08:00
|
|
|
|
_debug.log_call("vision_analyze_tool", debug_call_data)
|
|
|
|
|
|
_debug.save()
|
2025-10-01 23:29:25 +00:00
|
|
|
|
|
2025-11-05 03:47:17 +00:00
|
|
|
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
2025-10-01 23:29:25 +00:00
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
error_msg = f"Error analyzing image: {str(e)}"
|
2026-03-05 16:11:59 +03:00
|
|
|
|
logger.error("%s", error_msg, exc_info=True)
|
2025-10-01 23:29:25 +00:00
|
|
|
|
|
feat: centralized provider router + fix Codex vision bypass + vision error handling
Three interconnected fixes for auxiliary client infrastructure:
1. CENTRALIZED PROVIDER ROUTER (auxiliary_client.py)
Add resolve_provider_client(provider, model, async_mode) — a single
entry point for creating properly configured clients. Given a provider
name and optional model, it handles auth lookup (env vars, OAuth
tokens, auth.json), base URL resolution, provider-specific headers,
and API format differences (Chat Completions vs Responses API for
Codex). All auxiliary consumers should route through this instead of
ad-hoc env var lookups.
Refactored get_text_auxiliary_client, get_async_text_auxiliary_client,
and get_vision_auxiliary_client to use the router internally.
2. FIX CODEX VISION BYPASS (vision_tools.py)
vision_tools.py was constructing a raw AsyncOpenAI client from the
sync vision client's api_key/base_url, completely bypassing the Codex
Responses API adapter. When the vision provider resolved to Codex,
the raw client would hit chatgpt.com/backend-api/codex with
chat.completions.create() which only supports the Responses API.
Fix: Added get_async_vision_auxiliary_client() which properly wraps
Codex into AsyncCodexAuxiliaryClient. vision_tools.py now uses this
instead of manual client construction.
3. FIX COMPRESSION FALLBACK + VISION ERROR HANDLING
- context_compressor.py: Removed _get_fallback_client() which blindly
looked for OPENAI_API_KEY + OPENAI_BASE_URL (fails for Codex OAuth,
API-key providers, users without OPENAI_BASE_URL set). Replaced
with fallback loop through resolve_provider_client() for each
known provider, with same-provider dedup.
- vision_tools.py: Added error detection for vision capability
failures. Returns clear message to the model when the configured
model doesn't support vision, instead of a generic error.
Addresses #886
2026-03-11 19:46:47 -07:00
|
|
|
|
# Detect vision capability errors — give the model a clear message
|
|
|
|
|
|
# so it can inform the user instead of a cryptic API error.
|
|
|
|
|
|
err_str = str(e).lower()
|
|
|
|
|
|
if any(hint in err_str for hint in (
|
2026-03-24 07:23:07 -07:00
|
|
|
|
"402", "insufficient", "payment required", "credits", "billing",
|
|
|
|
|
|
)):
|
|
|
|
|
|
analysis = (
|
|
|
|
|
|
"Insufficient credits or payment required. Please top up your "
|
|
|
|
|
|
f"API provider account and try again. Error: {e}"
|
|
|
|
|
|
)
|
|
|
|
|
|
elif any(hint in err_str for hint in (
|
2026-04-10 15:11:14 +10:00
|
|
|
|
"does not support", "not support image",
|
|
|
|
|
|
"content_policy", "multimodal",
|
feat: centralized provider router + fix Codex vision bypass + vision error handling
Three interconnected fixes for auxiliary client infrastructure:
1. CENTRALIZED PROVIDER ROUTER (auxiliary_client.py)
Add resolve_provider_client(provider, model, async_mode) — a single
entry point for creating properly configured clients. Given a provider
name and optional model, it handles auth lookup (env vars, OAuth
tokens, auth.json), base URL resolution, provider-specific headers,
and API format differences (Chat Completions vs Responses API for
Codex). All auxiliary consumers should route through this instead of
ad-hoc env var lookups.
Refactored get_text_auxiliary_client, get_async_text_auxiliary_client,
and get_vision_auxiliary_client to use the router internally.
2. FIX CODEX VISION BYPASS (vision_tools.py)
vision_tools.py was constructing a raw AsyncOpenAI client from the
sync vision client's api_key/base_url, completely bypassing the Codex
Responses API adapter. When the vision provider resolved to Codex,
the raw client would hit chatgpt.com/backend-api/codex with
chat.completions.create() which only supports the Responses API.
Fix: Added get_async_vision_auxiliary_client() which properly wraps
Codex into AsyncCodexAuxiliaryClient. vision_tools.py now uses this
instead of manual client construction.
3. FIX COMPRESSION FALLBACK + VISION ERROR HANDLING
- context_compressor.py: Removed _get_fallback_client() which blindly
looked for OPENAI_API_KEY + OPENAI_BASE_URL (fails for Codex OAuth,
API-key providers, users without OPENAI_BASE_URL set). Replaced
with fallback loop through resolve_provider_client() for each
known provider, with same-provider dedup.
- vision_tools.py: Added error detection for vision capability
failures. Returns clear message to the model when the configured
model doesn't support vision, instead of a generic error.
Addresses #886
2026-03-11 19:46:47 -07:00
|
|
|
|
"unrecognized request argument", "image input",
|
|
|
|
|
|
)):
|
|
|
|
|
|
analysis = (
|
|
|
|
|
|
f"{model} does not support vision or our request was not "
|
|
|
|
|
|
f"accepted by the server. Error: {e}"
|
|
|
|
|
|
)
|
2026-04-10 15:11:14 +10:00
|
|
|
|
elif "invalid_request" in err_str or "image_url" in err_str:
|
|
|
|
|
|
analysis = (
|
|
|
|
|
|
"The vision API rejected the image. This can happen when the "
|
2026-04-11 11:07:18 -07:00
|
|
|
|
"image is in an unsupported format, corrupted, or still too "
|
|
|
|
|
|
"large after auto-resize. Try a smaller JPEG/PNG and retry. "
|
2026-04-10 15:11:14 +10:00
|
|
|
|
f"Error: {e}"
|
|
|
|
|
|
)
|
feat: centralized provider router + fix Codex vision bypass + vision error handling
Three interconnected fixes for auxiliary client infrastructure:
1. CENTRALIZED PROVIDER ROUTER (auxiliary_client.py)
Add resolve_provider_client(provider, model, async_mode) — a single
entry point for creating properly configured clients. Given a provider
name and optional model, it handles auth lookup (env vars, OAuth
tokens, auth.json), base URL resolution, provider-specific headers,
and API format differences (Chat Completions vs Responses API for
Codex). All auxiliary consumers should route through this instead of
ad-hoc env var lookups.
Refactored get_text_auxiliary_client, get_async_text_auxiliary_client,
and get_vision_auxiliary_client to use the router internally.
2. FIX CODEX VISION BYPASS (vision_tools.py)
vision_tools.py was constructing a raw AsyncOpenAI client from the
sync vision client's api_key/base_url, completely bypassing the Codex
Responses API adapter. When the vision provider resolved to Codex,
the raw client would hit chatgpt.com/backend-api/codex with
chat.completions.create() which only supports the Responses API.
Fix: Added get_async_vision_auxiliary_client() which properly wraps
Codex into AsyncCodexAuxiliaryClient. vision_tools.py now uses this
instead of manual client construction.
3. FIX COMPRESSION FALLBACK + VISION ERROR HANDLING
- context_compressor.py: Removed _get_fallback_client() which blindly
looked for OPENAI_API_KEY + OPENAI_BASE_URL (fails for Codex OAuth,
API-key providers, users without OPENAI_BASE_URL set). Replaced
with fallback loop through resolve_provider_client() for each
known provider, with same-provider dedup.
- vision_tools.py: Added error detection for vision capability
failures. Returns clear message to the model when the configured
model doesn't support vision, instead of a generic error.
Addresses #886
2026-03-11 19:46:47 -07:00
|
|
|
|
else:
|
|
|
|
|
|
analysis = (
|
|
|
|
|
|
"There was a problem with the request and the image could not "
|
|
|
|
|
|
f"be analyzed. Error: {e}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-10-01 23:29:25 +00:00
|
|
|
|
# Prepare error response
|
|
|
|
|
|
result = {
|
|
|
|
|
|
"success": False,
|
2026-03-12 13:25:09 +01:00
|
|
|
|
"error": error_msg,
|
feat: centralized provider router + fix Codex vision bypass + vision error handling
Three interconnected fixes for auxiliary client infrastructure:
1. CENTRALIZED PROVIDER ROUTER (auxiliary_client.py)
Add resolve_provider_client(provider, model, async_mode) — a single
entry point for creating properly configured clients. Given a provider
name and optional model, it handles auth lookup (env vars, OAuth
tokens, auth.json), base URL resolution, provider-specific headers,
and API format differences (Chat Completions vs Responses API for
Codex). All auxiliary consumers should route through this instead of
ad-hoc env var lookups.
Refactored get_text_auxiliary_client, get_async_text_auxiliary_client,
and get_vision_auxiliary_client to use the router internally.
2. FIX CODEX VISION BYPASS (vision_tools.py)
vision_tools.py was constructing a raw AsyncOpenAI client from the
sync vision client's api_key/base_url, completely bypassing the Codex
Responses API adapter. When the vision provider resolved to Codex,
the raw client would hit chatgpt.com/backend-api/codex with
chat.completions.create() which only supports the Responses API.
Fix: Added get_async_vision_auxiliary_client() which properly wraps
Codex into AsyncCodexAuxiliaryClient. vision_tools.py now uses this
instead of manual client construction.
3. FIX COMPRESSION FALLBACK + VISION ERROR HANDLING
- context_compressor.py: Removed _get_fallback_client() which blindly
looked for OPENAI_API_KEY + OPENAI_BASE_URL (fails for Codex OAuth,
API-key providers, users without OPENAI_BASE_URL set). Replaced
with fallback loop through resolve_provider_client() for each
known provider, with same-provider dedup.
- vision_tools.py: Added error detection for vision capability
failures. Returns clear message to the model when the configured
model doesn't support vision, instead of a generic error.
Addresses #886
2026-03-11 19:46:47 -07:00
|
|
|
|
"analysis": analysis,
|
2025-10-01 23:29:25 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
debug_call_data["error"] = error_msg
|
2026-02-21 03:53:24 -08:00
|
|
|
|
_debug.log_call("vision_analyze_tool", debug_call_data)
|
|
|
|
|
|
_debug.save()
|
2025-10-01 23:29:25 +00:00
|
|
|
|
|
2025-11-05 03:47:17 +00:00
|
|
|
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
2025-10-08 02:38:04 +00:00
|
|
|
|
|
|
|
|
|
|
finally:
|
2026-02-15 16:10:50 -08:00
|
|
|
|
# Clean up temporary image file (but NOT local/cached files)
|
|
|
|
|
|
if should_cleanup and temp_image_path and temp_image_path.exists():
|
2025-10-08 02:38:04 +00:00
|
|
|
|
try:
|
|
|
|
|
|
temp_image_path.unlink()
|
2026-02-21 03:11:11 -08:00
|
|
|
|
logger.debug("Cleaned up temporary image file")
|
2025-10-08 02:38:04 +00:00
|
|
|
|
except Exception as cleanup_error:
|
2026-03-05 16:11:59 +03:00
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Could not delete temporary file: %s", cleanup_error, exc_info=True
|
|
|
|
|
|
)
|
2025-10-01 23:29:25 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def check_vision_requirements() -> bool:
|
fix(vision): route auxiliary.vision.provider=openai to api.openai.com, skip text-only main (#31452)
* fix(vision): route auxiliary.vision.provider=openai to api.openai.com, skip text-only main for vision
Fixes #31179. Three coupled fixes so a configured aux vision backend
actually serves vision tasks instead of silently routing images to the
user's main provider:
1. agent/auxiliary_client.py: `auxiliary.<task>.provider: openai` resolves
to `custom` + `https://api.openai.com/v1`. "openai" was not in
PROVIDER_REGISTRY (we have `openai-codex` for OAuth and `custom` for
manual base_url), so the obvious config name silently failed to build a
client. User-supplied base_url is still preserved; only the provider
name normalises to `custom` so resolution doesn't hit the
PROVIDER_REGISTRY-only path.
2. agent/auxiliary_client.py: the vision auto-detect chain now skips the
user's main provider when models.dev reports `supports_vision=False`.
Without this guard, a misconfigured aux provider would fall back to
`auto`, which happily returned the main-provider client. The caller
would then send image content to e.g. api.deepseek.com with model
`gpt-4o-mini` and get a cryptic `unknown variant 'image_url',
expected 'text'` from the provider's parser.
3. tools/vision_tools.py + tools/browser_tool.py: `check_vision_requirements`
now mirrors the runtime fallback chain (explicit provider, then auto),
so `vision_analyze` shows up whenever vision is actually serviceable.
`browser_vision` gets a new `check_browser_vision_requirements` check_fn
that AND-gates browser + vision availability, so it doesn't get
advertised to the model when the call would fail at runtime.
Reproduction (config from the bug report):
model.provider: deepseek
model.default: deepseek-v4-pro
auxiliary.vision.provider: openai
auxiliary.vision.model: gpt-4o-mini
Before: resolve_vision_provider_client() returns None for the explicit
provider, fallback auto returns the deepseek client with model='gpt-4o-mini',
image hits api.deepseek.com → 'unknown variant image_url'. vision_analyze
hidden from tool list; browser_vision exposed but fails at call time.
After: resolves to custom + api.openai.com/v1 with model gpt-4o-mini.
vision_analyze and browser_vision both gate correctly on capability.
Tests: tests/agent/test_vision_routing_31179.py covers all three fixes
(12 cases including the user's exact scenario, base_url preservation,
text-only-main skip, capability-unknown permissive fallback, and tool
gating parity). Existing 382 tests across auxiliary/vision/image_routing
suites still pass.
* test(vision): use exact hostname check to silence CodeQL substring-sanitization alert
* fix(auxiliary): drop model name from vision-skip debug log to silence CodeQL
The new `logger.debug(...)` added in the previous commit interpolated
both `main_provider` and `vision_model` (a public model slug \u2014 not
sensitive). CodeQL's `py/clear-text-logging-sensitive-data` heuristic
re-flagged it twice because the rule mis-detects multi-value
interpolations near tainted-via-config provider strings.
Drop the model from the log args (provider alone is enough to diagnose
the skip; the same sibling branch a few lines up already logs provider
only). Behavior unchanged; CodeQL false positive cleared.
2026-05-24 15:01:28 -07:00
|
|
|
|
"""Check if the configured runtime vision path can resolve a client.
|
|
|
|
|
|
|
|
|
|
|
|
Mirrors the fallback chain that ``call_llm(task="vision")`` actually uses
|
|
|
|
|
|
at runtime: first the explicit ``auxiliary.vision.provider`` (if any),
|
|
|
|
|
|
and if that fails, the auto chain (main provider → openrouter → nous).
|
|
|
|
|
|
Without the auto-fallback step the tool would disappear from the model's
|
|
|
|
|
|
tool list whenever the explicit provider name was unresolvable, even
|
|
|
|
|
|
when the auto chain would have served the request (issue #31179).
|
|
|
|
|
|
"""
|
2026-03-11 20:52:19 -07:00
|
|
|
|
try:
|
2026-03-14 20:22:13 -07:00
|
|
|
|
from agent.auxiliary_client import resolve_vision_provider_client
|
fix(vision): route auxiliary.vision.provider=openai to api.openai.com, skip text-only main (#31452)
* fix(vision): route auxiliary.vision.provider=openai to api.openai.com, skip text-only main for vision
Fixes #31179. Three coupled fixes so a configured aux vision backend
actually serves vision tasks instead of silently routing images to the
user's main provider:
1. agent/auxiliary_client.py: `auxiliary.<task>.provider: openai` resolves
to `custom` + `https://api.openai.com/v1`. "openai" was not in
PROVIDER_REGISTRY (we have `openai-codex` for OAuth and `custom` for
manual base_url), so the obvious config name silently failed to build a
client. User-supplied base_url is still preserved; only the provider
name normalises to `custom` so resolution doesn't hit the
PROVIDER_REGISTRY-only path.
2. agent/auxiliary_client.py: the vision auto-detect chain now skips the
user's main provider when models.dev reports `supports_vision=False`.
Without this guard, a misconfigured aux provider would fall back to
`auto`, which happily returned the main-provider client. The caller
would then send image content to e.g. api.deepseek.com with model
`gpt-4o-mini` and get a cryptic `unknown variant 'image_url',
expected 'text'` from the provider's parser.
3. tools/vision_tools.py + tools/browser_tool.py: `check_vision_requirements`
now mirrors the runtime fallback chain (explicit provider, then auto),
so `vision_analyze` shows up whenever vision is actually serviceable.
`browser_vision` gets a new `check_browser_vision_requirements` check_fn
that AND-gates browser + vision availability, so it doesn't get
advertised to the model when the call would fail at runtime.
Reproduction (config from the bug report):
model.provider: deepseek
model.default: deepseek-v4-pro
auxiliary.vision.provider: openai
auxiliary.vision.model: gpt-4o-mini
Before: resolve_vision_provider_client() returns None for the explicit
provider, fallback auto returns the deepseek client with model='gpt-4o-mini',
image hits api.deepseek.com → 'unknown variant image_url'. vision_analyze
hidden from tool list; browser_vision exposed but fails at call time.
After: resolves to custom + api.openai.com/v1 with model gpt-4o-mini.
vision_analyze and browser_vision both gate correctly on capability.
Tests: tests/agent/test_vision_routing_31179.py covers all three fixes
(12 cases including the user's exact scenario, base_url preservation,
text-only-main skip, capability-unknown permissive fallback, and tool
gating parity). Existing 382 tests across auxiliary/vision/image_routing
suites still pass.
* test(vision): use exact hostname check to silence CodeQL substring-sanitization alert
* fix(auxiliary): drop model name from vision-skip debug log to silence CodeQL
The new `logger.debug(...)` added in the previous commit interpolated
both `main_provider` and `vision_model` (a public model slug \u2014 not
sensitive). CodeQL's `py/clear-text-logging-sensitive-data` heuristic
re-flagged it twice because the rule mis-detects multi-value
interpolations near tainted-via-config provider strings.
Drop the model from the log args (provider alone is enough to diagnose
the skip; the same sibling branch a few lines up already logs provider
only). Behavior unchanged; CodeQL false positive cleared.
2026-05-24 15:01:28 -07:00
|
|
|
|
except ImportError:
|
|
|
|
|
|
return False
|
|
|
|
|
|
try:
|
2026-03-14 20:22:13 -07:00
|
|
|
|
_provider, client, _model = resolve_vision_provider_client()
|
fix(vision): route auxiliary.vision.provider=openai to api.openai.com, skip text-only main (#31452)
* fix(vision): route auxiliary.vision.provider=openai to api.openai.com, skip text-only main for vision
Fixes #31179. Three coupled fixes so a configured aux vision backend
actually serves vision tasks instead of silently routing images to the
user's main provider:
1. agent/auxiliary_client.py: `auxiliary.<task>.provider: openai` resolves
to `custom` + `https://api.openai.com/v1`. "openai" was not in
PROVIDER_REGISTRY (we have `openai-codex` for OAuth and `custom` for
manual base_url), so the obvious config name silently failed to build a
client. User-supplied base_url is still preserved; only the provider
name normalises to `custom` so resolution doesn't hit the
PROVIDER_REGISTRY-only path.
2. agent/auxiliary_client.py: the vision auto-detect chain now skips the
user's main provider when models.dev reports `supports_vision=False`.
Without this guard, a misconfigured aux provider would fall back to
`auto`, which happily returned the main-provider client. The caller
would then send image content to e.g. api.deepseek.com with model
`gpt-4o-mini` and get a cryptic `unknown variant 'image_url',
expected 'text'` from the provider's parser.
3. tools/vision_tools.py + tools/browser_tool.py: `check_vision_requirements`
now mirrors the runtime fallback chain (explicit provider, then auto),
so `vision_analyze` shows up whenever vision is actually serviceable.
`browser_vision` gets a new `check_browser_vision_requirements` check_fn
that AND-gates browser + vision availability, so it doesn't get
advertised to the model when the call would fail at runtime.
Reproduction (config from the bug report):
model.provider: deepseek
model.default: deepseek-v4-pro
auxiliary.vision.provider: openai
auxiliary.vision.model: gpt-4o-mini
Before: resolve_vision_provider_client() returns None for the explicit
provider, fallback auto returns the deepseek client with model='gpt-4o-mini',
image hits api.deepseek.com → 'unknown variant image_url'. vision_analyze
hidden from tool list; browser_vision exposed but fails at call time.
After: resolves to custom + api.openai.com/v1 with model gpt-4o-mini.
vision_analyze and browser_vision both gate correctly on capability.
Tests: tests/agent/test_vision_routing_31179.py covers all three fixes
(12 cases including the user's exact scenario, base_url preservation,
text-only-main skip, capability-unknown permissive fallback, and tool
gating parity). Existing 382 tests across auxiliary/vision/image_routing
suites still pass.
* test(vision): use exact hostname check to silence CodeQL substring-sanitization alert
* fix(auxiliary): drop model name from vision-skip debug log to silence CodeQL
The new `logger.debug(...)` added in the previous commit interpolated
both `main_provider` and `vision_model` (a public model slug \u2014 not
sensitive). CodeQL's `py/clear-text-logging-sensitive-data` heuristic
re-flagged it twice because the rule mis-detects multi-value
interpolations near tainted-via-config provider strings.
Drop the model from the log args (provider alone is enough to diagnose
the skip; the same sibling branch a few lines up already logs provider
only). Behavior unchanged; CodeQL false positive cleared.
2026-05-24 15:01:28 -07:00
|
|
|
|
if client is not None:
|
|
|
|
|
|
return True
|
|
|
|
|
|
# Same fallback to "auto" that call_llm performs when the configured
|
|
|
|
|
|
# provider can't be resolved.
|
|
|
|
|
|
_provider, client, _model = resolve_vision_provider_client(provider="auto")
|
2026-03-11 20:52:19 -07:00
|
|
|
|
return client is not None
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return False
|
2025-10-01 23:29:25 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
"""
|
|
|
|
|
|
Simple test/demo when run directly
|
|
|
|
|
|
"""
|
|
|
|
|
|
print("👁️ Vision Tools Module")
|
|
|
|
|
|
print("=" * 40)
|
|
|
|
|
|
|
2026-02-22 02:16:11 -08:00
|
|
|
|
# Check if vision model is available
|
|
|
|
|
|
api_available = check_vision_requirements()
|
2025-10-01 23:29:25 +00:00
|
|
|
|
|
|
|
|
|
|
if not api_available:
|
2026-02-22 02:16:11 -08:00
|
|
|
|
print("❌ No auxiliary vision model available")
|
2026-03-14 21:14:20 -07:00
|
|
|
|
print("Configure a supported multimodal backend (OpenRouter, Nous, Codex, Anthropic, or a custom OpenAI-compatible endpoint).")
|
2026-05-11 11:20:58 -07:00
|
|
|
|
sys.exit(1)
|
2025-10-01 23:29:25 +00:00
|
|
|
|
else:
|
2026-03-11 20:52:19 -07:00
|
|
|
|
print("✅ Vision model available")
|
2025-10-01 23:29:25 +00:00
|
|
|
|
|
|
|
|
|
|
print("🛠️ Vision tools ready for use!")
|
|
|
|
|
|
|
|
|
|
|
|
# Show debug mode status
|
2026-02-21 03:53:24 -08:00
|
|
|
|
if _debug.active:
|
|
|
|
|
|
print(f"🐛 Debug mode ENABLED - Session ID: {_debug.session_id}")
|
|
|
|
|
|
print(f" Debug logs will be saved to: ./logs/vision_tools_debug_{_debug.session_id}.json")
|
2025-10-01 23:29:25 +00:00
|
|
|
|
else:
|
|
|
|
|
|
print("🐛 Debug mode disabled (set VISION_TOOLS_DEBUG=true to enable)")
|
|
|
|
|
|
|
|
|
|
|
|
print("\nBasic usage:")
|
|
|
|
|
|
print(" from vision_tools import vision_analyze_tool")
|
|
|
|
|
|
print(" import asyncio")
|
|
|
|
|
|
print("")
|
|
|
|
|
|
print(" async def main():")
|
|
|
|
|
|
print(" result = await vision_analyze_tool(")
|
|
|
|
|
|
print(" image_url='https://example.com/image.jpg',")
|
|
|
|
|
|
print(" user_prompt='What do you see in this image?'")
|
|
|
|
|
|
print(" )")
|
|
|
|
|
|
print(" print(result)")
|
|
|
|
|
|
print(" asyncio.run(main())")
|
|
|
|
|
|
|
|
|
|
|
|
print("\nExample prompts:")
|
|
|
|
|
|
print(" - 'What architectural style is this building?'")
|
|
|
|
|
|
print(" - 'Describe the emotions and mood in this image'")
|
|
|
|
|
|
print(" - 'What text can you read in this image?'")
|
|
|
|
|
|
print(" - 'Identify any safety hazards visible'")
|
|
|
|
|
|
print(" - 'What products or brands are shown?'")
|
|
|
|
|
|
|
|
|
|
|
|
print("\nDebug mode:")
|
|
|
|
|
|
print(" # Enable debug logging")
|
|
|
|
|
|
print(" export VISION_TOOLS_DEBUG=true")
|
|
|
|
|
|
print(" # Debug logs capture all vision analysis calls and results")
|
|
|
|
|
|
print(" # Logs saved to: ./logs/vision_tools_debug_UUID.json")
|
2026-02-21 20:22:33 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Registry
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
refactor: add tool_error/tool_result helpers + read_raw_config, migrate 129 callsites
Add three reusable helpers to eliminate pervasive boilerplate:
tools/registry.py — tool_error() and tool_result():
Every tool handler returns JSON strings. The pattern
json.dumps({"error": msg}, ensure_ascii=False) appeared 106 times,
and json.dumps({"success": False, "error": msg}, ...) another 23.
Now: tool_error(msg) or tool_error(msg, success=False).
tool_result() handles arbitrary result dicts:
tool_result(success=True, data=payload) or tool_result(some_dict).
hermes_cli/config.py — read_raw_config():
Lightweight YAML reader that returns the raw config dict without
load_config()'s deep-merge + migration overhead. Available for
callsites that just need a single config value.
Migration (129 callsites across 32 files):
- tools/: browser_camofox (18), file_tools (10), homeassistant (8),
web_tools (7), skill_manager (7), cronjob (11), code_execution (4),
delegate (5), send_message (4), tts (4), memory (7), session_search (3),
mcp (2), clarify (2), skills_tool (3), todo (1), vision (1),
browser (1), process_registry (2), image_gen (1)
- plugins/memory/: honcho (9), supermemory (9), hindsight (8),
holographic (7), openviking (7), mem0 (7), byterover (6), retaindb (2)
- agent/: memory_manager (2), builtin_memory_provider (1)
2026-04-07 13:36:20 -07:00
|
|
|
|
from tools.registry import registry, tool_error
|
2026-02-21 20:22:33 -08:00
|
|
|
|
|
|
|
|
|
|
VISION_ANALYZE_SCHEMA = {
|
|
|
|
|
|
"name": "vision_analyze",
|
feat(image-input): native multimodal routing based on model vision capability (#16506)
* feat(image-input): native multimodal routing based on model vision capability
Attach user-sent images as OpenAI-style content parts on the user turn when
the active model supports native vision, so vision-capable models see real
pixels instead of a lossy text description from vision_analyze.
Routing decision (agent/image_routing.py::decide_image_input_mode):
agent.image_input_mode = auto | native | text (default: auto)
In auto mode:
- If auxiliary.vision.provider/model is explicitly configured, keep the
text pipeline (user paid for a dedicated vision backend).
- Else if models.dev reports supports_vision=True for the active
provider/model, attach natively.
- Else fall back to text (current behaviour).
Call sites updated: gateway/run.py (all messaging platforms), tui_gateway
(dashboard/Ink), cli.py (interactive /attach + drag-drop).
run_agent.py changes:
- _prepare_anthropic_messages_for_api now passes image parts through
unchanged when the model supports vision — the Anthropic adapter
translates them to native image blocks. Previous behaviour
(vision_analyze → text) only runs for non-vision Anthropic models.
- New _prepare_messages_for_non_vision_model mirrors the same contract
for chat.completions and codex_responses paths, so non-vision models
on any provider get text-fallback instead of failing at the provider.
- New _model_supports_vision() helper reads models.dev caps.
vision_analyze description rewritten: positions it as a tool for images
NOT already visible in the conversation (URLs, tool output, deeper
inspection). Prevents the model from redundantly calling it on images
already attached natively.
Config default: agent.image_input_mode = auto.
Tests: 35 new (test_image_routing.py + test_vision_aware_preprocessing.py),
all existing tests that reference _prepare_anthropic_messages_for_api
still pass (198 targeted + new tests green).
* feat(image-input): size-cap + resize oversized images, charge image tokens in compressor
Two follow-ups that make the native image routing safer for long / heavy
sessions:
1) Oversize handling in build_native_content_parts:
- 20 MB ceiling per image (matches vision_tools._MAX_BASE64_BYTES,
the most restrictive provider — Gemini inline data).
- Delegates to vision_tools._resize_image_for_vision (Pillow-based,
already battle-tested) to downscale to 5 MB first-try.
- If Pillow is missing or resize still overshoots, the image is
dropped and reported back in skipped[]; caller falls back to text
enrichment for that image.
2) Image-token accounting in context_compressor:
- New _IMAGE_TOKEN_ESTIMATE = 1600 (matches Claude Code's constant;
within the realistic range for Anthropic/GPT-4o/Gemini billing).
- _content_length_for_budget() helper: sums text-part lengths and
charges _IMAGE_CHAR_EQUIVALENT (1600 * 4 chars) per image/image_url/
input_image part. Base64 payload inside image_url is NOT counted
as chars — dimensions don't matter, only image-presence.
- Both tail-cut sites (_prune_old_tool_results L527 and
_find_tail_cut_by_tokens L1126) now call the helper so multi-image
conversations don't slip past compression budget.
Tests: 9 new in test_image_routing.py (oversize triggers resize,
resize-fails-returns-None, oversize-skipped-reported), 11 new in
test_compressor_image_tokens.py (flat charge per image, multiple images,
Responses-API / Anthropic-native / OpenAI-chat shapes, no-inflation on
raw base64, bounds-check on the constant, integration test that an
image-heavy tail actually gets trimmed).
* fix(image-input): replace blanket 20MB ceiling with empirically-verified per-provider limits
The previous commit imposed a hardcoded 20 MB base64 ceiling on all
providers, triggering auto-resize on anything larger. This was wrong in
both directions:
* Too loose for Anthropic — actual limit is 5 MB (returns HTTP 400
'image exceeds 5 MB maximum' above that).
* Too strict for OpenAI / Codex / OpenRouter — accept 49 MB+ without
complaint (empirically verified April 2026 with progressive PNG
sizes).
New behaviour:
* _PROVIDER_BASE64_CEILING table: only anthropic and bedrock have a
ceiling (5 MB, since bedrock-on-Claude shares Anthropic's decoder).
* Providers NOT in the table get no ceiling — images attach at native
size and we trust the provider to return its own error if it
disagrees. A provider-specific 400 message is clearer than us
guessing wrong and silently degrading image quality.
* build_native_content_parts() gains a keyword-only provider arg;
gateway/CLI/TUI pass the active provider so Anthropic users get
auto-resize protection while OpenAI users don't pay it.
* Resize target dropped from 5 MB to 4 MB to slide safely under
Anthropic's boundary with header overhead.
Empirical measurements (direct API, no Hermes in the loop):
image b64 anthropic openrouter/gpt5.5 codex-oauth/gpt5.5
0.19 MB ✓ ✓ ✓
12.37 MB ✗ 400 5MB ✓ ✓
23.85 MB ✗ 400 5MB ✓ ✓
49.46 MB ✗ 413 ✓ ✓
Tests: rewrote TestOversizeHandling (5 tests): no-ceiling pass-through,
Anthropic resize fires, Anthropic skip on resize-fail, build_native_parts
routes ceiling by provider, unknown provider gets no ceiling. All 52
targeted tests pass.
* refactor(image-input): attempt native, shrink-and-retry on provider reject
Replace proactive per-provider size ceilings with a reactive shrink path
on the provider's actual rejection. All providers now attempt native
full-size attachment first; if the provider returns an image-too-large
error, the agent silently shrinks and retries once.
Why the previous design was wrong: hardcoding provider ceilings
(anthropic=5MB, others=unlimited) meant OpenAI users on a 10MB image
paid no tax, but Anthropic users lost quality on anything >5MB even
though the empirical behaviour at provider-reject time is the same
(shrink + retry). Baking the table into the routing layer also
requires updating Hermes every time a provider's limit changes.
Reactive design:
- image_routing.py: _file_to_data_url encodes native size, no ceiling.
build_native_content_parts drops its provider kwarg.
- error_classifier.py: new FailoverReason.image_too_large + pattern
match ("image exceeds", "image too large", etc.) checked BEFORE
context_overflow so Anthropic's 5MB rejection lands in the right
bucket.
- run_agent.py: new _try_shrink_image_parts_in_messages walks api
messages in-place, re-encodes oversized data: URL image parts
through vision_tools._resize_image_for_vision to fit under 4MB,
handles both chat.completions (dict image_url) and Responses
(string image_url) shapes, ignores http URLs (provider-fetched).
New image_shrink_retry_attempted flag in the retry loop fires the
shrink exactly once per turn after credential-pool recovery but
before auth retries.
E2E verified live against Anthropic claude-sonnet-4-6:
- 17.9MB PNG (23.9MB b64) attached at native size
- Anthropic returns 400 "image exceeds 5 MB maximum"
- Agent logs '📐 Image(s) exceeded provider size limit — shrank and
retrying...'
- Retry succeeds, correct response delivered in 6.8s total.
Tests: 12 new (8 shrink-helper shapes + 4 classifier signals),
replaces 5 proactive-ceiling tests with 3 simpler 'native attach works'
tests. 181 targeted tests pass. test_enum_members_exist in
test_error_classifier.py updated for the new enum value.
2026-04-27 06:27:59 -07:00
|
|
|
|
"description": (
|
feat(vision): vision_analyze returns pixels to vision-capable models, not aux text (#22955)
When the active main model has native vision and the provider supports
multimodal tool results (Anthropic, OpenAI Chat, Codex Responses, Gemini
3, OpenRouter, Nous), vision_analyze loads the image bytes and returns
them to the model as a multimodal tool-result envelope. The model then
sees the pixels directly on its next turn instead of receiving a lossy
text description from an auxiliary LLM.
Falls back to the legacy aux-LLM text path for non-vision models and
unverified providers.
Mirrors the architecture used in OpenCode, Claude Code, Codex CLI, and
Cline. All four converge on the same pattern: tool results carry image
content blocks for vision-capable provider/model combinations.
Changes
- tools/vision_tools.py: _vision_analyze_native fast path + provider
capability table (_supports_media_in_tool_results). Schema description
updated to reflect new behaviour.
- agent/codex_responses_adapter.py: function_call_output.output now
accepts the array form for multimodal tool results (was string-only).
Preflight validates input_text/input_image parts.
- agent/auxiliary_client.py: _RUNTIME_MAIN_PROVIDER/_MODEL globals so
tools see the live CLI/gateway override, not the stale config.yaml
default. set_runtime_main()/clear_runtime_main() helpers.
- run_agent.py: AIAgent.run_conversation calls set_runtime_main at turn
start so vision_analyze's fast-path check sees the actual runtime.
- tests/conftest.py: clear runtime-main override between tests.
Tests
- tests/tools/test_vision_native_fast_path.py: provider capability
table, envelope shape, fast-path gating (vision-capable model uses
fast path; non-vision model falls through to aux).
- tests/run_agent/test_codex_multimodal_tool_result.py: list tool
content becomes function_call_output.output array; preflight
preserves arrays and drops unknown part types.
Live verified
- Opus 4.6 + Sonnet 4.6 on OpenRouter: model calls vision_analyze on a
typed filepath, gets pixels back, reads exact text from images that
no aux description could capture (font color irony, multi-line
fruit-count list, etc.).
PR replaces the closed prior efforts (#16506 shipped the inbound user-
attached path; this PR closes the gap for tool-discovered images).
2026-05-09 21:06:19 -07:00
|
|
|
|
"Load an image into the conversation so you can see it. Accepts a "
|
|
|
|
|
|
"URL, local file path, or data URL. When your active model has "
|
|
|
|
|
|
"native vision, the image is attached to your context directly "
|
|
|
|
|
|
"and you read the pixels yourself on the next turn — call this "
|
|
|
|
|
|
"any time the user references an image (filepath in their message, "
|
|
|
|
|
|
"URL in tool output, screenshot from the browser, etc.). For "
|
|
|
|
|
|
"non-vision models, falls back to an auxiliary vision model that "
|
|
|
|
|
|
"returns a text description."
|
feat(image-input): native multimodal routing based on model vision capability (#16506)
* feat(image-input): native multimodal routing based on model vision capability
Attach user-sent images as OpenAI-style content parts on the user turn when
the active model supports native vision, so vision-capable models see real
pixels instead of a lossy text description from vision_analyze.
Routing decision (agent/image_routing.py::decide_image_input_mode):
agent.image_input_mode = auto | native | text (default: auto)
In auto mode:
- If auxiliary.vision.provider/model is explicitly configured, keep the
text pipeline (user paid for a dedicated vision backend).
- Else if models.dev reports supports_vision=True for the active
provider/model, attach natively.
- Else fall back to text (current behaviour).
Call sites updated: gateway/run.py (all messaging platforms), tui_gateway
(dashboard/Ink), cli.py (interactive /attach + drag-drop).
run_agent.py changes:
- _prepare_anthropic_messages_for_api now passes image parts through
unchanged when the model supports vision — the Anthropic adapter
translates them to native image blocks. Previous behaviour
(vision_analyze → text) only runs for non-vision Anthropic models.
- New _prepare_messages_for_non_vision_model mirrors the same contract
for chat.completions and codex_responses paths, so non-vision models
on any provider get text-fallback instead of failing at the provider.
- New _model_supports_vision() helper reads models.dev caps.
vision_analyze description rewritten: positions it as a tool for images
NOT already visible in the conversation (URLs, tool output, deeper
inspection). Prevents the model from redundantly calling it on images
already attached natively.
Config default: agent.image_input_mode = auto.
Tests: 35 new (test_image_routing.py + test_vision_aware_preprocessing.py),
all existing tests that reference _prepare_anthropic_messages_for_api
still pass (198 targeted + new tests green).
* feat(image-input): size-cap + resize oversized images, charge image tokens in compressor
Two follow-ups that make the native image routing safer for long / heavy
sessions:
1) Oversize handling in build_native_content_parts:
- 20 MB ceiling per image (matches vision_tools._MAX_BASE64_BYTES,
the most restrictive provider — Gemini inline data).
- Delegates to vision_tools._resize_image_for_vision (Pillow-based,
already battle-tested) to downscale to 5 MB first-try.
- If Pillow is missing or resize still overshoots, the image is
dropped and reported back in skipped[]; caller falls back to text
enrichment for that image.
2) Image-token accounting in context_compressor:
- New _IMAGE_TOKEN_ESTIMATE = 1600 (matches Claude Code's constant;
within the realistic range for Anthropic/GPT-4o/Gemini billing).
- _content_length_for_budget() helper: sums text-part lengths and
charges _IMAGE_CHAR_EQUIVALENT (1600 * 4 chars) per image/image_url/
input_image part. Base64 payload inside image_url is NOT counted
as chars — dimensions don't matter, only image-presence.
- Both tail-cut sites (_prune_old_tool_results L527 and
_find_tail_cut_by_tokens L1126) now call the helper so multi-image
conversations don't slip past compression budget.
Tests: 9 new in test_image_routing.py (oversize triggers resize,
resize-fails-returns-None, oversize-skipped-reported), 11 new in
test_compressor_image_tokens.py (flat charge per image, multiple images,
Responses-API / Anthropic-native / OpenAI-chat shapes, no-inflation on
raw base64, bounds-check on the constant, integration test that an
image-heavy tail actually gets trimmed).
* fix(image-input): replace blanket 20MB ceiling with empirically-verified per-provider limits
The previous commit imposed a hardcoded 20 MB base64 ceiling on all
providers, triggering auto-resize on anything larger. This was wrong in
both directions:
* Too loose for Anthropic — actual limit is 5 MB (returns HTTP 400
'image exceeds 5 MB maximum' above that).
* Too strict for OpenAI / Codex / OpenRouter — accept 49 MB+ without
complaint (empirically verified April 2026 with progressive PNG
sizes).
New behaviour:
* _PROVIDER_BASE64_CEILING table: only anthropic and bedrock have a
ceiling (5 MB, since bedrock-on-Claude shares Anthropic's decoder).
* Providers NOT in the table get no ceiling — images attach at native
size and we trust the provider to return its own error if it
disagrees. A provider-specific 400 message is clearer than us
guessing wrong and silently degrading image quality.
* build_native_content_parts() gains a keyword-only provider arg;
gateway/CLI/TUI pass the active provider so Anthropic users get
auto-resize protection while OpenAI users don't pay it.
* Resize target dropped from 5 MB to 4 MB to slide safely under
Anthropic's boundary with header overhead.
Empirical measurements (direct API, no Hermes in the loop):
image b64 anthropic openrouter/gpt5.5 codex-oauth/gpt5.5
0.19 MB ✓ ✓ ✓
12.37 MB ✗ 400 5MB ✓ ✓
23.85 MB ✗ 400 5MB ✓ ✓
49.46 MB ✗ 413 ✓ ✓
Tests: rewrote TestOversizeHandling (5 tests): no-ceiling pass-through,
Anthropic resize fires, Anthropic skip on resize-fail, build_native_parts
routes ceiling by provider, unknown provider gets no ceiling. All 52
targeted tests pass.
* refactor(image-input): attempt native, shrink-and-retry on provider reject
Replace proactive per-provider size ceilings with a reactive shrink path
on the provider's actual rejection. All providers now attempt native
full-size attachment first; if the provider returns an image-too-large
error, the agent silently shrinks and retries once.
Why the previous design was wrong: hardcoding provider ceilings
(anthropic=5MB, others=unlimited) meant OpenAI users on a 10MB image
paid no tax, but Anthropic users lost quality on anything >5MB even
though the empirical behaviour at provider-reject time is the same
(shrink + retry). Baking the table into the routing layer also
requires updating Hermes every time a provider's limit changes.
Reactive design:
- image_routing.py: _file_to_data_url encodes native size, no ceiling.
build_native_content_parts drops its provider kwarg.
- error_classifier.py: new FailoverReason.image_too_large + pattern
match ("image exceeds", "image too large", etc.) checked BEFORE
context_overflow so Anthropic's 5MB rejection lands in the right
bucket.
- run_agent.py: new _try_shrink_image_parts_in_messages walks api
messages in-place, re-encodes oversized data: URL image parts
through vision_tools._resize_image_for_vision to fit under 4MB,
handles both chat.completions (dict image_url) and Responses
(string image_url) shapes, ignores http URLs (provider-fetched).
New image_shrink_retry_attempted flag in the retry loop fires the
shrink exactly once per turn after credential-pool recovery but
before auth retries.
E2E verified live against Anthropic claude-sonnet-4-6:
- 17.9MB PNG (23.9MB b64) attached at native size
- Anthropic returns 400 "image exceeds 5 MB maximum"
- Agent logs '📐 Image(s) exceeded provider size limit — shrank and
retrying...'
- Retry succeeds, correct response delivered in 6.8s total.
Tests: 12 new (8 shrink-helper shapes + 4 classifier signals),
replaces 5 proactive-ceiling tests with 3 simpler 'native attach works'
tests. 181 targeted tests pass. test_enum_members_exist in
test_error_classifier.py updated for the new enum value.
2026-04-27 06:27:59 -07:00
|
|
|
|
),
|
2026-02-21 20:22:33 -08:00
|
|
|
|
"parameters": {
|
|
|
|
|
|
"type": "object",
|
|
|
|
|
|
"properties": {
|
|
|
|
|
|
"image_url": {
|
|
|
|
|
|
"type": "string",
|
feat(vision): vision_analyze returns pixels to vision-capable models, not aux text (#22955)
When the active main model has native vision and the provider supports
multimodal tool results (Anthropic, OpenAI Chat, Codex Responses, Gemini
3, OpenRouter, Nous), vision_analyze loads the image bytes and returns
them to the model as a multimodal tool-result envelope. The model then
sees the pixels directly on its next turn instead of receiving a lossy
text description from an auxiliary LLM.
Falls back to the legacy aux-LLM text path for non-vision models and
unverified providers.
Mirrors the architecture used in OpenCode, Claude Code, Codex CLI, and
Cline. All four converge on the same pattern: tool results carry image
content blocks for vision-capable provider/model combinations.
Changes
- tools/vision_tools.py: _vision_analyze_native fast path + provider
capability table (_supports_media_in_tool_results). Schema description
updated to reflect new behaviour.
- agent/codex_responses_adapter.py: function_call_output.output now
accepts the array form for multimodal tool results (was string-only).
Preflight validates input_text/input_image parts.
- agent/auxiliary_client.py: _RUNTIME_MAIN_PROVIDER/_MODEL globals so
tools see the live CLI/gateway override, not the stale config.yaml
default. set_runtime_main()/clear_runtime_main() helpers.
- run_agent.py: AIAgent.run_conversation calls set_runtime_main at turn
start so vision_analyze's fast-path check sees the actual runtime.
- tests/conftest.py: clear runtime-main override between tests.
Tests
- tests/tools/test_vision_native_fast_path.py: provider capability
table, envelope shape, fast-path gating (vision-capable model uses
fast path; non-vision model falls through to aux).
- tests/run_agent/test_codex_multimodal_tool_result.py: list tool
content becomes function_call_output.output array; preflight
preserves arrays and drops unknown part types.
Live verified
- Opus 4.6 + Sonnet 4.6 on OpenRouter: model calls vision_analyze on a
typed filepath, gets pixels back, reads exact text from images that
no aux description could capture (font color irony, multi-line
fruit-count list, etc.).
PR replaces the closed prior efforts (#16506 shipped the inbound user-
attached path; this PR closes the gap for tool-discovered images).
2026-05-09 21:06:19 -07:00
|
|
|
|
"description": "Image URL (http/https), local file path, or data: URL to load."
|
2026-02-21 20:22:33 -08:00
|
|
|
|
},
|
|
|
|
|
|
"question": {
|
|
|
|
|
|
"type": "string",
|
feat(vision): vision_analyze returns pixels to vision-capable models, not aux text (#22955)
When the active main model has native vision and the provider supports
multimodal tool results (Anthropic, OpenAI Chat, Codex Responses, Gemini
3, OpenRouter, Nous), vision_analyze loads the image bytes and returns
them to the model as a multimodal tool-result envelope. The model then
sees the pixels directly on its next turn instead of receiving a lossy
text description from an auxiliary LLM.
Falls back to the legacy aux-LLM text path for non-vision models and
unverified providers.
Mirrors the architecture used in OpenCode, Claude Code, Codex CLI, and
Cline. All four converge on the same pattern: tool results carry image
content blocks for vision-capable provider/model combinations.
Changes
- tools/vision_tools.py: _vision_analyze_native fast path + provider
capability table (_supports_media_in_tool_results). Schema description
updated to reflect new behaviour.
- agent/codex_responses_adapter.py: function_call_output.output now
accepts the array form for multimodal tool results (was string-only).
Preflight validates input_text/input_image parts.
- agent/auxiliary_client.py: _RUNTIME_MAIN_PROVIDER/_MODEL globals so
tools see the live CLI/gateway override, not the stale config.yaml
default. set_runtime_main()/clear_runtime_main() helpers.
- run_agent.py: AIAgent.run_conversation calls set_runtime_main at turn
start so vision_analyze's fast-path check sees the actual runtime.
- tests/conftest.py: clear runtime-main override between tests.
Tests
- tests/tools/test_vision_native_fast_path.py: provider capability
table, envelope shape, fast-path gating (vision-capable model uses
fast path; non-vision model falls through to aux).
- tests/run_agent/test_codex_multimodal_tool_result.py: list tool
content becomes function_call_output.output array; preflight
preserves arrays and drops unknown part types.
Live verified
- Opus 4.6 + Sonnet 4.6 on OpenRouter: model calls vision_analyze on a
typed filepath, gets pixels back, reads exact text from images that
no aux description could capture (font color irony, multi-line
fruit-count list, etc.).
PR replaces the closed prior efforts (#16506 shipped the inbound user-
attached path; this PR closes the gap for tool-discovered images).
2026-05-09 21:06:19 -07:00
|
|
|
|
"description": "Your specific question or request about the image. Optional context the model uses on the next turn after seeing the image."
|
2026-02-21 20:22:33 -08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
"required": ["image_url", "question"]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
fix(vision): cap vision_analyze fan-out concurrency process-wide
A single agent turn can fan out N vision_analyze calls at once — the
classic trigger is "analyze every frame of this video", where ffmpeg
explodes a clip into dozens of frames and the model calls vision_analyze
on each. Every call does a CPU-heavy base64-encode/resize burst AND holds
a long-lived LLM stream open. The tool executor runs concurrent tool calls
on a per-session ThreadPoolExecutor (_MAX_TOOL_WORKERS=8), and multiple
agent sessions share one process (the dashboard runs the agent in-process),
so there was no global ceiling. In prod (June 2026) a video-frame fan-out
pinned a worker thread at ~100% CPU and starved the shared asyncio event
loop that also serves the dashboard's /api/status liveness probe, flapping
the instance to UNHEALTHY even though nothing had crashed.
Add a process-global threading.BoundedSemaphore that bounds how many vision
analyses run concurrently across the whole process, held across the entire
analysis (image load + encode + LLM call) in the single _handle_vision_analyze
chokepoint (covers both the native fast path and the legacy aux-LLM path).
It is a threading semaphore, NOT asyncio: each vision call is dispatched
through model_tools._run_async on a per-thread event loop, so an asyncio
primitive bound to one loop cannot coordinate across them. The acquire is
offloaded via run_in_executor so waiting for a slot never blocks the calling
loop.
Default: min(host CPUs, 4), floored at 1 — respect the host's concurrency,
or lower. Override via auxiliary.vision.max_concurrency (config.yaml) or
HERMES_VISION_MAX_CONCURRENCY (env). Values < 1 are ignored so the cap can
never be disabled into an unbounded fan-out.
Tests: bounded-fan-out regression guard + a control proving it would fail
without the cap; resolver tests for host-cpu default, ceiling clamp, low-cpu
host, env override, and sub-1 rejection. Pre-existing handler tests updated
for the now-async _handle_vision_analyze. Verified via the real
registry.dispatch -> _run_async per-thread-loop path (16 concurrent calls,
peak bounded to cap).
2026-06-29 15:18:01 +10:00
|
|
|
|
async def _handle_vision_analyze(args: Dict[str, Any], **kw: Any) -> str:
|
2026-02-21 20:22:33 -08:00
|
|
|
|
image_url = args.get("image_url", "")
|
|
|
|
|
|
question = args.get("question", "")
|
feat(vision): vision_analyze returns pixels to vision-capable models, not aux text (#22955)
When the active main model has native vision and the provider supports
multimodal tool results (Anthropic, OpenAI Chat, Codex Responses, Gemini
3, OpenRouter, Nous), vision_analyze loads the image bytes and returns
them to the model as a multimodal tool-result envelope. The model then
sees the pixels directly on its next turn instead of receiving a lossy
text description from an auxiliary LLM.
Falls back to the legacy aux-LLM text path for non-vision models and
unverified providers.
Mirrors the architecture used in OpenCode, Claude Code, Codex CLI, and
Cline. All four converge on the same pattern: tool results carry image
content blocks for vision-capable provider/model combinations.
Changes
- tools/vision_tools.py: _vision_analyze_native fast path + provider
capability table (_supports_media_in_tool_results). Schema description
updated to reflect new behaviour.
- agent/codex_responses_adapter.py: function_call_output.output now
accepts the array form for multimodal tool results (was string-only).
Preflight validates input_text/input_image parts.
- agent/auxiliary_client.py: _RUNTIME_MAIN_PROVIDER/_MODEL globals so
tools see the live CLI/gateway override, not the stale config.yaml
default. set_runtime_main()/clear_runtime_main() helpers.
- run_agent.py: AIAgent.run_conversation calls set_runtime_main at turn
start so vision_analyze's fast-path check sees the actual runtime.
- tests/conftest.py: clear runtime-main override between tests.
Tests
- tests/tools/test_vision_native_fast_path.py: provider capability
table, envelope shape, fast-path gating (vision-capable model uses
fast path; non-vision model falls through to aux).
- tests/run_agent/test_codex_multimodal_tool_result.py: list tool
content becomes function_call_output.output array; preflight
preserves arrays and drops unknown part types.
Live verified
- Opus 4.6 + Sonnet 4.6 on OpenRouter: model calls vision_analyze on a
typed filepath, gets pixels back, reads exact text from images that
no aux description could capture (font color irony, multi-line
fruit-count list, etc.).
PR replaces the closed prior efforts (#16506 shipped the inbound user-
attached path; this PR closes the gap for tool-discovered images).
2026-05-09 21:06:19 -07:00
|
|
|
|
|
fix(vision): narrow the fan-out cap to the CPU encode burst only
The original cap held a process-global slot across the WHOLE vision
analysis (image load + encode + LLM call) with a default of min(CPUs, 4).
That serialized legitimate multi-image workflows — "compare these 6
screenshots", "read this 10-page scan", "analyze every frame" — behind a
4-wide gate, and on the native fast path it even throttled calls that make
no LLM request at all. Excess calls queued (blocking acquire, nothing
dropped), but the latency hit on real fan-out was the wrong tradeoff.
The incident was CPU exhaustion, not call count: concurrent base64/resize
bursts saturated every core and left none to service the shared event loop
serving /api/status. So cap ONLY that:
- A dedicated, bounded ThreadPoolExecutor (_vision_cpu_executor) runs the
encode/resize/dimension-check off the caller's loop, sized to the host's
usable core count with NO fixed ceiling — the cap tracks the actual
exhausted resource (cores), not a magic number. Excess encodes queue on
the executor; cores stay free for the loop.
- The LLM call is deliberately OUTSIDE the executor, so multi-image
workflows keep full request concurrency.
- Override via auxiliary.vision.max_concurrency / HERMES_VISION_MAX_CONCURRENCY
(honored verbatim, including above core count); sub-1 ignored.
- _vision_concurrency_slot() is now a no-op shim for back-compat.
Tests assert: resolver defaults to host cores with no ceiling; env/config
override (incl. above cores); sub-1 rejection; the executor is dedicated and
core-sized; encode runs on a vision-encode thread; and crucially that encode
bursts are bounded to the cap while the analyses themselves stay fully
concurrent (calls_peak > cap).
2026-06-28 22:48:37 -07:00
|
|
|
|
# The fan-out cap lives inside the encode/resize step (offloaded to the
|
|
|
|
|
|
# bounded _vision_cpu_executor), NOT around the whole analysis — so a
|
|
|
|
|
|
# legitimate multi-image workflow keeps full request concurrency while the
|
|
|
|
|
|
# CPU bursts that actually starve the loop are bounded to host cores.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Fast path: when native image routing is in effect for the active main
|
|
|
|
|
|
# model (provider accepts images in tool results, or the user set the
|
|
|
|
|
|
# model.supports_vision override), short-circuit the auxiliary LLM and
|
|
|
|
|
|
# return the image bytes as a multimodal tool-result envelope. The main
|
|
|
|
|
|
# model sees the pixels directly on its next turn — no aux call, no
|
|
|
|
|
|
# information loss, no extra latency.
|
|
|
|
|
|
if _should_use_native_vision_fast_path():
|
|
|
|
|
|
logger.info("vision_analyze: native fast path")
|
|
|
|
|
|
return await _vision_analyze_native(image_url, question)
|
|
|
|
|
|
|
|
|
|
|
|
# Legacy path: aux LLM describes the image and we return its text.
|
|
|
|
|
|
full_prompt = (
|
|
|
|
|
|
"Fully describe and explain everything about this image, then answer the "
|
|
|
|
|
|
f"following question:\n\n{question}"
|
|
|
|
|
|
)
|
|
|
|
|
|
model = os.getenv("AUXILIARY_VISION_MODEL", "").strip() or None
|
|
|
|
|
|
return await vision_analyze_tool(image_url, full_prompt, model)
|
2026-02-21 20:22:33 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
registry.register(
|
|
|
|
|
|
name="vision_analyze",
|
|
|
|
|
|
toolset="vision",
|
|
|
|
|
|
schema=VISION_ANALYZE_SCHEMA,
|
|
|
|
|
|
handler=_handle_vision_analyze,
|
|
|
|
|
|
check_fn=check_vision_requirements,
|
|
|
|
|
|
is_async=True,
|
2026-03-15 20:21:21 -07:00
|
|
|
|
emoji="👁️",
|
2026-02-21 20:22:33 -08:00
|
|
|
|
)
|
feat: add video_analyze tool for native video understanding (#19301)
* feat: add video_analyze tool for native video understanding
Adds a video_analyze tool that sends video files to multimodal LLMs
(e.g. Gemini) for analysis via the OpenRouter-compatible video_url
content type. Mirrors vision_analyze in structure, error handling,
and registration pattern.
Key design:
- Base64 encodes entire video (no frame extraction, no ffmpeg dep)
- Uses 'video_url' content block type (OpenRouter standard)
- Supports mp4, webm, mov, avi, mkv, mpeg formats
- 50 MB hard cap, 20 MB warning threshold
- 180s minimum timeout (videos take longer than images)
- AUXILIARY_VIDEO_MODEL env override, falls back to AUXILIARY_VISION_MODEL
- Same SSRF protection, retry logic, and cleanup as vision_analyze
Default disabled: registered in 'video' toolset (not in _HERMES_CORE_TOOLS).
Users opt in via: hermes tools enable video, or enabled_toolsets=['video'].
* feat(video): add models.dev capability pre-check + CONFIGURABLE_TOOLSETS entry
- Pre-checks model video capability via models.dev modalities.input
before expensive base64 encoding. Fails early with helpful message
suggesting video-capable alternatives (gemini, mimo-v2.5-pro).
- Passes optimistically if model unknown or lookup fails.
- Adds ModelInfo.supports_video_input() helper.
- Adds 'video' to CONFIGURABLE_TOOLSETS and _DEFAULT_OFF_TOOLSETS
so 'hermes tools enable video' works from CLI.
- 8 new tests for the capability check (37 total).
* refactor(video): remove models.dev capability pre-check
Removes _check_video_model_capability and ModelInfo.supports_video_input.
The vision_analyze tool doesn't pre-check image capability either — both
tools rely on the same pattern: send request, handle API errors gracefully
with categorized user-facing messages. The pre-check was inconsistent
(only worked for some providers/models) so drop it for parity.
* cleanup: compress comments, fix fragile timeout coupling
- Replace _VISION_DOWNLOAD_TIMEOUT * 2 with hardcoded 60s (no silent
breakage if vision timeout changes independently)
- Strip verbose comments and redundant log lines throughout
- No behavioral changes
2026-05-04 00:04:36 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Video Analysis Tool
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
# Extension → MIME. avi/mkv fall back to mp4.
|
|
|
|
|
|
_VIDEO_MIME_TYPES = {
|
|
|
|
|
|
".mp4": "video/mp4",
|
|
|
|
|
|
".webm": "video/webm",
|
|
|
|
|
|
".mov": "video/mov",
|
|
|
|
|
|
".avi": "video/mp4",
|
|
|
|
|
|
".mkv": "video/mp4",
|
|
|
|
|
|
".mpeg": "video/mpeg",
|
|
|
|
|
|
".mpg": "video/mpeg",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_MAX_VIDEO_BASE64_BYTES = 50 * 1024 * 1024 # 50 MB hard cap
|
|
|
|
|
|
_VIDEO_SIZE_WARN_BYTES = 20 * 1024 * 1024
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _detect_video_mime_type(video_path: Path) -> Optional[str]:
|
|
|
|
|
|
"""Return a video MIME type based on file extension, or None if unsupported."""
|
|
|
|
|
|
ext = video_path.suffix.lower()
|
|
|
|
|
|
return _VIDEO_MIME_TYPES.get(ext)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _video_to_base64_data_url(video_path: Path, mime_type: Optional[str] = None) -> str:
|
|
|
|
|
|
"""Convert a video file to a base64-encoded data URL."""
|
|
|
|
|
|
data = video_path.read_bytes()
|
|
|
|
|
|
encoded = base64.b64encode(data).decode("ascii")
|
|
|
|
|
|
mime = mime_type or _VIDEO_MIME_TYPES.get(video_path.suffix.lower(), "video/mp4")
|
|
|
|
|
|
return f"data:{mime};base64,{encoded}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _download_video(video_url: str, destination: Path, max_retries: int = 3) -> Path:
|
|
|
|
|
|
"""Download video from URL with SSRF protection and retry."""
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
|
|
|
|
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
async def _ssrf_redirect_guard(response):
|
|
|
|
|
|
if response.is_redirect and response.next_request:
|
|
|
|
|
|
redirect_url = str(response.next_request.url)
|
2026-06-04 05:57:11 -07:00
|
|
|
|
from tools.url_safety import async_is_safe_url
|
|
|
|
|
|
if not await async_is_safe_url(redirect_url):
|
feat: add video_analyze tool for native video understanding (#19301)
* feat: add video_analyze tool for native video understanding
Adds a video_analyze tool that sends video files to multimodal LLMs
(e.g. Gemini) for analysis via the OpenRouter-compatible video_url
content type. Mirrors vision_analyze in structure, error handling,
and registration pattern.
Key design:
- Base64 encodes entire video (no frame extraction, no ffmpeg dep)
- Uses 'video_url' content block type (OpenRouter standard)
- Supports mp4, webm, mov, avi, mkv, mpeg formats
- 50 MB hard cap, 20 MB warning threshold
- 180s minimum timeout (videos take longer than images)
- AUXILIARY_VIDEO_MODEL env override, falls back to AUXILIARY_VISION_MODEL
- Same SSRF protection, retry logic, and cleanup as vision_analyze
Default disabled: registered in 'video' toolset (not in _HERMES_CORE_TOOLS).
Users opt in via: hermes tools enable video, or enabled_toolsets=['video'].
* feat(video): add models.dev capability pre-check + CONFIGURABLE_TOOLSETS entry
- Pre-checks model video capability via models.dev modalities.input
before expensive base64 encoding. Fails early with helpful message
suggesting video-capable alternatives (gemini, mimo-v2.5-pro).
- Passes optimistically if model unknown or lookup fails.
- Adds ModelInfo.supports_video_input() helper.
- Adds 'video' to CONFIGURABLE_TOOLSETS and _DEFAULT_OFF_TOOLSETS
so 'hermes tools enable video' works from CLI.
- 8 new tests for the capability check (37 total).
* refactor(video): remove models.dev capability pre-check
Removes _check_video_model_capability and ModelInfo.supports_video_input.
The vision_analyze tool doesn't pre-check image capability either — both
tools rely on the same pattern: send request, handle API errors gracefully
with categorized user-facing messages. The pre-check was inconsistent
(only worked for some providers/models) so drop it for parity.
* cleanup: compress comments, fix fragile timeout coupling
- Replace _VISION_DOWNLOAD_TIMEOUT * 2 with hardcoded 60s (no silent
breakage if vision timeout changes independently)
- Strip verbose comments and redundant log lines throughout
- No behavioral changes
2026-05-04 00:04:36 +05:30
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"Blocked redirect to private/internal address: {redirect_url}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
last_error = None
|
|
|
|
|
|
for attempt in range(max_retries):
|
|
|
|
|
|
try:
|
|
|
|
|
|
blocked = check_website_access(video_url)
|
|
|
|
|
|
if blocked:
|
|
|
|
|
|
raise PermissionError(blocked["message"])
|
|
|
|
|
|
|
|
|
|
|
|
async with httpx.AsyncClient(
|
|
|
|
|
|
timeout=60.0,
|
|
|
|
|
|
follow_redirects=True,
|
|
|
|
|
|
event_hooks={"response": [_ssrf_redirect_guard]},
|
|
|
|
|
|
) as client:
|
|
|
|
|
|
response = await client.get(
|
|
|
|
|
|
video_url,
|
|
|
|
|
|
headers={
|
|
|
|
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
|
|
|
|
"Accept": "video/*,*/*;q=0.8",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
|
|
|
|
|
|
|
cl = response.headers.get("content-length")
|
|
|
|
|
|
if cl and int(cl) > _MAX_VIDEO_BASE64_BYTES:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"Video too large ({int(cl)} bytes, max {_MAX_VIDEO_BASE64_BYTES})"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
final_url = str(response.url)
|
|
|
|
|
|
blocked = check_website_access(final_url)
|
|
|
|
|
|
if blocked:
|
|
|
|
|
|
raise PermissionError(blocked["message"])
|
|
|
|
|
|
|
|
|
|
|
|
body = response.content
|
|
|
|
|
|
if len(body) > _MAX_VIDEO_BASE64_BYTES:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"Video too large ({len(body)} bytes, max {_MAX_VIDEO_BASE64_BYTES})"
|
|
|
|
|
|
)
|
|
|
|
|
|
destination.write_bytes(body)
|
|
|
|
|
|
|
|
|
|
|
|
return destination
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
last_error = e
|
|
|
|
|
|
if attempt < max_retries - 1:
|
|
|
|
|
|
wait_time = 2 ** (attempt + 1)
|
|
|
|
|
|
logger.warning("Video download failed (attempt %s/%s): %s", attempt + 1, max_retries, str(e)[:50])
|
|
|
|
|
|
await asyncio.sleep(wait_time)
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.error(
|
|
|
|
|
|
"Video download failed after %s attempts: %s",
|
|
|
|
|
|
max_retries, str(e)[:100], exc_info=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if last_error is None:
|
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
f"_download_video exited retry loop without attempting (max_retries={max_retries})"
|
|
|
|
|
|
)
|
|
|
|
|
|
raise last_error
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def video_analyze_tool(
|
|
|
|
|
|
video_url: str,
|
|
|
|
|
|
user_prompt: str,
|
|
|
|
|
|
model: str = None,
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
"""Analyze a video via multimodal LLM. Returns JSON {success, analysis}."""
|
2026-05-04 00:44:47 +03:00
|
|
|
|
if not isinstance(user_prompt, str):
|
|
|
|
|
|
user_prompt = str(user_prompt) if user_prompt is not None else ""
|
feat: add video_analyze tool for native video understanding (#19301)
* feat: add video_analyze tool for native video understanding
Adds a video_analyze tool that sends video files to multimodal LLMs
(e.g. Gemini) for analysis via the OpenRouter-compatible video_url
content type. Mirrors vision_analyze in structure, error handling,
and registration pattern.
Key design:
- Base64 encodes entire video (no frame extraction, no ffmpeg dep)
- Uses 'video_url' content block type (OpenRouter standard)
- Supports mp4, webm, mov, avi, mkv, mpeg formats
- 50 MB hard cap, 20 MB warning threshold
- 180s minimum timeout (videos take longer than images)
- AUXILIARY_VIDEO_MODEL env override, falls back to AUXILIARY_VISION_MODEL
- Same SSRF protection, retry logic, and cleanup as vision_analyze
Default disabled: registered in 'video' toolset (not in _HERMES_CORE_TOOLS).
Users opt in via: hermes tools enable video, or enabled_toolsets=['video'].
* feat(video): add models.dev capability pre-check + CONFIGURABLE_TOOLSETS entry
- Pre-checks model video capability via models.dev modalities.input
before expensive base64 encoding. Fails early with helpful message
suggesting video-capable alternatives (gemini, mimo-v2.5-pro).
- Passes optimistically if model unknown or lookup fails.
- Adds ModelInfo.supports_video_input() helper.
- Adds 'video' to CONFIGURABLE_TOOLSETS and _DEFAULT_OFF_TOOLSETS
so 'hermes tools enable video' works from CLI.
- 8 new tests for the capability check (37 total).
* refactor(video): remove models.dev capability pre-check
Removes _check_video_model_capability and ModelInfo.supports_video_input.
The vision_analyze tool doesn't pre-check image capability either — both
tools rely on the same pattern: send request, handle API errors gracefully
with categorized user-facing messages. The pre-check was inconsistent
(only worked for some providers/models) so drop it for parity.
* cleanup: compress comments, fix fragile timeout coupling
- Replace _VISION_DOWNLOAD_TIMEOUT * 2 with hardcoded 60s (no silent
breakage if vision timeout changes independently)
- Strip verbose comments and redundant log lines throughout
- No behavioral changes
2026-05-04 00:04:36 +05:30
|
|
|
|
debug_call_data = {
|
|
|
|
|
|
"parameters": {
|
|
|
|
|
|
"video_url": video_url,
|
|
|
|
|
|
"user_prompt": user_prompt[:200] + "..." if len(user_prompt) > 200 else user_prompt,
|
|
|
|
|
|
"model": model,
|
|
|
|
|
|
},
|
|
|
|
|
|
"error": None,
|
|
|
|
|
|
"success": False,
|
|
|
|
|
|
"analysis_length": 0,
|
|
|
|
|
|
"model_used": model,
|
|
|
|
|
|
"video_size_bytes": 0,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
temp_video_path = None
|
|
|
|
|
|
should_cleanup = True
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from tools.interrupt import is_interrupted
|
|
|
|
|
|
if is_interrupted():
|
|
|
|
|
|
return tool_error("Interrupted", success=False)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info("Analyzing video: %s", video_url[:60])
|
|
|
|
|
|
logger.info("User prompt: %s", user_prompt[:100])
|
|
|
|
|
|
|
|
|
|
|
|
# Resolve local path vs remote URL
|
|
|
|
|
|
resolved_url = video_url
|
|
|
|
|
|
if resolved_url.startswith("file://"):
|
|
|
|
|
|
resolved_url = resolved_url[len("file://"):]
|
|
|
|
|
|
local_path = Path(os.path.expanduser(resolved_url))
|
|
|
|
|
|
|
|
|
|
|
|
if local_path.is_file():
|
|
|
|
|
|
logger.info("Using local video file: %s", video_url)
|
|
|
|
|
|
temp_video_path = local_path
|
|
|
|
|
|
should_cleanup = False
|
2026-06-04 05:57:11 -07:00
|
|
|
|
elif await _validate_image_url_async(video_url):
|
feat: add video_analyze tool for native video understanding (#19301)
* feat: add video_analyze tool for native video understanding
Adds a video_analyze tool that sends video files to multimodal LLMs
(e.g. Gemini) for analysis via the OpenRouter-compatible video_url
content type. Mirrors vision_analyze in structure, error handling,
and registration pattern.
Key design:
- Base64 encodes entire video (no frame extraction, no ffmpeg dep)
- Uses 'video_url' content block type (OpenRouter standard)
- Supports mp4, webm, mov, avi, mkv, mpeg formats
- 50 MB hard cap, 20 MB warning threshold
- 180s minimum timeout (videos take longer than images)
- AUXILIARY_VIDEO_MODEL env override, falls back to AUXILIARY_VISION_MODEL
- Same SSRF protection, retry logic, and cleanup as vision_analyze
Default disabled: registered in 'video' toolset (not in _HERMES_CORE_TOOLS).
Users opt in via: hermes tools enable video, or enabled_toolsets=['video'].
* feat(video): add models.dev capability pre-check + CONFIGURABLE_TOOLSETS entry
- Pre-checks model video capability via models.dev modalities.input
before expensive base64 encoding. Fails early with helpful message
suggesting video-capable alternatives (gemini, mimo-v2.5-pro).
- Passes optimistically if model unknown or lookup fails.
- Adds ModelInfo.supports_video_input() helper.
- Adds 'video' to CONFIGURABLE_TOOLSETS and _DEFAULT_OFF_TOOLSETS
so 'hermes tools enable video' works from CLI.
- 8 new tests for the capability check (37 total).
* refactor(video): remove models.dev capability pre-check
Removes _check_video_model_capability and ModelInfo.supports_video_input.
The vision_analyze tool doesn't pre-check image capability either — both
tools rely on the same pattern: send request, handle API errors gracefully
with categorized user-facing messages. The pre-check was inconsistent
(only worked for some providers/models) so drop it for parity.
* cleanup: compress comments, fix fragile timeout coupling
- Replace _VISION_DOWNLOAD_TIMEOUT * 2 with hardcoded 60s (no silent
breakage if vision timeout changes independently)
- Strip verbose comments and redundant log lines throughout
- No behavioral changes
2026-05-04 00:04:36 +05:30
|
|
|
|
blocked = check_website_access(video_url)
|
|
|
|
|
|
if blocked:
|
|
|
|
|
|
raise PermissionError(blocked["message"])
|
|
|
|
|
|
temp_dir = get_hermes_dir("cache/video", "temp_video_files")
|
|
|
|
|
|
temp_video_path = temp_dir / f"temp_video_{uuid.uuid4()}.mp4"
|
|
|
|
|
|
await _download_video(video_url, temp_video_path)
|
|
|
|
|
|
should_cleanup = True
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
"Invalid video source. Provide an HTTP/HTTPS URL or a valid local file path."
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
video_size_bytes = temp_video_path.stat().st_size
|
|
|
|
|
|
video_size_mb = video_size_bytes / (1024 * 1024)
|
|
|
|
|
|
logger.info("Video ready (%.1f MB)", video_size_mb)
|
|
|
|
|
|
|
|
|
|
|
|
detected_mime = _detect_video_mime_type(temp_video_path)
|
|
|
|
|
|
if not detected_mime:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"Unsupported video format: '{temp_video_path.suffix}'. "
|
|
|
|
|
|
f"Supported: {', '.join(sorted(_VIDEO_MIME_TYPES.keys()))}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if video_size_bytes > _VIDEO_SIZE_WARN_BYTES:
|
|
|
|
|
|
logger.warning("Video is %.1f MB — may be slow or rejected", video_size_mb)
|
|
|
|
|
|
|
|
|
|
|
|
video_data_url = _video_to_base64_data_url(temp_video_path, mime_type=detected_mime)
|
|
|
|
|
|
data_size_mb = len(video_data_url) / (1024 * 1024)
|
|
|
|
|
|
|
|
|
|
|
|
if len(video_data_url) > _MAX_VIDEO_BASE64_BYTES:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"Video too large for API: base64 payload is {data_size_mb:.1f} MB "
|
|
|
|
|
|
f"(limit {_MAX_VIDEO_BASE64_BYTES / (1024 * 1024):.0f} MB). "
|
|
|
|
|
|
f"Compress or trim the video and retry."
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
debug_call_data["video_size_bytes"] = video_size_bytes
|
|
|
|
|
|
|
|
|
|
|
|
messages = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"role": "user",
|
|
|
|
|
|
"content": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "text",
|
|
|
|
|
|
"text": user_prompt,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "video_url",
|
|
|
|
|
|
"video_url": {
|
|
|
|
|
|
"url": video_data_url,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
vision_timeout = 180.0
|
|
|
|
|
|
vision_temperature = 0.1
|
|
|
|
|
|
try:
|
|
|
|
|
|
from hermes_cli.config import cfg_get, load_config
|
|
|
|
|
|
_cfg = load_config()
|
|
|
|
|
|
_vision_cfg = cfg_get(_cfg, "auxiliary", "vision", default={})
|
|
|
|
|
|
_vt = _vision_cfg.get("timeout")
|
|
|
|
|
|
if _vt is not None:
|
|
|
|
|
|
vision_timeout = max(float(_vt), 180.0)
|
|
|
|
|
|
_vtemp = _vision_cfg.get("temperature")
|
|
|
|
|
|
if _vtemp is not None:
|
|
|
|
|
|
vision_temperature = float(_vtemp)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
call_kwargs = {
|
|
|
|
|
|
"task": "vision",
|
|
|
|
|
|
"messages": messages,
|
|
|
|
|
|
"temperature": vision_temperature,
|
|
|
|
|
|
"max_tokens": 4000,
|
|
|
|
|
|
"timeout": vision_timeout,
|
|
|
|
|
|
}
|
|
|
|
|
|
if model:
|
|
|
|
|
|
call_kwargs["model"] = model
|
|
|
|
|
|
|
|
|
|
|
|
response = await async_call_llm(**call_kwargs)
|
|
|
|
|
|
analysis = extract_content_or_reasoning(response)
|
|
|
|
|
|
|
|
|
|
|
|
if not analysis:
|
|
|
|
|
|
logger.warning("Empty video response, retrying once")
|
|
|
|
|
|
response = await async_call_llm(**call_kwargs)
|
|
|
|
|
|
analysis = extract_content_or_reasoning(response)
|
|
|
|
|
|
|
|
|
|
|
|
analysis_length = len(analysis) if analysis else 0
|
|
|
|
|
|
logger.info("Video analysis completed (%s characters)", analysis_length)
|
|
|
|
|
|
|
|
|
|
|
|
result = {
|
|
|
|
|
|
"success": True,
|
|
|
|
|
|
"analysis": analysis or "There was a problem with the request and the video could not be analyzed.",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
debug_call_data["success"] = True
|
|
|
|
|
|
debug_call_data["analysis_length"] = analysis_length
|
|
|
|
|
|
_debug.log_call("video_analyze_tool", debug_call_data)
|
|
|
|
|
|
_debug.save()
|
|
|
|
|
|
|
|
|
|
|
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
error_msg = f"Error analyzing video: {str(e)}"
|
|
|
|
|
|
logger.error("%s", error_msg, exc_info=True)
|
|
|
|
|
|
|
|
|
|
|
|
err_str = str(e).lower()
|
|
|
|
|
|
if any(hint in err_str for hint in (
|
|
|
|
|
|
"402", "insufficient", "payment required", "credits", "billing",
|
|
|
|
|
|
)):
|
|
|
|
|
|
analysis = (
|
|
|
|
|
|
"Insufficient credits or payment required. Please top up your "
|
|
|
|
|
|
f"API provider account and try again. Error: {e}"
|
|
|
|
|
|
)
|
|
|
|
|
|
elif any(hint in err_str for hint in (
|
|
|
|
|
|
"does not support", "not support video",
|
|
|
|
|
|
"content_policy", "multimodal",
|
|
|
|
|
|
"unrecognized request argument", "video input",
|
|
|
|
|
|
"video_url",
|
|
|
|
|
|
)):
|
|
|
|
|
|
analysis = (
|
|
|
|
|
|
f"The model does not support video analysis or the request was "
|
|
|
|
|
|
f"rejected. Ensure you're using a video-capable model "
|
|
|
|
|
|
f"(e.g. google/gemini-2.5-flash). Error: {e}"
|
|
|
|
|
|
)
|
|
|
|
|
|
elif any(hint in err_str for hint in (
|
|
|
|
|
|
"too large", "payload", "413", "content_too_large",
|
|
|
|
|
|
"request_too_large", "exceeds", "size limit",
|
|
|
|
|
|
)):
|
|
|
|
|
|
analysis = (
|
|
|
|
|
|
"The video is too large for the API. Try compressing or trimming "
|
|
|
|
|
|
f"the video (max ~50 MB). Error: {e}"
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
analysis = (
|
|
|
|
|
|
"There was a problem with the request and the video could not "
|
|
|
|
|
|
f"be analyzed. Error: {e}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
result = {
|
|
|
|
|
|
"success": False,
|
|
|
|
|
|
"error": error_msg,
|
|
|
|
|
|
"analysis": analysis,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
debug_call_data["error"] = error_msg
|
|
|
|
|
|
_debug.log_call("video_analyze_tool", debug_call_data)
|
|
|
|
|
|
_debug.save()
|
|
|
|
|
|
|
|
|
|
|
|
return json.dumps(result, indent=2, ensure_ascii=False)
|
|
|
|
|
|
|
|
|
|
|
|
finally:
|
|
|
|
|
|
if should_cleanup and temp_video_path and temp_video_path.exists():
|
|
|
|
|
|
try:
|
|
|
|
|
|
temp_video_path.unlink()
|
|
|
|
|
|
logger.debug("Cleaned up temporary video file")
|
|
|
|
|
|
except Exception as cleanup_error:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Could not delete temporary file: %s", cleanup_error, exc_info=True
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
VIDEO_ANALYZE_SCHEMA = {
|
|
|
|
|
|
"name": "video_analyze",
|
|
|
|
|
|
"description": (
|
|
|
|
|
|
"Analyze a video from a URL or local file path using a multimodal AI model. "
|
|
|
|
|
|
"Sends the video to a video-capable model (e.g. Gemini) for understanding. "
|
|
|
|
|
|
"Use this for video files — for images, use vision_analyze instead. "
|
|
|
|
|
|
"Supports mp4, webm, mov, avi, mkv, mpeg formats. "
|
|
|
|
|
|
"Note: large videos (>20 MB) may be slow; max ~50 MB."
|
|
|
|
|
|
),
|
|
|
|
|
|
"parameters": {
|
|
|
|
|
|
"type": "object",
|
|
|
|
|
|
"properties": {
|
|
|
|
|
|
"video_url": {
|
|
|
|
|
|
"type": "string",
|
|
|
|
|
|
"description": "Video URL (http/https) or local file path to analyze.",
|
|
|
|
|
|
},
|
|
|
|
|
|
"question": {
|
|
|
|
|
|
"type": "string",
|
|
|
|
|
|
"description": "Your specific question about the video. The AI will describe what happens in the video and answer your question.",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
"required": ["video_url", "question"],
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _handle_video_analyze(args: Dict[str, Any], **kw: Any) -> Awaitable[str]:
|
|
|
|
|
|
video_url = args.get("video_url", "")
|
|
|
|
|
|
question = args.get("question", "")
|
|
|
|
|
|
full_prompt = (
|
|
|
|
|
|
"Fully describe and explain everything happening in this video, "
|
|
|
|
|
|
"including visual content, motion, audio cues, text overlays, and scene "
|
|
|
|
|
|
f"transitions. Then answer the following question:\n\n{question}"
|
|
|
|
|
|
)
|
|
|
|
|
|
model = os.getenv("AUXILIARY_VIDEO_MODEL", "").strip() or os.getenv("AUXILIARY_VISION_MODEL", "").strip() or None
|
|
|
|
|
|
return video_analyze_tool(video_url, full_prompt, model)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
registry.register(
|
|
|
|
|
|
name="video_analyze",
|
|
|
|
|
|
toolset="video",
|
|
|
|
|
|
schema=VIDEO_ANALYZE_SCHEMA,
|
|
|
|
|
|
handler=_handle_video_analyze,
|
|
|
|
|
|
check_fn=check_vision_requirements,
|
|
|
|
|
|
is_async=True,
|
|
|
|
|
|
emoji="🎬",
|
|
|
|
|
|
)
|