Simplify Vision Model routing and execution

Alessandro committed Aug 25, 2026 at 20:20 UTC 2c09a4ed7241d9a2966f14baccda2d39cd0dcc9a
12 files changed +162 -263
extensions/python/system_prompt/_11_tools_prompt.py
+3 -6
@@ -53,14 +53,11 @@ async def build_prompt(agent: Agent) -> str:
53 # vision support
54 from plugins._model_config.helpers.model_config import (
55 get_chat_model_config,
56 - use_vision_sidecar,
56 + get_vision_model_config,
57 )
58
59 chat_cfg = get_chat_model_config(agent)
60 - sidecar = use_vision_sidecar(agent)
61 - if sidecar or chat_cfg.get("vision", False):
62 - prompt += "\n\n" + agent.read_prompt(
63 - "agent.system.tools_vision.md", sidecar=sidecar
64 - )
60 + if get_vision_model_config(agent) or chat_cfg.get("vision", False):
61 + prompt += "\n\n" + agent.read_prompt("agent.system.tools_vision.md")
62
63 return prompt
helpers/responses_tools.py
+6 -4
@@ -116,13 +116,15 @@ def _vision_tool_prompt(agent: Any) -> str:
116 try:
117 from plugins._model_config.helpers.model_config import (
118 get_chat_model_config,
119 - use_vision_sidecar,
119 + get_vision_model_config,
120 )
121
122 - sidecar = use_vision_sidecar(agent)
123 - if not (sidecar or get_chat_model_config(agent).get("vision", False)):
122 + if not (
123 + get_vision_model_config(agent)
124 + or get_chat_model_config(agent).get("vision", False)
125 + ):
126 return ""
125 - return agent.read_prompt("agent.system.tools_vision.md", sidecar=sidecar)
127 + return agent.read_prompt("agent.system.tools_vision.md")
128 except Exception:
129 return ""
130
plugins/_model_config/AGENTS.md
+1 -1
@@ -23,7 +23,7 @@
23 - Coordinate OAuth-backed providers with `_oauth` instead of hardcoding provider-specific auth here.
24 - `model_config_get` exposes `model_configured` as a derived chat-model readiness flag from provider, model name, and API-key availability.
25 - Non-default presets may inherit omitted main, utility, or embedding slots and durable tuning from `Default`, but must replace or clear per-slot `kwargs` so provider-specific extra params never leak across model providers.
26 -- The optional `vision` slot is strictly per preset and never inherited from `Default`; an empty slot disables sidecar vision for that preset.
26 +- The optional `vision` slot is strictly per preset and never inherited from `Default`; an empty slot disables the separate Vision Model for that preset.
27 - Main native vision wins by default. A configured Vision Model handles `vision_load` when Main lacks vision, or when that preset explicitly enables `override_main`.
28 - Keep the optional Vision provider/model selector inside the Main Model card and flush with Main's field alignment, without a nested left inset. Show it only while Main vision is disabled or `override_main` is enabled; do not render a standalone Vision Model card.
29 - Show `Use separate Vision Model` immediately below `Supports Vision` while Main vision is enabled, not inside Advanced Settings; describe the disabled state as using Main's native vision.
plugins/_model_config/helpers/model_config.py
+14 -16
@@ -759,20 +759,17 @@ def get_chat_model_config(agent=None) -> dict:
759
760
761 def get_vision_model_config(agent=None) -> dict:
762 - """Get the optional, strictly per-preset Vision Model config."""
763 - return get_effective_config(agent).get("vision_model", {})
764 -
765 -
766 -def use_vision_sidecar(agent=None) -> bool:
767 - """Return whether vision_load should delegate to the preset's Vision Model."""
768 - vision_cfg = get_vision_model_config(agent)
769 - if not (
770 - str(vision_cfg.get("provider") or "").strip()
771 - and str(vision_cfg.get("name") or "").strip()
772 - ):
773 - return False
774 - chat_cfg = get_chat_model_config(agent)
775 - return not bool(chat_cfg.get("vision")) or bool(vision_cfg.get("override_main"))
762 + """Get the active Vision Model config after applying Main-first routing."""
763 + cfg = get_effective_config(agent)
764 + vision_cfg = cfg.get("vision_model", {})
765 + if not _slot_has_identity(vision_cfg):
766 + return {}
767 + chat_cfg = cfg.get("chat_model", {})
768 + return (
769 + vision_cfg
770 + if not chat_cfg.get("vision") or vision_cfg.get("override_main")
771 + else {}
772 + )
773
774
775 def get_utility_model_config(agent=None) -> dict:
@@ -928,8 +925,9 @@ def get_missing_api_key_providers(agent=None) -> list[dict]:
925 ("Utility Model", cfg.get("utility_model", {})),
926 ("Embedding Model", get_embedding_model_config(agent)),
927 ]
931 - if use_vision_sidecar(agent):
932 - checks.insert(1, ("Vision Model", cfg.get("vision_model", {})))
928 + vision_cfg = get_vision_model_config(agent)
929 + if vision_cfg:
930 + checks.insert(1, ("Vision Model", vision_cfg))
931
932 for label, model_cfg in checks:
933 provider = model_cfg.get("provider", "")
prompts/AGENTS.md
+1 -1
@@ -25,7 +25,7 @@
25 - Read the rendering path before changing placeholders or filenames.
26 - Prefer small prompt additions over broad rewrites when fixing a specific behavior.
27 - Keep document/OCR routing explicit: image files, screenshots, scans, charts, photos, and diagrams should prefer vision tools when available, while `document_query` is for documents, large text-heavy files, and fallback OCR.
28 -- Keep the single `vision_load` prompt accurate for both native and sidecar routing: related images belong in one call, while a sidecar result is text-only unless Main explicitly requests its native raw route.
28 +- Keep the single `vision_load` prompt accurate for both native and separate Vision Model routing: related images belong in one call, and a Vision Model result is text-only.
29 - Update tests or snapshots when prompt budget, required sections, or generated system content changes.
30
31 ## Verification
prompts/agent.system.tools_vision.md
+7 -22
@@ -1,10 +1,8 @@
1 ## multimodal vision tools
2
3 ### vision_load
4 -{{if sidecar}}analyze images with the preset's separate Vision Model and return a text result{{endif}}
5 -{{if not sidecar}}load images into Main for visual reasoning{{endif}}
6 -args: `paths` list of absolute image paths or ephemeral image refs{{if sidecar}}, `query` optional focused instruction, `raw` optional boolean{{endif}}
7 -{{if sidecar}}
4 +load or analyze images for visual reasoning
5 +args: `paths` list of absolute image paths or ephemeral image refs, `query` optional focused instruction
6 Input schema for tool_args:
7 ```json
8 {
@@ -12,32 +10,20 @@ Input schema for tool_args:
10 "properties": {
11 "paths": {
12 "type": "array",
15 - "items": {"type": "string"},
16 - "description": "Absolute image paths or ephemeral image refs."
13 + "items": {"type": "string"}
14 },
18 - "query": {
19 - "type": "string",
20 - "description": "What the Vision Model should inspect, compare, locate, or read."
21 - },
22 - "raw": {
23 - "type": "boolean",
24 - "description": "Use Main's native vision instead of the separate Vision Model, when Main supports vision."
25 - }
15 + "query": {"type": "string"}
16 },
17 "required": ["paths"],
18 "additionalProperties": false
19 }
20 ```
31 -{{endif}}
21 rules:
22 - put all images needed for one comparison or visual task in the same `paths` array
23 - use when the task depends on screenshots, diagrams, scanned documents, charts, or photos
35 -{{if sidecar}}
36 -- when the separate Vision Model is active, use a focused `query`; the result is a text capsule and Main does not receive raw images
37 -- use `raw=true` only when Main supports vision and must inspect the pixels itself
38 -{{endif}}
24 +- use a focused `query` when asking the configured Vision Model to inspect, compare, locate, or read something
25 - only bitmaps are supported; convert other formats first if needed
40 -- the tool result includes loaded/skipped image totals and the corresponding path lists
26 +- the tool result reports loaded and skipped image counts
27 example:
28 ```json
29 {
@@ -47,9 +33,8 @@ example:
33 "headline": "Comparing screenshots",
34 "tool_name": "vision_load",
35 "tool_args": {
50 - "paths": ["/path/to/before.png", "/path/to/after.png"]{{if sidecar}},
36 + "paths": ["/path/to/before.png", "/path/to/after.png"],
37 "query": "Compare the error banners and describe what changed."
52 - {{endif}}
38 }
39 }
40 ```
tests/test_browser_agent_regressions.py
+10 -3
@@ -63,6 +63,15 @@ class _TestTool:
63 self.message = message
64 self.loop_data = loop_data
65
66 + async def after_execution(self, response, **kwargs):
67 + self.agent.hist_add_tool_result(
68 + self.name,
69 + response.message.strip(),
70 + id=self.log.id,
71 + **(response.additional or {}),
72 + )
73 + self.log.update(content=response.message.strip())
74 +
75
76 class _TestWsHandler:
77 def __init__(self, *args, **kwargs):
@@ -102,7 +111,6 @@ _model_config_stub.get_presets = lambda: []
111 _model_config_stub.get_preset_by_name = lambda name: None
112 _model_config_stub.get_chat_model_config = lambda agent=None: {}
113 _model_config_stub.get_vision_model_config = lambda agent=None: {}
105 -_model_config_stub.use_vision_sidecar = lambda agent=None: False
114 _model_config_stub.build_vision_model = lambda agent=None: None
115 sys.modules.setdefault("plugins._model_config.helpers.model_config", _model_config_stub)
116
@@ -4307,7 +4315,6 @@ async def test_vision_load_materializes_ephemeral_browser_refs(monkeypatch, tmp_
4315 monkeypatch.setattr(vision_load_module.chat_media.files, "normalize_a0_path", fake_normalize_a0_path)
4316 monkeypatch.setattr(vision_load_module, "get_chat_model_config", lambda _agent: {"vision": True, "max_embeds": 10})
4317 monkeypatch.setattr(vision_load_module, "get_vision_model_config", lambda _agent: {})
4310 - monkeypatch.setattr(vision_load_module, "use_vision_sidecar", lambda _agent: False)
4318
4319 tool_results = []
4320 messages = []
@@ -4344,7 +4351,7 @@ async def test_vision_load_materializes_ephemeral_browser_refs(monkeypatch, tmp_
4351 assert stored_ref.startswith("/a0/usr/chats/ctx-vision/screenshots/browser/browser-shot-")
4352 stored_path = tmp_path / stored_ref.removeprefix("/a0/")
4353 assert stored_path.read_bytes() == __import__("base64").b64decode(SMALL_JPEG_10X10)
4347 - assert updates[-1]["result"] == "1 images loaded, 0 skipped"
4354 + assert updates[-1]["content"] == response.message
4355
4356
4357 @pytest.mark.anyio
tests/test_model_config_api_keys.py
+2 -4
@@ -93,7 +93,7 @@ def test_missing_api_key_checks_only_the_active_vision_model(monkeypatch):
93 from plugins._model_config.helpers import model_config
94
95 config = {
96 - "chat_model": {"provider": "ollama", "name": "text-main"},
96 + "chat_model": {"provider": "ollama", "name": "text-main", "vision": True},
97 "vision_model": {"provider": "openai", "name": "vision"},
98 "utility_model": {"provider": "ollama", "name": "utility"},
99 "embedding_model": {
@@ -101,19 +101,17 @@ def test_missing_api_key_checks_only_the_active_vision_model(monkeypatch):
101 "name": "sentence-transformers/all-MiniLM-L6-v2",
102 },
103 }
104 - active = False
104 monkeypatch.setattr(model_config, "get_effective_config", lambda _agent=None: config)
105 monkeypatch.setattr(
106 model_config,
107 "get_embedding_model_config",
108 lambda _agent=None: config["embedding_model"],
109 )
111 - monkeypatch.setattr(model_config, "use_vision_sidecar", lambda _agent=None: active)
110 monkeypatch.setattr(model_config, "has_provider_api_key", lambda *args: False)
111
112 assert model_config.get_missing_api_key_providers() == []
113
116 - active = True
114 + config["chat_model"]["vision"] = False
115 assert model_config.get_missing_api_key_providers() == [
116 {"model_type": "Vision Model", "provider": "openai"}
117 ]
tests/test_tool_policy.py
+13 -17
@@ -8,7 +8,7 @@ from types import SimpleNamespace
8 import pytest
9
10 from extensions.python.system_prompt import _11_tools_prompt, _13_skills_prompt
11 -from helpers import files, mcp_handler, responses_tools, tool_policy
11 +from helpers import mcp_handler, responses_tools, tool_policy
12 from helpers.errors import RepairableException
13 from plugins._tool_access.extensions.python.tool_execute_before._10_enforce_tool_policy import (
14 EnforceToolPolicy,
@@ -528,6 +528,10 @@ async def test_vision_tool_follows_chat_config_not_profile_policy(
528 "plugins._model_config.helpers.model_config.get_chat_model_config",
529 lambda agent: {"vision": True},
530 )
531 + monkeypatch.setattr(
532 + "plugins._model_config.helpers.model_config.get_vision_model_config",
533 + lambda agent: {},
534 + )
535 monkeypatch.setattr(
536 tool_policy,
537 "get_policy",
@@ -547,7 +551,7 @@ async def test_vision_tool_follows_chat_config_not_profile_policy(
551
552
553 @pytest.mark.asyncio
550 -async def test_vision_sidecar_uses_canonical_vision_prompt(
554 +async def test_active_vision_model_uses_canonical_vision_prompt(
555 monkeypatch, tmp_path: Path
556 ) -> None:
557 _write_prompt(tmp_path, "agent.system.tools.md", "TOOLS\n{{tools}}")
@@ -559,11 +563,11 @@ async def test_vision_sidecar_uses_canonical_vision_prompt(
563 monkeypatch.setattr(tool_policy.subagents, "get_paths", _prompt_paths(tmp_path))
564 monkeypatch.setattr(
565 "plugins._model_config.helpers.model_config.get_chat_model_config",
562 - lambda agent: {"vision": True},
566 + lambda agent: {"vision": False},
567 )
568 monkeypatch.setattr(
565 - "plugins._model_config.helpers.model_config.use_vision_sidecar",
566 - lambda agent: True,
569 + "plugins._model_config.helpers.model_config.get_vision_model_config",
570 + lambda agent: {"provider": "test", "name": "vision"},
571 )
572 monkeypatch.setattr(responses_tools, "_mcp_tools", lambda agent: [])
573 agent = _Agent(tmp_path)
@@ -582,24 +586,16 @@ def test_vision_prompt_declares_multi_image_native_schema() -> None:
586 / "prompts"
587 / "agent.system.tools_vision.md"
588 ).read_text(encoding="utf-8")
585 - sidecar_prompt = files.evaluate_text_conditions(source, sidecar=True)
586 - native_prompt = files.evaluate_text_conditions(source, sidecar=False)
587 - schema = responses_tools._schema_from_prompt(sidecar_prompt)
589 + schema = responses_tools._schema_from_prompt(source)
590
591 assert schema["required"] == ["paths"]
592 assert schema["properties"]["paths"] == {
593 "type": "array",
594 "items": {"type": "string"},
593 - "description": "Absolute image paths or ephemeral image refs.",
594 - }
595 - assert {"query", "raw"} <= schema["properties"].keys()
596 - assert "separate Vision Model" in sidecar_prompt
597 - assert "separate Vision Model" not in native_prompt
598 - assert responses_tools._schema_from_prompt(native_prompt) == {
599 - "type": "object",
600 - "properties": {},
601 - "additionalProperties": True,
595 }
596 + assert "query" in schema["properties"]
597 + assert "raw" not in schema["properties"]
598 + assert "Vision Model" in source
599
600
601 def test_mcp_prompt_and_native_schema_omit_blocked_tool(
tests/test_vision_load_image_refs.py
+19 -29
@@ -41,6 +41,15 @@ class _TestTool:
41 self.message = message
42 self.loop_data = loop_data
43
44 + async def after_execution(self, response, **kwargs):
45 + self.agent.hist_add_tool_result(
46 + self.name,
47 + response.message.strip(),
48 + id=self.log.id,
49 + **(response.additional or {}),
50 + )
51 + self.log.update(content=response.message.strip())
52 +
53
54 def _install_tool_stub(monkeypatch):
55 tool_stub = types.ModuleType("helpers.tool")
@@ -82,7 +91,6 @@ async def test_vision_load_materializes_local_image_to_chat_artifact(monkeypatch
91 monkeypatch.setattr(vision_load_module.chat_media.files, "normalize_a0_path", fake_normalize_a0_path)
92 monkeypatch.setattr(vision_load_module, "get_chat_model_config", lambda _agent: {"vision": True, "max_embeds": 10})
93 monkeypatch.setattr(vision_load_module, "get_vision_model_config", lambda _agent: {})
85 - monkeypatch.setattr(vision_load_module, "use_vision_sidecar", lambda _agent: False)
94
95 async def direct_call(func, *args, **kwargs):
96 return func(*args, **kwargs)
@@ -115,6 +123,9 @@ async def test_vision_load_materializes_local_image_to_chat_artifact(monkeypatch
123 )
124 tool.log = SimpleNamespace(id="vision-log", update=lambda **kwargs: updates.append(kwargs))
125
126 + invalid = await tool.execute(paths=None)
127 + assert invalid.message == "vision_load error: `paths` must be an array."
128 +
129 response = await tool.execute(paths=[str(image_path)])
130 image_path.unlink()
131 await tool.after_execution(response)
@@ -124,10 +135,10 @@ async def test_vision_load_materializes_local_image_to_chat_artifact(monkeypatch
135 assert stored_ref.startswith("/a0/usr/chats/ctx-vision/images/vision-load/sample-image-")
136 stored_path = tmp_path / stored_ref.removeprefix("/a0/")
137 assert stored_path.read_bytes() == b"png-data"
127 - assert updates[-1]["result"] == "1 images loaded, 0 skipped"
138 + assert updates[-1]["content"] == response.message
139
140
130 -def test_vision_sidecar_route_matrix_prefers_main_native_vision(monkeypatch):
141 +def test_active_vision_model_route_prefers_main_native_vision(monkeypatch):
142 from plugins._model_config.helpers import model_config
143
144 cases = [
@@ -149,11 +160,11 @@ def test_vision_sidecar_route_matrix_prefers_main_native_vision(monkeypatch):
160 "vision_model": vision,
161 },
162 )
152 - assert model_config.use_vision_sidecar() is expected
163 + assert bool(model_config.get_vision_model_config()) is expected
164
165
166 @pytest.mark.anyio
156 -async def test_vision_sidecar_sends_multiple_images_once_and_keeps_history_text_only(
167 +async def test_vision_model_sends_multiple_images_once_and_keeps_history_text_only(
168 monkeypatch,
169 tmp_path,
170 ):
@@ -182,7 +193,6 @@ async def test_vision_sidecar_sends_multiple_images_once_and_keeps_history_text_
193 "get_vision_model_config",
194 lambda _agent: {"provider": "test", "name": "vision", "max_embeds": 5},
195 )
185 - monkeypatch.setattr(vision_load_module, "use_vision_sidecar", lambda _agent: True)
196
197 image_paths = [tmp_path / "before.png", tmp_path / "after.png"]
198 for path in image_paths:
@@ -214,7 +224,7 @@ async def test_vision_sidecar_sends_multiple_images_once_and_keeps_history_text_
224 await tool.after_execution(response)
225
226 assert len(calls) == 1
217 - content = calls[0]["messages"][1].content
227 + content = calls[0]["messages"][0].content
228 assert content[0] == {"type": "text", "text": "Compare the login errors."}
229 assert [item["type"] for item in content].count("image_url") == 2
230 assert "max_tokens" not in calls[0]
@@ -240,29 +250,12 @@ async def test_parallel_worker_consumes_parent_ephemeral_image(monkeypatch, tmp_
250 monkeypatch.setattr(vision_load_module.chat_media.files, "get_abs_path", fake_get_abs_path)
251 monkeypatch.setattr(vision_load_module.chat_media.files, "normalize_a0_path", fake_normalize_a0_path)
252 parent_id = "parent-vision"
243 - parent_agent = SimpleNamespace(
244 - context=SimpleNamespace(id=parent_id),
245 - agent_name="Parent Agent",
246 - )
247 - parent_context = SimpleNamespace(agent0=parent_agent)
248 - agent_stub = types.ModuleType("agent")
249 - agent_stub.AgentContext = SimpleNamespace(
250 - get=lambda context_id: parent_context if context_id == parent_id else None
251 - )
252 - monkeypatch.setitem(sys.modules, "agent", agent_stub)
253 - config_owners = []
254 -
255 - def get_chat_config(owner):
256 - config_owners.append(owner)
257 - return {"vision": True, "max_embeds": 10}
258 -
253 monkeypatch.setattr(
254 vision_load_module,
255 "get_chat_model_config",
262 - get_chat_config,
256 + lambda _agent: {"vision": True, "max_embeds": 10},
257 )
258 monkeypatch.setattr(vision_load_module, "get_vision_model_config", lambda _agent: {})
265 - monkeypatch.setattr(vision_load_module, "use_vision_sidecar", lambda _agent: False)
259
260 ref = vision_load_module.ephemeral_images.put_image_bytes(
261 context_id=parent_id,
@@ -288,8 +281,6 @@ async def test_parallel_worker_consumes_parent_ephemeral_image(monkeypatch, tmp_
281
282 await tool.execute(paths=[ref])
283
291 - assert tool._config_owner is parent_agent
292 - assert config_owners and all(owner is parent_agent for owner in config_owners)
284 assert tool._context_id() == parent_id
285 assert tool.loaded_paths == ["shot.png"]
286 assert vision_load_module.ephemeral_images.get_image(ref, context_id=parent_id) is None
@@ -298,7 +289,7 @@ async def test_parallel_worker_consumes_parent_ephemeral_image(monkeypatch, tmp_
289
290
291 @pytest.mark.anyio
301 -async def test_independent_vision_sidecar_calls_can_run_concurrently(monkeypatch, tmp_path):
292 +async def test_independent_vision_model_calls_can_run_concurrently(monkeypatch, tmp_path):
293 _install_tool_stub(monkeypatch)
294 import tools.vision_load as vision_load_module
295
@@ -327,7 +318,6 @@ async def test_independent_vision_sidecar_calls_can_run_concurrently(monkeypatch
318 "get_vision_model_config",
319 lambda _agent: {"provider": "test", "name": "vision", "max_embeds": 10},
320 )
330 - monkeypatch.setattr(vision_load_module, "use_vision_sidecar", lambda _agent: True)
321
322 image_paths = [tmp_path / "one.png", tmp_path / "two.png"]
323 for path in image_paths:
tools/vision_load.py
+80 -154
@@ -1,24 +1,18 @@
1 -import json
1 from mimetypes import guess_type
2
4 -from langchain_core.messages import HumanMessage, SystemMessage
3 +from langchain_core.messages import HumanMessage
4
5 from helpers import chat_media, ephemeral_images, files, history, images, runtime
7 -from helpers.parallel_tools import PARALLEL_WORKER_PARENT_CONTEXT_KEY, coerce_bool
8 -from helpers.print_style import PrintStyle
6 +from helpers.parallel_tools import PARALLEL_WORKER_PARENT_CONTEXT_KEY
7 from helpers.tool import Response, Tool
8 from plugins._model_config.helpers.model_config import (
9 build_vision_model,
10 get_chat_model_config,
11 get_vision_model_config,
14 - use_vision_sidecar,
12 )
13
14 +# image token estimation for context window
15 TOKENS_ESTIMATE = 1500
18 -VISION_SYSTEM_PROMPT = (
19 - "You are a precise vision analyst. Answer only what was asked about the images. "
20 - "Be concise and factual. Preserve exact visible text when asked to read it."
21 -)
16 DEFAULT_VISION_QUERY = (
17 "Describe the images precisely, including the key objects, visible text, and layout."
18 )
@@ -26,172 +20,99 @@ DEFAULT_VISION_QUERY = (
20
21 class VisionLoad(Tool):
22 async def execute(
29 - self,
30 - paths: list[str] | str | None = None,
31 - query: str = "",
32 - raw: bool = False,
33 - **kwargs,
23 + self, paths: list[str] = [], query: str = "", **kwargs
24 ) -> Response:
35 - self.images_dict: dict[str, str] = {}
25 +
26 + self.images_dict = {}
27 self.loaded_paths: list[str] = []
28 self.skipped_paths: list[str] = []
38 - self._config_owner = self._config_agent()
39 - self._main_has_vision = bool(
40 - get_chat_model_config(self._config_owner).get("vision", False)
41 - )
42 - self._delegated = use_vision_sidecar(self._config_owner) and not (
43 - coerce_bool(raw, False) and self._main_has_vision
44 - )
45 - self._max_embeds = self._get_max_embeds()
46 -
47 - normalized = self._normalize_paths(paths)
48 - if isinstance(normalized, str):
49 - self._history_result = normalized
50 - return Response(message=normalized, break_loop=False)
29 + self.vision_config = get_vision_model_config(self.agent)
30 + if not isinstance(paths, list):
31 + return Response(
32 + message="vision_load error: `paths` must be an array.",
33 + break_loop=False,
34 + )
35
36 + max_embeds = self._get_max_embeds()
37 requested = [
53 - (path.strip(), self._display_input_path(path.strip(), index + 1))
54 - for index, path in enumerate(normalized)
38 + (str(path or "").strip(), self._display_input_path(str(path or "").strip(), idx + 1))
39 + for idx, path in enumerate(paths)
40 ]
56 - limited = requested if self._max_embeds <= 0 else requested[-self._max_embeds :]
57 - if self._max_embeds > 0 and len(requested) > self._max_embeds:
58 - self.skipped_paths = [display for _, display in requested[: -self._max_embeds]]
41 + limited_paths = requested if max_embeds <= 0 else requested[-max_embeds:]
42 + self.skipped_paths = (
43 + [display for _, display in requested[:-max_embeds]]
44 + if max_embeds > 0 and len(requested) > max_embeds
45 + else []
46 + )
47
60 - for index, (path, display_path) in enumerate(limited):
48 + for idx, (path, display_path) in enumerate(limited_paths):
49 if not path:
50 continue
51 if ephemeral_images.is_ref(path):
64 - image = ephemeral_images.consume_image(path, context_id=self._context_id())
52 + image = ephemeral_images.consume_image(
53 + path,
54 + context_id=self._context_id(),
55 + )
56 if image is None:
57 continue
67 - display_path = image.display_name or display_path
58 + display = image.display_name or display_path
59 stored_ref = self._store_ephemeral_image(image)
69 - elif self._is_data_image_url(path):
70 - stored_ref = self._store_data_url(
71 - path, preferred_name=f"vision-load-{index + 1}.png"
72 - )
73 - elif await runtime.call_development_function(files.exists, path):
74 - mime_type, _ = guess_type(path)
75 - if not mime_type or not mime_type.startswith("image/"):
76 - continue
77 - try:
78 - stored_ref = self._store_local_image(
79 - path, preferred_name=files.basename(path)
80 - )
81 - except (FileNotFoundError, OSError, ValueError):
82 - continue
83 - else:
60 + if stored_ref:
61 + self.images_dict[display] = stored_ref
62 + self.loaded_paths.append(display)
63 + continue
64 + if self._is_data_image_url(path):
65 + stored_ref = self._store_data_url(path, preferred_name=f"vision-load-{idx + 1}.png")
66 + if stored_ref:
67 + self.images_dict[display_path] = stored_ref
68 + self.loaded_paths.append(display_path)
69 + continue
70 + if not await runtime.call_development_function(files.exists, str(path)):
71 continue
72
86 - if stored_ref:
87 - self.images_dict[display_path] = stored_ref
88 - self.loaded_paths.append(display_path)
89 -
90 - summary = self._summary()
91 - if self._delegated and self.images_dict:
73 + if path not in self.images_dict:
74 + mime_type, _ = guess_type(str(path))
75 + if mime_type and mime_type.startswith("image/"):
76 + try:
77 + stored_ref = self._store_local_image(path, preferred_name=files.basename(path))
78 + self.images_dict[display_path] = stored_ref
79 + self.loaded_paths.append(display_path)
80 + except (FileNotFoundError, OSError, ValueError):
81 + continue
82 +
83 + message = self._summary() if self.images_dict or self.skipped_paths else "No images processed"
84 + if self.vision_config and self.images_dict:
85 try:
86 capsule = await self._call_vision_model(
94 - list(self.images_dict.values()),
95 - str(query or "").strip() or DEFAULT_VISION_QUERY,
87 + list(self.images_dict.values()), query
88 )
89 message = (
90 f"Vision Model analyzed {len(self.images_dict)} image(s)"
91 f"; {len(self.skipped_paths)} skipped.\n\n{capsule.strip()}"
92 )
101 - self._history_result = message
102 - return Response(message=message, break_loop=False)
93 except Exception as exc:
94 message = f"Vision Model error: {str(exc)[:1000]}"
105 - self._history_result = f"{summary}\n\n{message}"
106 - return Response(message=message, break_loop=False)
107 -
108 - if self.images_dict and not self._main_has_vision:
109 - summary += (
110 - "\n\nImages were not injected because neither Main native vision nor "
111 - "a usable Vision Model is active."
112 - )
113 - self._history_result = (
114 - summary if self.images_dict or self.skipped_paths else "No images processed"
115 - )
116 - message = (
117 - "No images processed"
118 - if not self.images_dict and not self.skipped_paths
119 - else f"{len(self.images_dict)} images loaded, {len(self.skipped_paths)} skipped"
120 - )
95 return Response(message=message, break_loop=False)
96
123 - async def after_execution(self, response: Response, **kwargs):
124 - log_id = str(getattr(getattr(self, "log", None), "id", "") or "")
125 - self.agent.hist_add_tool_result(
126 - self.name,
127 - self._history_result,
128 - id=log_id,
129 - **(response.additional or {}),
130 - )
131 -
132 - if self.images_dict and self._main_has_vision and not self._delegated:
133 - content = [
134 - {"type": "image_url", "image_url": {"url": image_path}}
135 - for image_path in self.images_dict.values()
136 - ]
137 - self.agent.hist_add_message(
138 - False,
139 - content=history.RawMessage(
140 - raw_content=content,
141 - preview="<Image attachments loaded by path>",
142 - ),
143 - tokens=TOKENS_ESTIMATE * len(content),
144 - )
145 -
146 - PrintStyle(
147 - font_color="#1B4F72", background_color="white", padding=True, bold=True
148 - ).print(f"{self.agent.agent_name}: Response from tool '{self.name}'")
149 - PrintStyle(font_color="#85C1E9").print(response.message)
150 - if getattr(self, "log", None):
151 - self.log.update(result=response.message)
152 -
97 def _get_max_embeds(self) -> int:
154 - cfg = (
155 - get_vision_model_config(self._config_owner)
156 - if self._delegated
157 - else get_chat_model_config(self._config_owner)
158 - )
159 - try:
160 - return int(cfg.get("max_embeds", 10) or 0)
161 - except (TypeError, ValueError):
162 - return 10
98 + cfg = self.vision_config or get_chat_model_config(self.agent)
99 + return int(cfg.get("max_embeds", 10) or 0)
100
101 def _context_id(self) -> str:
165 - context = getattr(self._config_owner, "context", None)
166 - return str(getattr(context, "id", "") or "").strip()
167 -
168 - def _config_agent(self):
102 context = getattr(self.agent, "context", None)
103 get_data = getattr(context, "get_data", None)
104 parent_id = get_data(PARALLEL_WORKER_PARENT_CONTEXT_KEY) if get_data else ""
172 - if parent_id:
173 - from agent import AgentContext
174 -
175 - parent = AgentContext.get(str(parent_id))
176 - if parent:
177 - return parent.agent0
178 - return self.agent
105 + return str(parent_id or getattr(context, "id", "") or "").strip()
106
107 async def _call_vision_model(self, image_paths: list[str], query: str) -> str:
181 - model = build_vision_model(self._config_owner)
182 - content = [{"type": "text", "text": query or DEFAULT_VISION_QUERY}]
108 + content = [{"type": "text", "text": str(query or "").strip() or DEFAULT_VISION_QUERY}]
109 content.extend(
110 {"type": "image_url", "image_url": {"url": path}}
111 for path in image_paths
112 )
187 - response, _ = await model.unified_call(
188 - messages=[
189 - SystemMessage(content=VISION_SYSTEM_PROMPT),
190 - HumanMessage(content=content),
191 - ],
113 + response, _ = await build_vision_model(self.agent).unified_call(
114 + messages=[HumanMessage(content=content)],
115 )
193 - if not str(response or "").strip():
194 - raise RuntimeError("Vision Model returned an empty response.")
116 return str(response)
117
118 def _store_ephemeral_image(self, image: ephemeral_images.EphemeralImage) -> str:
@@ -199,11 +120,12 @@ class VisionLoad(Tool):
120 if not context_id:
121 return image.data_url
122 source = chat_media.infer_source(image.ref, image.display_name)
123 + category = chat_media.category_for_source(source)
124 saved = chat_media.save_image_base64(
125 context_id=context_id,
126 data=image.data,
127 mime_type=image.mime,
206 - category=chat_media.category_for_source(source),
128 + category=category,
129 source=source,
130 preferred_name=image.display_name,
131 )
@@ -214,10 +136,11 @@ class VisionLoad(Tool):
136 if not context_id:
137 return data_url
138 source = chat_media.infer_source(data_url, preferred_name)
139 + category = chat_media.category_for_source(source)
140 saved = chat_media.save_image_data_url(
141 context_id=context_id,
142 data_url=data_url,
220 - category=chat_media.category_for_source(source),
143 + category=category,
144 source=source,
145 preferred_name=preferred_name,
146 )
@@ -239,23 +162,9 @@ class VisionLoad(Tool):
162 skipped = "\n".join(self.skipped_paths) if self.skipped_paths else "none"
163 return (
164 f"Loaded images ({len(self.loaded_paths)}):\n{loaded}\n\n"
242 - f"Skipped images ({len(self.skipped_paths)}, max {self._max_embeds}):\n{skipped}"
165 + f"Skipped images ({len(self.skipped_paths)}, max {self._get_max_embeds()}):\n{skipped}"
166 )
167
245 - @staticmethod
246 - def _normalize_paths(paths: list[str] | str | None) -> list[str] | str:
247 - if isinstance(paths, str):
248 - try:
249 - decoded = json.loads(paths)
250 - except json.JSONDecodeError:
251 - decoded = paths
252 - paths = decoded if isinstance(decoded, list) else [paths]
253 - if paths is None:
254 - return []
255 - if not isinstance(paths, (list, tuple)):
256 - return "vision_load error: `paths` must be an array of image paths."
257 - return [str(path or "").strip() for path in paths]
258 -
168 @staticmethod
169 def _is_data_image_url(value: str) -> bool:
170 normalized = str(value or "").strip().lower()
@@ -266,5 +175,22 @@ class VisionLoad(Tool):
175 if ephemeral_images.is_ref(value):
176 return ephemeral_images.display_ref(value)
177 if cls._is_data_image_url(value):
269 - return f"{value.split(',', 1)[0]},<ephemeral-image-{index}>"
178 + prefix = value.split(",", 1)[0]
179 + return f"{prefix},<ephemeral-image-{index}>"
180 return value
181 +
182 + async def after_execution(self, response: Response, **kwargs):
183 + await super().after_execution(response, **kwargs)
184 + if self.images_dict and not self.vision_config:
185 + content = [
186 + {"type": "image_url", "image_url": {"url": image_path}}
187 + for image_path in self.images_dict.values()
188 + ]
189 + self.agent.hist_add_message(
190 + False,
191 + content=history.RawMessage(
192 + raw_content=content,
193 + preview="<Image attachments loaded by path>",
194 + ),
195 + tokens=TOKENS_ESTIMATE * len(content),
196 + )
tools/vision_load.py.dox.md
+6 -6
@@ -12,29 +12,29 @@
12 - `vision_load.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
13 - Classes:
14 - `VisionLoad` (`Tool`)
15 - - `async execute(self, paths, query="", raw=False, **kwargs) -> Response`
15 + - `async execute(self, paths, query="", **kwargs) -> Response`
16 - `async after_execution(self, response: Response, **kwargs)`
17 - Notable constants/configuration names: `TOKENS_ESTIMATE`.
18
19 ## Runtime Contracts
20
21 - Tool modules must define `helpers.tool.Tool` subclasses and return `helpers.tool.Response` from `execute(...)`.
22 -- One call may contain multiple paths. The delegated route sends every selected path in one Vision Model request and returns one textual capsule.
23 -- Main native vision wins unless the effective preset selects the sidecar route; `raw=true` returns to Main native vision only when Main supports it.
22 +- One call may contain multiple paths. The Vision Model route sends every selected path in one request and returns one textual capsule.
23 +- Model configuration exposes a Vision Model only when the effective preset selects that route; otherwise this tool follows Main's native vision path.
24 - Delegation completes during `execute(...)` so native Responses function output contains the real capsule before `after_execution(...)` persists it.
25 - Delegated history contains the text capsule only. Native history contains the tool result followed by one raw message holding all loaded image blocks.
26 -- Direct parallel workers resolve ephemeral refs, model routing, and durable chat-media storage against their recorded parent context; independent vision jobs remain generic parallel jobs.
26 +- Direct parallel workers inherit the parent's model override generically. This tool uses their recorded parent context only to resolve ephemeral refs and durable chat media.
27 - `max_embeds` comes from the model that actually receives the images.
28 - Vision Model calls use the selected model's Advanced `kwargs`; this tool does not impose a separate timeout or output-token limit.
29 - Update this file whenever tool arguments, output shape, `break_loop` behavior, intervention handling, prompt instructions, or side effects change.
30 - `VisionLoad` is a `Tool`.
31 - `VisionLoad` defines `execute(...)`.
32 - Observed side-effect areas: filesystem writes, model calls, plugin state, settings/state persistence, secret handling.
33 -- Imported dependency areas include: `helpers`, `helpers.print_style`, `helpers.tool`, `mimetypes`.
33 +- Imported dependency areas include: `helpers`, `helpers.tool`, `langchain_core.messages`, `mimetypes`, and `_model_config`.
34
35 ## Key Concepts
36
37 -- Important called helpers/classes observed in the source: `build_vision_model`, `use_vision_sidecar`, `self._get_max_embeds`, `Response`, `self._context_id`, `chat_media.save_image_base64`, `chat_media.save_image_data_url`, `chat_media.materialize_image_ref`, `ephemeral_images.consume_image`, `images.to_data_url`, `self.agent.hist_add_tool_result`, `history.RawMessage`, `model.unified_call`.
37 +- Important called helpers/classes observed in the source: `build_vision_model`, `get_vision_model_config`, `self._get_max_embeds`, `Response`, `self._context_id`, `chat_media.save_image_base64`, `chat_media.save_image_data_url`, `chat_media.materialize_image_ref`, `ephemeral_images.consume_image`, `images.to_data_url`, `history.RawMessage`, `super().after_execution`, `model.unified_call`.
38 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
39
40 ## Work Guidance