Integrate vision sidecar routing with model presets

Add a strict per-preset sidecar slot, Main-first routing, one-call multi-image analysis, parallel-worker image resolution, and inline model settings with focused regression coverage.

Alessandro committed Aug 25, 2026 at 15:14 UTC 59f5ae03ea618aecb6e9dd3ee4657b6dee35cff1
21 files changed +878 -148
extensions/python/system_prompt/_11_tools_prompt.py
+7 -2
@@ -51,10 +51,15 @@ async def build_prompt(agent: Agent) -> str:
51 prompt = agent.read_prompt("agent.system.tools.md", tools=tools_str)
52
53 # vision support
54 - from plugins._model_config.helpers.model_config import get_chat_model_config
54 + from plugins._model_config.helpers.model_config import (
55 + get_chat_model_config,
56 + use_vision_sidecar,
57 + )
58
59 chat_cfg = get_chat_model_config(agent)
57 - if chat_cfg.get("vision", False):
60 + if use_vision_sidecar(agent):
61 + prompt += "\n\n" + agent.read_prompt("agent.system.tools_vision_sidecar.md")
62 + elif chat_cfg.get("vision", False):
63 prompt += "\n\n" + agent.read_prompt("agent.system.tools_vision.md")
64
65 return prompt
helpers/responses_tools.py
+6 -1
@@ -114,8 +114,13 @@ def _local_tool_prompts(agent: Any) -> list[tuple[str, str]]:
114
115 def _vision_tool_prompt(agent: Any) -> str:
116 try:
117 - from plugins._model_config.helpers.model_config import get_chat_model_config
117 + from plugins._model_config.helpers.model_config import (
118 + get_chat_model_config,
119 + use_vision_sidecar,
120 + )
121
122 + if use_vision_sidecar(agent):
123 + return agent.read_prompt("agent.system.tools_vision_sidecar.md")
124 if not get_chat_model_config(agent).get("vision", False):
125 return ""
126 return agent.read_prompt("agent.system.tools_vision.md")
helpers/responses_tools.py.dox.md
+3 -3
@@ -12,7 +12,7 @@
12
13 ## Local Contracts
14
15 -- Build local function tools from enabled `agent.system.tool.*.md` prompt files and include `vision_load` only when the active chat model enables the matching vision prompt.
15 +- Build local function tools from enabled `agent.system.tool.*.md` prompt files and include `vision_load` when either Main native vision or the effective preset's Vision Model enables the matching prompt.
16 - Discover local prompt files through `helpers.subagents.get_paths`; this module
17 owns the Responses-specific prompt-name compatibility rules.
18 - Local prompt-derived function names use existing bullet declarations that pair a backticked name with `arg` or `args` for multi-tool prompt files, otherwise prefer explicit `"tool_name"` examples, then the first prompt heading, and finally the prompt filename.
@@ -25,8 +25,8 @@
25 - Preserve original Agent Zero tool names through the native Responses name map.
26 - Keep MCP tool schemas merged after local prompt-derived tools.
27 - Apply `helpers.tool_policy` before emitting local or MCP schemas; a blocked
28 - capability is absent from provider-native tool definitions. Vision remains
29 - controlled solely by the active chat model configuration.
28 + capability is absent from provider-native tool definitions. Vision routing
29 + is controlled by the effective model preset rather than Agent Editor.
30 - Connector remote tools are advertised only when `_a0_connector` runtime metadata says the matching connected CLI capability is currently available.
31
32 ## Work Guidance
plugins/_model_config/AGENTS.md
+7 -2
@@ -14,7 +14,7 @@
14
15 ## Local Contracts
16
17 -- `Default` is the first global preset and cannot be deleted or renamed. It owns the complete main, utility, and embedding baseline.
17 +- `Default` is the first global preset and cannot be deleted or renamed. It owns the complete main, utility, and embedding baseline; its Vision Model slot is optional.
18 - Preset definitions are global. Global, project, agent-profile, and project/profile plugin configs persist only `model_preset`; chats may persist a preset reference as their explicit override.
19 - Preserve scoped plugin resolution order and fall back invalid or missing scope/chat references to `Default`.
20 - Project Settings `llm` payloads are owned here through the generic `helpers.projects` project extension-data hooks; keep project helper code agnostic to `_model_config` paths, presets, and inheritance rules.
@@ -22,7 +22,12 @@
22 - Check API-key readiness only for the effective model configuration; unused global presets must not produce Welcome-screen warnings.
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 slots or durable tuning from `Default`, but must replace or clear per-slot `kwargs` so provider-specific extra params never leak across model providers.
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.
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.
30 +- In model overviews, render the effective Vision Model as a text-only `Vision override / Provider / Model` child aligned with Main's provider column, not as an icon-bearing peer row.
31 - Changing a model provider in the settings UI must clear `api_base` and `kwargs` because both may be provider-specific.
32 - Repair provider-specific model-config aliases at the model-config read/build boundary; keep provider-specific repairs out of provider-agnostic core wrappers such as `models.py`.
33 - `modelConfig.createPresetEditor()` owns local preset drafts, row actions, and stable UI-only row keys so deletion or renaming cannot rebind nested model fields.
plugins/_model_config/extensions/webui/chat-input-progress-start/model-switcher.html
+10
@@ -65,6 +65,16 @@
65 </span>
66 </div>
67 </template>
68 + <template x-if="preset.vision?.name && (!preset.chat?.vision || preset.vision?.override_main)">
69 + <div class="model-switcher-model-row">
70 + <span class="model-switcher-model-label">Vision</span>
71 + <span class="model-switcher-model-value">
72 + <span x-text="preset.vision.provider" style="opacity:0.5;"></span>
73 + <span style="opacity:0.3; margin:0 3px;">/</span>
74 + <span x-text="preset.vision.name"></span>
75 + </span>
76 + </div>
77 + </template>
78 <template x-if="preset.utility?.name">
79 <div class="model-switcher-model-row">
80 <span class="model-switcher-model-label">Utility</span>
plugins/_model_config/helpers/model_config.py
+51 -2
@@ -16,11 +16,21 @@ PRESET_SCOPE_GLOBAL = "global"
16 PRESET_SCOPE_PROJECT = "project"
17 PRESET_SLOT_CONFIG_SECTIONS = {
18 "chat": "chat_model",
19 + "vision": "vision_model",
20 "utility": "utility_model",
21 "embedding": "embedding_model",
22 }
23 MODEL_SLOT_PRESET_REPLACE_FIELDS = {"kwargs"}
24 IMPLICIT_PRESET_SLOT_DEFAULTS = {
25 + "vision": {
26 + "vision": True,
27 + "max_embeds": 10,
28 + "override_main": False,
29 + "rl_requests": 0,
30 + "rl_input": 0,
31 + "rl_output": 0,
32 + "kwargs": {},
33 + },
34 "utility": {
35 "ctx_length": 128000,
36 "ctx_input": 0.7,
@@ -267,6 +277,9 @@ def _clean_preset_for_file(preset: dict) -> dict:
277 slot_config = preset.get(slot)
278 if isinstance(slot_config, dict):
279 slot_clean = _strip_ui_fields(slot_config, strip_api_key=True)
280 + if slot == "vision" and _slot_has_identity(slot_clean):
281 + slot_clean["vision"] = True
282 + slot_clean.setdefault("max_embeds", 10)
283 cleaned[slot] = (
284 slot_clean
285 if name == DEFAULT_PRESET_NAME
@@ -339,7 +352,12 @@ def validate_presets(presets: list, *, require_default: bool = True) -> list:
352 def normalize_config_for_save(config: dict) -> dict:
353 """Remove UI-only fields and inline API keys before storing scoped config."""
354 cleaned = deepcopy(config or {})
342 - for section_name in ("chat_model", "utility_model", "embedding_model"):
355 + for section_name in (
356 + "chat_model",
357 + "vision_model",
358 + "utility_model",
359 + "embedding_model",
360 + ):
361 section = cleaned.get(section_name)
362 if isinstance(section, dict):
363 cleaned[section_name] = _strip_ui_fields(section, strip_api_key=True)
@@ -659,10 +677,12 @@ def build_config_from_preset(
677 continue
678 slot_config = _get_preset_slot_config(preset, slot)
679 if not _should_apply_preset_slot(slot, slot_config):
680 + if slot == "vision":
681 + config[section] = {}
682 continue
683 config[section] = _merge_model_slot(
684 slot,
665 - config.get(section, {}),
685 + {} if slot == "vision" else config.get(section, {}),
686 slot_config,
687 strip_api_key=strip_api_key,
688 )
@@ -738,6 +758,23 @@ def get_chat_model_config(agent=None) -> dict:
758 return get_effective_config(agent).get("chat_model", {})
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"))
776 +
777 +
778 def get_utility_model_config(agent=None) -> dict:
779 """Get utility model config, with per-chat override if active."""
780 return get_effective_config(agent).get("utility_model", {})
@@ -835,6 +872,16 @@ def build_utility_model(agent=None):
872 )
873
874
875 +def build_vision_model(agent=None):
876 + """Build the optional Vision Model selected by the effective preset."""
877 + cfg = get_vision_model_config(agent)
878 + mc = build_model_config(cfg, models.ModelType.CHAT)
879 + mc.vision = True
880 + return models.get_chat_model(
881 + mc.provider, mc.name, model_config=mc, **mc.build_kwargs()
882 + )
883 +
884 +
885 def build_embedding_model(agent=None):
886 """Build and return an embedding model wrapper."""
887 cfg = get_embedding_model_config(agent)
@@ -881,6 +928,8 @@ def get_missing_api_key_providers(agent=None) -> list[dict]:
928 ("Utility Model", cfg.get("utility_model", {})),
929 ("Embedding Model", get_embedding_model_config(agent)),
930 ]
931 + if use_vision_sidecar(agent):
932 + checks.insert(1, ("Vision Model", cfg.get("vision_model", {})))
933
934 for label, model_cfg in checks:
935 provider = model_cfg.get("provider", "")
plugins/_model_config/webui/main.html
+19 -2
@@ -21,7 +21,7 @@
21 <div class="preset-editor-intro">
22 <div class="field-title">Model Presets</div>
23 <div class="field-description">
24 - Each preset contains the complete main, utility, and embedding model setup. Default is always available.
24 + Each preset contains the main, utility, embedding, and optional vision-sidecar setup. Default is always available.
25 </div>
26 </div>
27
@@ -67,9 +67,21 @@
67 <div class="section-title">Main Model</div>
68 <div class="section-description">Primary model for conversations, reasoning, and tools.</div>
69 </div>
70 - <div x-data="{ get model() { return selectedPreset.chat; }, modelType: 'chat', providers: $store.modelConfig.chatProviders, searchType: 'chat', apiKeyMode: 'store' }">
70 + <div x-data="{ get model() { return selectedPreset.chat; }, get visionModel() { return selectedPreset.vision; }, modelType: 'chat', providers: $store.modelConfig.chatProviders, searchType: 'chat', apiKeyMode: 'store' }">
71 <x-component path="/plugins/_model_config/webui/model-field.html"></x-component>
72 </div>
73 + <template x-if="!selectedPreset.chat.vision || selectedPreset.vision.override_main">
74 + <div class="vision-sidecar-selector">
75 + <div class="field-title">Vision sidecar</div>
76 + <div class="field-description"
77 + x-text="selectedPreset.chat.vision
78 + ? 'Overrides Main model vision for vision_load.'
79 + : 'Used by vision_load while Main model vision is disabled. Leave empty to disable image analysis.'"></div>
80 + <div x-data="{ get model() { return selectedPreset.vision; }, modelType: 'vision', providers: $store.modelConfig.chatProviders, searchType: 'chat', apiKeyMode: 'store' }">
81 + <x-component path="/plugins/_model_config/webui/model-field.html"></x-component>
82 + </div>
83 + </div>
84 + </template>
85 </section>
86
87 <section class="preset-model-section">
@@ -166,6 +178,11 @@
178 margin-top: 0;
179 }
180
181 + .vision-sidecar-selector {
182 + margin: 0.75rem 0 0;
183 + padding: 0.25rem 0 0;
184 + }
185 +
186 .preset-editor-secondary-actions {
187 display: flex;
188 flex-wrap: wrap;
plugins/_model_config/webui/model-config-store.js
+40 -6
@@ -7,6 +7,7 @@ import { switcherState, switcherMethods } from "/plugins/_model_config/webui/swi
7
8 export const MODEL_SECTIONS = [
9 { key: 'chat_model', title: 'Main Model', desc: 'Primary model for chat, reasoning, and browser tasks.' },
10 + { key: 'vision_model', title: 'Vision Model', desc: 'Optional model used by vision_load when Main vision is unavailable or overridden.' },
11 { key: 'utility_model', title: 'Utility Model', desc: 'Lightweight model for background tasks: memory management, prompt preparation, summarization.' },
12 { key: 'embedding_model', title: 'Embedding Model', desc: 'Model for generating vector embeddings used in knowledge retrieval.' }
13 ];
@@ -59,6 +60,15 @@ function isBlankPresetValue(value) {
60 }
61
62 const IMPLICIT_PRESET_SLOT_DEFAULTS = {
63 + vision: {
64 + vision: true,
65 + max_embeds: 10,
66 + override_main: false,
67 + rl_requests: 0,
68 + rl_input: 0,
69 + rl_output: 0,
70 + kwargs: {},
71 + },
72 utility: {
73 ctx_length: 128000,
74 ctx_input: 0.7,
@@ -143,15 +153,21 @@ export function configFromPreset(preset, baseConfig, stripApiKey = true) {
153 const config = clonePlain(baseConfig || {});
154 const slots = [
155 ['chat', 'chat_model'],
156 + ['vision', 'vision_model'],
157 ['utility', 'utility_model'],
158 ['embedding', 'embedding_model'],
159 ];
160
161 for (const [slotKey, sectionKey] of slots) {
162 const slot = preset?.[slotKey];
152 - if (!slot || typeof slot !== 'object') continue;
153 - if (!hasModelIdentity(slot)) continue;
154 - config[sectionKey] = mergeModelSlot(config[sectionKey] || {}, slot, stripApiKey, slotKey);
163 + if (slotKey === 'vision') config[sectionKey] = {};
164 + if (!slot || typeof slot !== 'object' || !hasModelIdentity(slot)) continue;
165 + config[sectionKey] = mergeModelSlot(
166 + slotKey === 'vision' ? {} : (config[sectionKey] || {}),
167 + slot,
168 + stripApiKey,
169 + slotKey,
170 + );
171 }
172
173 return config;
@@ -206,8 +222,10 @@ export const store = createStore("modelConfig", {
222 const source = (rawPresets || []).filter(p => p && typeof p === 'object');
223 const rawDefault = source.find(p => String(p.name || '').toLowerCase() === 'default') || {};
224 const slot = value => ({ provider: '', name: '', api_key: '', api_base: '', kwargs: {}, ...(value || {}) });
225 + const visionSlot = value => ({ ...slot(value), vision: true, max_embeds: Number(value?.max_embeds ?? 10), override_main: !!value?.override_main });
226 const defaultConfig = {
227 chat_model: slot(rawDefault.chat),
228 + vision_model: hasModelIdentity(rawDefault.vision) ? visionSlot(rawDefault.vision) : {},
229 utility_model: slot(rawDefault.utility),
230 embedding_model: slot(rawDefault.embedding),
231 };
@@ -219,6 +237,7 @@ export const store = createStore("modelConfig", {
237 return {
238 name: p.name || '',
239 chat: { ...slot(effective.chat_model), _kwargs_text: kwargsToText(effective.chat_model?.kwargs) },
240 + vision: { ...visionSlot(effective.vision_model), _kwargs_text: kwargsToText(effective.vision_model?.kwargs) },
241 utility: { ...slot(effective.utility_model), _kwargs_text: kwargsToText(effective.utility_model?.kwargs) },
242 embedding: { ...slot(effective.embedding_model), _kwargs_text: kwargsToText(effective.embedding_model?.kwargs) },
243 };
@@ -273,6 +292,7 @@ export const store = createStore("modelConfig", {
292 // Config field initialization (converts kwargs dicts to editable text)
293 initConfigFields(config) {
294 if (config?.chat_model) config.chat_model._kwargs_text = kwargsToText(config.chat_model.kwargs);
295 + if (config?.vision_model) config.vision_model._kwargs_text = kwargsToText(config.vision_model.kwargs);
296 if (config?.utility_model) config.utility_model._kwargs_text = kwargsToText(config.utility_model.kwargs);
297 if (config?.embedding_model) config.embedding_model._kwargs_text = kwargsToText(config.embedding_model.kwargs);
298 },
@@ -340,6 +360,7 @@ export const store = createStore("modelConfig", {
360 || this.presets[0]
361 || {
362 chat: { provider: '', name: '', api_base: '', kwargs: {}, _kwargs_text: '' },
363 + vision: { provider: '', name: '', api_base: '', vision: true, max_embeds: 10, override_main: false, kwargs: {}, _kwargs_text: '' },
364 utility: { provider: '', name: '', api_base: '', kwargs: {}, _kwargs_text: '' },
365 embedding: { provider: '', name: '', api_base: '', kwargs: {}, _kwargs_text: '' },
366 }
@@ -420,9 +441,10 @@ export const store = createStore("modelConfig", {
441 const clean = presets.map(p => {
442 const c = { name: p.name };
443 const isDefault = p._originalName === DEFAULT_PRESET_NAME;
423 - for (const slot of ['chat', 'utility']) {
444 + for (const slot of ['chat', 'vision', 'utility']) {
445 if (p[slot]) {
446 const rest = cleanPresetSlot(p[slot], true, slot, isDefault);
447 + if (slot === 'vision' && hasModelIdentity(rest)) rest.vision = true;
448 if (hasModelIdentity(rest)) c[slot] = rest;
449 }
450 }
@@ -608,9 +630,15 @@ export const store = createStore("modelConfig", {
630 const label = (list, id) => (list.find(x => x.value === id) || {}).label || id || '\u2014';
631 return [
632 { icon: 'chat', title: 'Main', cfg: preset?.chat, pList: chatP },
633 + { icon: 'eye', title: 'Vision', cfg: preset?.vision, pList: chatP },
634 { icon: 'manufacturing', title: 'Utility', cfg: preset?.utility, pList: chatP },
635 { icon: 'database', title: 'Embedding', cfg: preset?.embedding, pList: embedP },
613 - ].map(s => ({ icon: s.icon, title: s.title, provider: label(s.pList, s.cfg?.provider), name: s.cfg?.name || '\u2014' }));
636 + ]
637 + .filter(s => s.title !== 'Vision' || (
638 + hasModelIdentity(s.cfg)
639 + && (!preset?.chat?.vision || s.cfg?.override_main)
640 + ))
641 + .map(s => ({ icon: s.icon, title: s.title, provider: label(s.pList, s.cfg?.provider), name: s.cfg?.name || '\u2014' }));
642 },
643
644 getPreset(name) {
@@ -747,9 +775,15 @@ export const store = createStore("modelConfig", {
775 const label = (list, id) => (list.find(x => x.value === id) || {}).label || id || '\u2014';
776 return [
777 { icon: 'chat', title: 'Main', cfg: cfg.chat_model, pList: chatP },
778 + { icon: 'eye', title: 'Vision', cfg: cfg.vision_model, pList: chatP },
779 { icon: 'manufacturing', title: 'Utility', cfg: cfg.utility_model, pList: chatP },
780 { icon: 'database', title: 'Embedding', cfg: cfg.embedding_model, pList: embedP },
752 - ].map(s => ({ icon: s.icon, title: s.title, provider: label(s.pList, s.cfg?.provider), name: s.cfg?.name || '\u2014' }));
781 + ]
782 + .filter(s => s.title !== 'Vision' || (
783 + hasModelIdentity(s.cfg)
784 + && (!cfg.chat_model?.vision || s.cfg?.override_main)
785 + ))
786 + .map(s => ({ icon: s.icon, title: s.title, provider: label(s.pList, s.cfg?.provider), name: s.cfg?.name || '\u2014' }));
787 },
788
789 async refreshModelsSummary(contextId = '') {
plugins/_model_config/webui/model-field.html
+30 -2
@@ -10,13 +10,14 @@
10 Reusable model configuration field set.
11 Parent x-data scope must provide:
12 model — reactive object with provider, name, api_key, api_base, ctx_length, ctx_history, ctx_input, vision, max_embeds, rl_requests, rl_input, rl_output, kwargs, _kwargs_text
13 - modelType — 'chat' | 'utility' | 'embedding'
13 + modelType — 'chat' | 'vision' | 'utility' | 'embedding'
14 providers — array of { value, label }
15 searchType — 'chat' | 'embedding'
16 apiKeyMode — 'store' (key lives in $store.modelConfig.apiKeyValues), 'inline' (key lives in model.api_key), or 'none'
17 Optional:
18 providerFallback — fallback provider string for search/API key status (e.g. preset.chat.provider for utility slot)
19 apiBaseFallback — fallback api_base string for search (e.g. preset.chat.api_base for utility slot)
20 + visionModel — vision slot object, required by the chat model's sidecar switch
21 -->
22 <div x-data="{ get _prov() { return model.provider || (typeof providerFallback !== 'undefined' ? providerFallback : ''); }, get _apiBase() { return model.api_base || (typeof apiBaseFallback !== 'undefined' ? apiBaseFallback : ''); } }">
23 <!-- Provider -->
@@ -146,8 +147,23 @@
147 </div>
148 </template>
149
150 + <template x-if="modelType === 'chat' && model.vision">
151 + <div class="field">
152 + <div class="field-label">
153 + <div class="field-title">Use separate Vision Model</div>
154 + <div class="field-description">When disabled, vision_load uses this model's native vision.</div>
155 + </div>
156 + <div class="field-control">
157 + <label class="toggle">
158 + <input type="checkbox" x-model="visionModel.override_main" />
159 + <span class="toggler"></span>
160 + </label>
161 + </div>
162 + </div>
163 + </template>
164 +
165 <!-- Context window size (main and utility only) -->
150 - <template x-if="modelType !== 'embedding'">
166 + <template x-if="modelType === 'chat' || modelType === 'utility'">
167 <div class="field">
168 <div class="field-label">
169 <div class="field-title">Context window size</div>
@@ -207,6 +223,18 @@
223 </div>
224 </template>
225
226 + <template x-if="modelType === 'vision'">
227 + <div class="field">
228 + <div class="field-label">
229 + <div class="field-title">Max embeds</div>
230 + <div class="field-description">Maximum number of images sent to the Vision Model in one call. Set to 0 for unlimited.</div>
231 + </div>
232 + <div class="field-control">
233 + <input type="number" min="0" x-model.number="model.max_embeds" />
234 + </div>
235 + </div>
236 + </template>
237 +
238 <!-- Utility-specific: ctx_input slider -->
239 <template x-if="modelType === 'utility'">
240 <div class="field">
plugins/_model_config/webui/preset-overview.html
+26 -4
@@ -17,10 +17,22 @@
17
18 <div class="model-preset-rows" aria-live="polite">
19 <template x-for="model in modelRows" :key="model.title">
20 - <div class="model-preset-row">
21 - <x-icon class="model-preset-icon" :name="model.icon"></x-icon>
22 - <span class="model-preset-role" x-text="model.title"></span>
23 - <span class="model-preset-identity">
20 + <div class="model-preset-row"
21 + :class="{ 'model-preset-row-nested': model.title === 'Vision' }">
22 + <template x-if="model.title !== 'Vision'">
23 + <x-icon class="model-preset-icon" :name="model.icon"></x-icon>
24 + </template>
25 + <template x-if="model.title !== 'Vision'">
26 + <span class="model-preset-role" x-text="model.title"></span>
27 + </template>
28 + <span class="model-preset-identity"
29 + :class="{ 'model-preset-identity-vision': model.title === 'Vision' }">
30 + <template x-if="model.title === 'Vision'">
31 + <span>
32 + <span class="model-preset-role">Vision override</span>
33 + <span class="model-preset-separator">/</span>
34 + </span>
35 + </template>
36 <span class="model-preset-provider" x-text="model.provider"></span>
37 <span class="model-preset-separator">/</span>
38 <span x-text="model.name"></span>
@@ -86,6 +98,16 @@
98 border-top: 1px solid var(--color-border);
99 }
100
101 + .model-preset-row-nested {
102 + border-top: 0 !important;
103 + padding-top: var(--spacing-xs);
104 + font-size: 0.76rem;
105 + }
106 +
107 + .model-preset-identity-vision {
108 + grid-column: 3;
109 + }
110 +
111 .model-preset-icon,
112 .model-preset-provider,
113 .model-preset-separator {
prompts/AGENTS.md
+1
@@ -25,6 +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 native and sidecar vision prompts synchronized with `vision_load`: related images belong in one call, while a sidecar result is text-only unless Main explicitly requests its native raw route.
29 - Update tests or snapshots when prompt budget, required sections, or generated system content changes.
30
31 ## Verification
prompts/agent.system.tools_vision_sidecar.md new
+48
@@ -0,0 +1,48 @@
1 +## multimodal vision tools
2 +
3 +### vision_load
4 +analyze images with the separate Vision Model and return a text result
5 +args: `paths` list of absolute image paths or ephemeral image refs, `query` optional focused instruction, `raw` optional boolean
6 +Input schema for tool_args:
7 +```json
8 +{
9 + "type": "object",
10 + "properties": {
11 + "paths": {
12 + "type": "array",
13 + "items": {"type": "string"},
14 + "description": "Absolute image paths or ephemeral image refs."
15 + },
16 + "query": {
17 + "type": "string",
18 + "description": "What the Vision Model should inspect, compare, locate, or read."
19 + },
20 + "raw": {
21 + "type": "boolean",
22 + "description": "Use the Main model's native vision instead, when Main supports vision."
23 + }
24 + },
25 + "required": ["paths"],
26 + "additionalProperties": false
27 +}
28 +```
29 +rules:
30 +- put all images needed for one comparison or visual task in the same `paths` array; they are sent in one Vision Model call
31 +- use a focused `query`; if omitted, the Vision Model returns a concise general description
32 +- the result is a text capsule; the Main model does not receive the raw images
33 +- use `raw=true` only when Main supports vision and must inspect the pixels itself
34 +- only bitmaps are supported
35 +example:
36 +```json
37 +{
38 + "thoughts": [
39 + "I need to compare both screenshots before answering."
40 + ],
41 + "headline": "Comparing screenshots",
42 + "tool_name": "vision_load",
43 + "tool_args": {
44 + "paths": ["/path/to/before.png", "/path/to/after.png"],
45 + "query": "Compare the error banners and describe what changed."
46 + }
47 +}
48 +```
tests/test_browser_agent_regressions.py
+13 -7
@@ -36,8 +36,13 @@ class _TestAgentContextType:
36
37
38 class _TestResponse(SimpleNamespace):
39 - def __init__(self, message="", break_loop=False, **kwargs):
40 - super().__init__(message=message, break_loop=break_loop, **kwargs)
39 + def __init__(self, message="", break_loop=False, additional=None, **kwargs):
40 + super().__init__(
41 + message=message,
42 + break_loop=break_loop,
43 + additional=additional,
44 + **kwargs,
45 + )
46
47
48 class _TestTool:
@@ -96,6 +101,9 @@ _model_config_stub = ModuleType("plugins._model_config.helpers.model_config")
101 _model_config_stub.get_presets = lambda: []
102 _model_config_stub.get_preset_by_name = lambda name: None
103 _model_config_stub.get_chat_model_config = lambda agent=None: {}
104 +_model_config_stub.get_vision_model_config = lambda agent=None: {}
105 +_model_config_stub.use_vision_sidecar = lambda agent=None: False
106 +_model_config_stub.build_vision_model = lambda agent=None: None
107 sys.modules.setdefault("plugins._model_config.helpers.model_config", _model_config_stub)
108
109
@@ -4297,11 +4305,9 @@ async def test_vision_load_materializes_ephemeral_browser_refs(monkeypatch, tmp_
4305
4306 monkeypatch.setattr(vision_load_module.chat_media.files, "get_abs_path", fake_get_abs_path)
4307 monkeypatch.setattr(vision_load_module.chat_media.files, "normalize_a0_path", fake_normalize_a0_path)
4300 - monkeypatch.setattr(
4301 - vision_load_module.plugins,
4302 - "get_plugin_config",
4303 - lambda *args, **kwargs: {"chat_model": {"max_embeds": 10}},
4304 - )
4308 + monkeypatch.setattr(vision_load_module, "get_chat_model_config", lambda _agent: {"vision": True, "max_embeds": 10})
4309 + monkeypatch.setattr(vision_load_module, "get_vision_model_config", lambda _agent: {})
4310 + monkeypatch.setattr(vision_load_module, "use_vision_sidecar", lambda _agent: False)
4311
4312 tool_results = []
4313 messages = []
tests/test_model_config_api_keys.py
+1 -1
@@ -149,7 +149,7 @@ def test_model_config_frontend_tracks_provider_api_key_edits():
149 assert "/plugins/_model_config/missing_api_key_status" not in model_gate_content
150 assert '@input="$store.modelConfig.setApiKeyValue(_prov, $el.value)"' in config_content
151 assert "apiKeyMode: 'none'" not in preset_modal_content
152 - assert preset_modal_content.count("apiKeyMode: 'store'") == 3
152 + assert preset_modal_content.count("apiKeyMode: 'store'") == 4
153 assert "$store.modelConfig.resetApiKeyDrafts();" in preset_modal_content
154 assert "await $store.modelConfig.refreshApiKeyStatus();" in preset_modal_content
155 assert "await store.persistAllDirtyApiKeys();" in store_content
tests/test_model_config_project_presets.py
+38
@@ -1092,6 +1092,44 @@ def test_preset_application_inherits_optional_slots(monkeypatch, tmp_path):
1092 assert config["embedding_model"] == base_config["embedding_model"]
1093
1094
1095 +def test_preset_vision_slot_is_optional_and_never_inherited(monkeypatch, tmp_path):
1096 + _prepare_a0_tree(monkeypatch, tmp_path)
1097 +
1098 + from plugins._model_config.helpers import model_config
1099 +
1100 + base_config = {
1101 + "chat_model": {"provider": "openrouter", "name": "main", "vision": False},
1102 + "vision_model": {
1103 + "provider": "openrouter",
1104 + "name": "default-vision",
1105 + "max_embeds": 2,
1106 + },
1107 + }
1108 +
1109 + without_sidecar = model_config.build_config_from_preset(
1110 + {"name": "Text only", "chat": {"provider": "openrouter", "name": "text"}},
1111 + base_config,
1112 + )
1113 + with_sidecar = model_config.build_config_from_preset(
1114 + {
1115 + "name": "Visual",
1116 + "chat": {"provider": "openrouter", "name": "text"},
1117 + "vision": {
1118 + "provider": "anthropic",
1119 + "name": "visual",
1120 + "max_embeds": 5,
1121 + },
1122 + },
1123 + base_config,
1124 + )
1125 +
1126 + assert without_sidecar["vision_model"] == {}
1127 + assert with_sidecar["vision_model"]["provider"] == "anthropic"
1128 + assert with_sidecar["vision_model"]["name"] == "visual"
1129 + assert with_sidecar["vision_model"]["max_embeds"] == 5
1130 + assert "default-vision" not in str(with_sidecar["vision_model"])
1131 +
1132 +
1133 def test_legacy_utility_preset_defaults_preserve_tuning_but_clear_kwargs(
1134 monkeypatch,
1135 tmp_path,
tests/test_model_config_ui.py
+43
@@ -85,6 +85,49 @@ def test_preset_editor_uses_standard_modal_footer_buttons() -> None:
85 assert "preset-editor-footer" not in preset_modal
86
87
88 +def test_preset_editor_nests_one_conditional_vision_selector_in_main() -> None:
89 + preset_modal = read("plugins", "_model_config", "webui", "main.html")
90 + model_field = read("plugins", "_model_config", "webui", "model-field.html")
91 + preset_overview = read("plugins", "_model_config", "webui", "preset-overview.html")
92 + preset_store = read("plugins", "_model_config", "webui", "model-config-store.js")
93 +
94 + main_start = preset_modal.index('<div class="section-title">Main Model</div>')
95 + utility_start = preset_modal.index('<div class="section-title">Utility Model</div>')
96 + selector_start = preset_modal.index('class="vision-sidecar-selector"')
97 + supports_start = model_field.index('<div class="field-title">Supports Vision</div>')
98 + override_start = model_field.index('<div class="field-title">Use separate Vision Model</div>')
99 + context_start = model_field.index('<div class="field-title">Context window size</div>')
100 + advanced_start = model_field.index('<!-- Advanced Settings (collapsed by default) -->')
101 +
102 + assert '<div class="section-title">Vision Model</div>' not in preset_modal
103 + assert preset_modal.count('class="vision-sidecar-selector"') == 1
104 + assert main_start < selector_start < utility_start
105 + assert '!selectedPreset.chat.vision || selectedPreset.vision.override_main' in preset_modal
106 + assert "get visionModel() { return selectedPreset.vision; }" in preset_modal
107 + assert "modelType: 'vision'" in preset_modal
108 + assert preset_modal.count("apiKeyMode: 'store'") == 4
109 + assert "margin: 0.75rem 0 0;" in preset_modal
110 + assert "padding: 0.25rem 0 0;" in preset_modal
111 + assert "border-left: 2px solid var(--color-border);" not in preset_modal
112 + assert "Use separate Vision Model" in model_field
113 + assert supports_start < override_start < context_start < advanced_start
114 + assert "When disabled, vision_load uses this model's native vision." in model_field
115 + assert "When enabled, vision_load uses the preset's Vision Model" not in model_field
116 + assert 'x-model="visionModel.override_main"' in model_field
117 + assert '<div class="advanced-section" x-data="{ advOpen: false }">' in model_field
118 + assert "model-preset-row-nested" in preset_overview
119 + assert "model.title === 'Vision'" in preset_overview
120 + assert preset_overview.count("model.title !== 'Vision'") == 2
121 + assert '<span class="model-preset-role">Vision override</span>' in preset_overview
122 + assert "'model-preset-identity-vision': model.title === 'Vision'" in preset_overview
123 + assert "grid-column: 3;" in preset_overview
124 + assert "padding-top: var(--spacing-xs);" in preset_overview
125 + assert "margin-left: 2rem;" not in preset_overview
126 + assert "border-left: 1px solid var(--color-border);" not in preset_overview
127 + assert "if (slotKey === 'vision') config[sectionKey] = {};" in preset_store
128 + assert "['chat', 'vision', 'utility']" in preset_store
129 +
130 +
131 def test_plugin_settings_reset_is_explicit_and_does_not_capture_toast_early() -> None:
132 settings_modal = read("webui", "components", "plugins", "plugin-settings.html")
133 settings_store = read("webui", "components", "plugins", "plugin-settings-store.js")
tests/test_parallel_tool.py
+36
@@ -160,6 +160,42 @@ def test_normalize_parallel_tool_calls_accepts_json_string_array() -> None:
160 assert calls[1].tool_args["message"] == "Research nuclear fusion news in Italian."
161
162
163 +def test_parallel_keeps_mixed_tools_and_batched_vision_paths() -> None:
164 + calls = parallel_tools.normalize_parallel_tool_calls(
165 + [
166 + {"tool_name": "search_a", "tool_args": {"query": "one"}},
167 + {"tool_name": "search_b", "tool_args": {"query": "two"}},
168 + {"tool_name": "browser_agent", "tool_args": {"message": "open page"}},
169 + {
170 + "tool_name": "vision_load",
171 + "tool_args": {
172 + "paths": ["/tmp/before.png", "/tmp/after.png"],
173 + "query": "compare",
174 + },
175 + },
176 + ]
177 + )
178 +
179 + assert [call.tool_name for call in calls] == [
180 + "search_a",
181 + "search_b",
182 + "browser_agent",
183 + "vision_load",
184 + ]
185 + assert calls[3].tool_args["paths"] == ["/tmp/before.png", "/tmp/after.png"]
186 +
187 +
188 +def test_parallel_allows_multiple_independent_vision_calls() -> None:
189 + calls = parallel_tools.normalize_parallel_tool_calls(
190 + [
191 + {"tool_name": "vision_load", "tool_args": {"paths": ["/tmp/a.png"]}},
192 + {"tool_name": "vision_load", "tool_args": {"paths": ["/tmp/b.png"]}},
193 + ]
194 + )
195 +
196 + assert [call.tool_name for call in calls] == ["vision_load", "vision_load"]
197 +
198 +
199 def test_subordinate_prompts_share_reusable_tree_contract() -> None:
200 call_prompt = (PROJECT_ROOT / "prompts/agent.system.tool.call_sub.md").read_text(
201 encoding="utf-8"
tests/test_tool_policy.py
+53
@@ -546,6 +546,59 @@ async def test_vision_tool_follows_chat_config_not_profile_policy(
546 assert tool_policy.resolve_tool(agent, "vision_load").source == "runtime-config"
547
548
549 +@pytest.mark.asyncio
550 +async def test_vision_sidecar_prompt_replaces_native_vision_prompt(
551 + monkeypatch, tmp_path: Path
552 +) -> None:
553 + _write_prompt(tmp_path, "agent.system.tools.md", "TOOLS\n{{tools}}")
554 + _write_prompt(
555 + tmp_path,
556 + "agent.system.tools_vision.md",
557 + "### vision_load\nnative pixels\nargs: `paths`",
558 + )
559 + _write_prompt(
560 + tmp_path,
561 + "agent.system.tools_vision_sidecar.md",
562 + "### vision_load\nsidecar capsule\nargs: `paths`, `query`",
563 + )
564 + monkeypatch.setattr(tool_policy.subagents, "get_paths", _prompt_paths(tmp_path))
565 + monkeypatch.setattr(
566 + "plugins._model_config.helpers.model_config.get_chat_model_config",
567 + lambda agent: {"vision": True},
568 + )
569 + monkeypatch.setattr(
570 + "plugins._model_config.helpers.model_config.use_vision_sidecar",
571 + lambda agent: True,
572 + )
573 + monkeypatch.setattr(responses_tools, "_mcp_tools", lambda agent: [])
574 + agent = _Agent(tmp_path)
575 +
576 + prompt = await _11_tools_prompt.build_prompt(agent)
577 + schemas, _name_map = responses_tools.build_responses_function_tools(agent)
578 +
579 + assert "sidecar capsule" in prompt
580 + assert "native pixels" not in prompt
581 + assert schemas[0]["name"] == "vision_load"
582 + assert schemas[0]["description"] == "sidecar capsule"
583 +
584 +
585 +def test_vision_sidecar_prompt_declares_multi_image_native_schema() -> None:
586 + prompt = (
587 + Path(__file__).resolve().parents[1]
588 + / "prompts"
589 + / "agent.system.tools_vision_sidecar.md"
590 + ).read_text(encoding="utf-8")
591 + schema = responses_tools._schema_from_prompt(prompt)
592 +
593 + assert schema["required"] == ["paths"]
594 + assert schema["properties"]["paths"] == {
595 + "type": "array",
596 + "items": {"type": "string"},
597 + "description": "Absolute image paths or ephemeral image refs.",
598 + }
599 + assert {"query", "raw"} <= schema["properties"].keys()
600 +
601 +
602 def test_mcp_prompt_and_native_schema_omit_blocked_tool(
603 monkeypatch, tmp_path: Path
604 ) -> None:
tests/test_vision_load_image_refs.py
+223 -7
@@ -1,3 +1,4 @@
1 +import asyncio
2 import types
3 from types import SimpleNamespace
4 import sys
@@ -13,8 +14,13 @@ from helpers import images
14
15
16 class _TestResponse(SimpleNamespace):
16 - def __init__(self, message="", break_loop=False, **kwargs):
17 - super().__init__(message=message, break_loop=break_loop, **kwargs)
17 + def __init__(self, message="", break_loop=False, additional=None, **kwargs):
18 + super().__init__(
19 + message=message,
20 + break_loop=break_loop,
21 + additional=additional,
22 + **kwargs,
23 + )
24
25
26 class _TestTool:
@@ -74,11 +80,9 @@ async def test_vision_load_materializes_local_image_to_chat_artifact(monkeypatch
80
81 monkeypatch.setattr(vision_load_module.chat_media.files, "get_abs_path", fake_get_abs_path)
82 monkeypatch.setattr(vision_load_module.chat_media.files, "normalize_a0_path", fake_normalize_a0_path)
77 - monkeypatch.setattr(
78 - vision_load_module.plugins,
79 - "get_plugin_config",
80 - lambda *args, **kwargs: {"chat_model": {"max_embeds": 10}},
81 - )
83 + monkeypatch.setattr(vision_load_module, "get_chat_model_config", lambda _agent: {"vision": True, "max_embeds": 10})
84 + monkeypatch.setattr(vision_load_module, "get_vision_model_config", lambda _agent: {})
85 + monkeypatch.setattr(vision_load_module, "use_vision_sidecar", lambda _agent: False)
86
87 async def direct_call(func, *args, **kwargs):
88 return func(*args, **kwargs)
@@ -121,3 +125,215 @@ async def test_vision_load_materializes_local_image_to_chat_artifact(monkeypatch
125 stored_path = tmp_path / stored_ref.removeprefix("/a0/")
126 assert stored_path.read_bytes() == b"png-data"
127 assert updates[-1]["result"] == "1 images loaded, 0 skipped"
128 +
129 +
130 +def test_vision_sidecar_route_matrix_prefers_main_native_vision(monkeypatch):
131 + from plugins._model_config.helpers import model_config
132 +
133 + cases = [
134 + ({"vision": False}, {}, False),
135 + ({"vision": True}, {"provider": "p", "name": "v"}, False),
136 + ({"vision": False}, {"provider": "p", "name": "v"}, True),
137 + (
138 + {"vision": True},
139 + {"provider": "p", "name": "v", "override_main": True},
140 + True,
141 + ),
142 + ]
143 + for chat, vision, expected in cases:
144 + monkeypatch.setattr(
145 + model_config,
146 + "get_effective_config",
147 + lambda _agent=None, chat=chat, vision=vision: {
148 + "chat_model": chat,
149 + "vision_model": vision,
150 + },
151 + )
152 + assert model_config.use_vision_sidecar() is expected
153 +
154 +
155 +@pytest.mark.anyio
156 +async def test_vision_sidecar_sends_multiple_images_once_and_keeps_history_text_only(
157 + monkeypatch,
158 + tmp_path,
159 +):
160 + _install_tool_stub(monkeypatch)
161 + import tools.vision_load as vision_load_module
162 +
163 + async def direct_call(func, *args, **kwargs):
164 + return func(*args, **kwargs)
165 +
166 + calls = []
167 +
168 + class FakeVisionModel:
169 + async def unified_call(self, **kwargs):
170 + calls.append(kwargs)
171 + return "The second screenshot fixes the red login error.", ""
172 +
173 + monkeypatch.setattr(vision_load_module.runtime, "call_development_function", direct_call)
174 + monkeypatch.setattr(vision_load_module, "build_vision_model", lambda _agent: FakeVisionModel())
175 + monkeypatch.setattr(
176 + vision_load_module,
177 + "get_chat_model_config",
178 + lambda _agent: {"vision": True, "max_embeds": 1},
179 + )
180 + monkeypatch.setattr(
181 + vision_load_module,
182 + "get_vision_model_config",
183 + lambda _agent: {"provider": "test", "name": "vision", "max_embeds": 5},
184 + )
185 + monkeypatch.setattr(vision_load_module, "use_vision_sidecar", lambda _agent: True)
186 +
187 + image_paths = [tmp_path / "before.png", tmp_path / "after.png"]
188 + for path in image_paths:
189 + path.write_bytes(b"png-data")
190 +
191 + tool_results = []
192 + raw_messages = []
193 + agent = SimpleNamespace(
194 + context=SimpleNamespace(id=""),
195 + agent_name="Agent 0",
196 + hist_add_tool_result=lambda *args, **kwargs: tool_results.append((args, kwargs)),
197 + hist_add_message=lambda *args, **kwargs: raw_messages.append((args, kwargs)),
198 + )
199 + tool = vision_load_module.VisionLoad(
200 + agent=agent,
201 + name="vision_load",
202 + method=None,
203 + args={"paths": [str(path) for path in image_paths]},
204 + message="",
205 + loop_data=None,
206 + )
207 + tool.log = SimpleNamespace(id="vision-log", update=lambda **kwargs: None)
208 +
209 + response = await tool.execute(
210 + paths=[str(path) for path in image_paths],
211 + query="Compare the login errors.",
212 + )
213 + response.additional = {"_responses_output_item": {"output": response.message}}
214 + await tool.after_execution(response)
215 +
216 + assert len(calls) == 1
217 + content = calls[0]["messages"][1].content
218 + assert content[0] == {"type": "text", "text": "Compare the login errors."}
219 + assert [item["type"] for item in content].count("image_url") == 2
220 + assert "fixes the red login error" in response.message
221 + assert response.message != "dummy"
222 + assert raw_messages == []
223 + assert tool.loaded_paths == [str(path) for path in image_paths]
224 + assert tool_results[0][1]["_responses_output_item"]["output"] == response.message
225 +
226 +
227 +@pytest.mark.anyio
228 +async def test_parallel_worker_consumes_parent_ephemeral_image(monkeypatch, tmp_path):
229 + _install_tool_stub(monkeypatch)
230 + import tools.vision_load as vision_load_module
231 +
232 + def fake_get_abs_path(*parts):
233 + return str(tmp_path.joinpath(*parts))
234 +
235 + def fake_normalize_a0_path(path):
236 + return "/a0/" + str(Path(path).relative_to(tmp_path)).replace("\\", "/")
237 +
238 + monkeypatch.setattr(vision_load_module.chat_media.files, "get_abs_path", fake_get_abs_path)
239 + monkeypatch.setattr(vision_load_module.chat_media.files, "normalize_a0_path", fake_normalize_a0_path)
240 + monkeypatch.setattr(vision_load_module.VisionLoad, "_config_agent", lambda self: self.agent)
241 + monkeypatch.setattr(
242 + vision_load_module,
243 + "get_chat_model_config",
244 + lambda _agent: {"vision": True, "max_embeds": 10},
245 + )
246 + monkeypatch.setattr(vision_load_module, "get_vision_model_config", lambda _agent: {})
247 + monkeypatch.setattr(vision_load_module, "use_vision_sidecar", lambda _agent: False)
248 +
249 + parent_id = "parent-vision"
250 + ref = vision_load_module.ephemeral_images.put_image_bytes(
251 + context_id=parent_id,
252 + mime="image/png",
253 + payload=b"png-data",
254 + name="shot.png",
255 + )
256 + context = SimpleNamespace(
257 + id="parallel-worker",
258 + get_data=lambda key: parent_id
259 + if key == vision_load_module.PARALLEL_WORKER_PARENT_CONTEXT_KEY
260 + else None,
261 + )
262 + agent = SimpleNamespace(context=context, agent_name="Agent 0")
263 + tool = vision_load_module.VisionLoad(
264 + agent=agent,
265 + name="vision_load",
266 + method=None,
267 + args={"paths": [ref]},
268 + message="",
269 + loop_data=None,
270 + )
271 +
272 + await tool.execute(paths=[ref])
273 +
274 + assert tool.loaded_paths == ["shot.png"]
275 + assert vision_load_module.ephemeral_images.get_image(ref, context_id=parent_id) is None
276 + stored_ref = tool.images_dict["shot.png"]
277 + assert stored_ref.startswith("/a0/usr/chats/parent-vision/images/vision-load/shot-")
278 +
279 +
280 +@pytest.mark.anyio
281 +async def test_independent_vision_sidecar_calls_can_run_concurrently(monkeypatch, tmp_path):
282 + _install_tool_stub(monkeypatch)
283 + import tools.vision_load as vision_load_module
284 +
285 + active = 0
286 + max_active = 0
287 + call_count = 0
288 +
289 + class FakeVisionModel:
290 + async def unified_call(self, **kwargs):
291 + nonlocal active, max_active, call_count
292 + active += 1
293 + call_count += 1
294 + max_active = max(max_active, active)
295 + await asyncio.sleep(0.02)
296 + active -= 1
297 + return "done", ""
298 +
299 + async def direct_call(func, *args, **kwargs):
300 + return func(*args, **kwargs)
301 +
302 + monkeypatch.setattr(vision_load_module.runtime, "call_development_function", direct_call)
303 + monkeypatch.setattr(vision_load_module, "build_vision_model", lambda _agent: FakeVisionModel())
304 + monkeypatch.setattr(vision_load_module, "get_chat_model_config", lambda _agent: {"vision": False})
305 + monkeypatch.setattr(
306 + vision_load_module,
307 + "get_vision_model_config",
308 + lambda _agent: {"provider": "test", "name": "vision", "max_embeds": 10},
309 + )
310 + monkeypatch.setattr(vision_load_module, "use_vision_sidecar", lambda _agent: True)
311 +
312 + image_paths = [tmp_path / "one.png", tmp_path / "two.png"]
313 + for path in image_paths:
314 + path.write_bytes(b"png-data")
315 +
316 + def make_tool(index):
317 + agent = SimpleNamespace(context=SimpleNamespace(id=""), agent_name=f"Agent {index}")
318 + return vision_load_module.VisionLoad(
319 + agent=agent,
320 + name="vision_load",
321 + method=None,
322 + args={"paths": [str(path) for path in image_paths]},
323 + message="",
324 + loop_data=None,
325 + )
326 +
327 + responses = await asyncio.gather(
328 + *(
329 + make_tool(index).execute(
330 + paths=[str(path) for path in image_paths],
331 + query=f"inspection {index}",
332 + )
333 + for index in range(4)
334 + )
335 + )
336 +
337 + assert call_count == 4
338 + assert max_active == 4
339 + assert all("done" in response.message for response in responses)
tools/vision_load.py
+214 -106
@@ -1,89 +1,219 @@
1 -from helpers.print_style import PrintStyle
2 -from helpers.tool import Tool, Response
3 -from helpers import runtime, files, plugins, ephemeral_images, images, chat_media
1 +import asyncio
2 +import json
3 from mimetypes import guess_type
5 -from helpers import history
4
7 -# image token estimation for context window
5 +from langchain_core.messages import HumanMessage, SystemMessage
6 +
7 +from helpers import chat_media, ephemeral_images, files, history, images, runtime
8 +from helpers.parallel_tools import PARALLEL_WORKER_PARENT_CONTEXT_KEY, coerce_bool
9 +from helpers.print_style import PrintStyle
10 +from helpers.tool import Response, Tool
11 +from plugins._model_config.helpers.model_config import (
12 + build_vision_model,
13 + get_chat_model_config,
14 + get_vision_model_config,
15 + use_vision_sidecar,
16 +)
17 +
18 TOKENS_ESTIMATE = 1500
19 +VISION_TIMEOUT_SECONDS = 300
20 +VISION_SYSTEM_PROMPT = (
21 + "You are a precise vision analyst. Answer only what was asked about the images. "
22 + "Be concise and factual. Preserve exact visible text when asked to read it."
23 +)
24 +DEFAULT_VISION_QUERY = (
25 + "Describe the images precisely, including the key objects, visible text, and layout."
26 +)
27
28
29 class VisionLoad(Tool):
12 - async def execute(self, paths: list[str] = [], **kwargs) -> Response:
13 -
14 - self.images_dict = {}
30 + async def execute(
31 + self,
32 + paths: list[str] | str | None = None,
33 + query: str = "",
34 + raw: bool = False,
35 + **kwargs,
36 + ) -> Response:
37 + self.images_dict: dict[str, str] = {}
38 self.loaded_paths: list[str] = []
39 self.skipped_paths: list[str] = []
40 + self._config_owner = self._config_agent()
41 + self._main_has_vision = bool(
42 + get_chat_model_config(self._config_owner).get("vision", False)
43 + )
44 + self._delegated = use_vision_sidecar(self._config_owner) and not (
45 + coerce_bool(raw, False) and self._main_has_vision
46 + )
47 + self._max_embeds = self._get_max_embeds()
48 +
49 + normalized = self._normalize_paths(paths)
50 + if isinstance(normalized, str):
51 + self._history_result = normalized
52 + return Response(message=normalized, break_loop=False)
53
18 - max_embeds = self._get_max_embeds()
54 requested = [
20 - (str(path or "").strip(), self._display_input_path(str(path or "").strip(), idx + 1))
21 - for idx, path in enumerate(paths)
55 + (path.strip(), self._display_input_path(path.strip(), index + 1))
56 + for index, path in enumerate(normalized)
57 ]
23 - limited_paths = requested if max_embeds <= 0 else requested[-max_embeds:]
24 - self.skipped_paths = (
25 - [display for _, display in requested[:-max_embeds]]
26 - if max_embeds > 0 and len(requested) > max_embeds
27 - else []
28 - )
58 + limited = requested if self._max_embeds <= 0 else requested[-self._max_embeds :]
59 + if self._max_embeds > 0 and len(requested) > self._max_embeds:
60 + self.skipped_paths = [display for _, display in requested[: -self._max_embeds]]
61
30 - for idx, (path, display_path) in enumerate(limited_paths):
62 + for index, (path, display_path) in enumerate(limited):
63 if not path:
64 continue
65 if ephemeral_images.is_ref(path):
34 - image = ephemeral_images.consume_image(
35 - path,
36 - context_id=self._context_id(),
37 - )
66 + image = ephemeral_images.consume_image(path, context_id=self._context_id())
67 if image is None:
68 continue
40 - display = image.display_name or display_path
69 + display_path = image.display_name or display_path
70 stored_ref = self._store_ephemeral_image(image)
42 - if stored_ref:
43 - self.images_dict[display] = stored_ref
44 - self.loaded_paths.append(display)
45 - continue
46 - if self._is_data_image_url(path):
47 - stored_ref = self._store_data_url(path, preferred_name=f"vision-load-{idx + 1}.png")
48 - if stored_ref:
49 - self.images_dict[display_path] = stored_ref
50 - self.loaded_paths.append(display_path)
51 - continue
52 - if not await runtime.call_development_function(files.exists, str(path)):
71 + elif self._is_data_image_url(path):
72 + stored_ref = self._store_data_url(
73 + path, preferred_name=f"vision-load-{index + 1}.png"
74 + )
75 + elif await runtime.call_development_function(files.exists, path):
76 + mime_type, _ = guess_type(path)
77 + if not mime_type or not mime_type.startswith("image/"):
78 + continue
79 + try:
80 + stored_ref = self._store_local_image(
81 + path, preferred_name=files.basename(path)
82 + )
83 + except (FileNotFoundError, OSError, ValueError):
84 + continue
85 + else:
86 continue
87
55 - if path not in self.images_dict:
56 - mime_type, _ = guess_type(str(path))
57 - if mime_type and mime_type.startswith("image/"):
58 - try:
59 - stored_ref = self._store_local_image(path, preferred_name=files.basename(path))
60 - self.images_dict[display_path] = stored_ref
61 - self.loaded_paths.append(display_path)
62 - except (FileNotFoundError, OSError, ValueError):
63 - continue
88 + if stored_ref:
89 + self.images_dict[display_path] = stored_ref
90 + self.loaded_paths.append(display_path)
91
65 - return Response(message="dummy", break_loop=False)
92 + summary = self._summary()
93 + if self._delegated and self.images_dict:
94 + try:
95 + capsule = await self._call_vision_model(
96 + list(self.images_dict.values()), self._query(query, kwargs)
97 + )
98 + message = (
99 + f"Vision Model analyzed {len(self.images_dict)} image(s)"
100 + f"; {len(self.skipped_paths)} skipped.\n\n{capsule.strip()}"
101 + )
102 + self._history_result = message
103 + return Response(message=message, break_loop=False)
104 + except Exception as exc:
105 + message = f"Vision Model error: {str(exc)[:1000]}"
106 + self._history_result = f"{summary}\n\n{message}"
107 + return Response(message=message, break_loop=False)
108 +
109 + if self.images_dict and not self._main_has_vision:
110 + summary += (
111 + "\n\nImages were not injected because neither Main native vision nor "
112 + "a usable Vision Model is active."
113 + )
114 + self._history_result = (
115 + summary if self.images_dict or self.skipped_paths else "No images processed"
116 + )
117 + message = (
118 + "No images processed"
119 + if not self.images_dict and not self.skipped_paths
120 + else f"{len(self.images_dict)} images loaded, {len(self.skipped_paths)} skipped"
121 + )
122 + return Response(message=message, break_loop=False)
123 +
124 + async def after_execution(self, response: Response, **kwargs):
125 + log_id = str(getattr(getattr(self, "log", None), "id", "") or "")
126 + self.agent.hist_add_tool_result(
127 + self.name,
128 + self._history_result,
129 + id=log_id,
130 + **(response.additional or {}),
131 + )
132 +
133 + if self.images_dict and self._main_has_vision and not self._delegated:
134 + content = [
135 + {"type": "image_url", "image_url": {"url": image_path}}
136 + for image_path in self.images_dict.values()
137 + ]
138 + self.agent.hist_add_message(
139 + False,
140 + content=history.RawMessage(
141 + raw_content=content,
142 + preview="<Image attachments loaded by path>",
143 + ),
144 + tokens=TOKENS_ESTIMATE * len(content),
145 + )
146 +
147 + PrintStyle(
148 + font_color="#1B4F72", background_color="white", padding=True, bold=True
149 + ).print(f"{self.agent.agent_name}: Response from tool '{self.name}'")
150 + PrintStyle(font_color="#85C1E9").print(response.message)
151 + if getattr(self, "log", None):
152 + self.log.update(result=response.message)
153
154 def _get_max_embeds(self) -> int:
68 - cfg = plugins.get_plugin_config("_model_config", agent=self.agent) or {}
69 - chat_cfg = cfg.get("chat_model", {})
70 - max_embeds = chat_cfg.get("max_embeds", 10)
71 - return int(max_embeds or 0)
155 + cfg = (
156 + get_vision_model_config(self._config_owner)
157 + if self._delegated
158 + else get_chat_model_config(self._config_owner)
159 + )
160 + try:
161 + return int(cfg.get("max_embeds", 10) or 0)
162 + except (TypeError, ValueError):
163 + return 10
164
165 def _context_id(self) -> str:
74 - return str(getattr(getattr(self.agent, "context", None), "id", "") or "").strip()
166 + context = getattr(self.agent, "context", None)
167 + if not context:
168 + return ""
169 + get_data = getattr(context, "get_data", None)
170 + parent_id = get_data(PARALLEL_WORKER_PARENT_CONTEXT_KEY) if get_data else ""
171 + return str(parent_id or getattr(context, "id", "") or "").strip()
172 +
173 + def _config_agent(self):
174 + context = getattr(self.agent, "context", None)
175 + get_data = getattr(context, "get_data", None)
176 + parent_id = get_data(PARALLEL_WORKER_PARENT_CONTEXT_KEY) if get_data else ""
177 + if parent_id:
178 + from agent import AgentContext
179 +
180 + parent = AgentContext.get(str(parent_id))
181 + if parent:
182 + return parent.agent0
183 + return self.agent
184 +
185 + async def _call_vision_model(self, image_paths: list[str], query: str) -> str:
186 + model = build_vision_model(self._config_owner)
187 + content = [{"type": "text", "text": query or DEFAULT_VISION_QUERY}]
188 + content.extend(
189 + {"type": "image_url", "image_url": {"url": path}}
190 + for path in image_paths
191 + )
192 + response, _ = await asyncio.wait_for(
193 + model.unified_call(
194 + messages=[
195 + SystemMessage(content=VISION_SYSTEM_PROMPT),
196 + HumanMessage(content=content),
197 + ],
198 + explicit_caching=False,
199 + max_tokens=2000,
200 + ),
201 + timeout=VISION_TIMEOUT_SECONDS,
202 + )
203 + if not str(response or "").strip():
204 + raise RuntimeError("Vision Model returned an empty response.")
205 + return str(response)
206
207 def _store_ephemeral_image(self, image: ephemeral_images.EphemeralImage) -> str:
208 context_id = self._context_id()
209 if not context_id:
210 return image.data_url
211 source = chat_media.infer_source(image.ref, image.display_name)
81 - category = chat_media.category_for_source(source)
212 saved = chat_media.save_image_base64(
213 context_id=context_id,
214 data=image.data,
215 mime_type=image.mime,
86 - category=category,
216 + category=chat_media.category_for_source(source),
217 source=source,
218 preferred_name=image.display_name,
219 )
@@ -94,11 +224,10 @@ class VisionLoad(Tool):
224 if not context_id:
225 return data_url
226 source = chat_media.infer_source(data_url, preferred_name)
97 - category = chat_media.category_for_source(source)
227 saved = chat_media.save_image_data_url(
228 context_id=context_id,
229 data_url=data_url,
101 - category=category,
230 + category=chat_media.category_for_source(source),
231 source=source,
232 preferred_name=preferred_name,
233 )
@@ -115,6 +244,37 @@ class VisionLoad(Tool):
244 preferred_name=preferred_name,
245 )
246
247 + def _summary(self) -> str:
248 + loaded = "\n".join(self.loaded_paths) if self.loaded_paths else "none"
249 + skipped = "\n".join(self.skipped_paths) if self.skipped_paths else "none"
250 + return (
251 + f"Loaded images ({len(self.loaded_paths)}):\n{loaded}\n\n"
252 + f"Skipped images ({len(self.skipped_paths)}, max {self._max_embeds}):\n{skipped}"
253 + )
254 +
255 + @staticmethod
256 + def _normalize_paths(paths: list[str] | str | None) -> list[str] | str:
257 + if isinstance(paths, str):
258 + try:
259 + decoded = json.loads(paths)
260 + except json.JSONDecodeError:
261 + decoded = paths
262 + paths = decoded if isinstance(decoded, list) else [paths]
263 + if paths is None:
264 + return []
265 + if not isinstance(paths, (list, tuple)):
266 + return "vision_load error: `paths` must be an array of image paths."
267 + return [str(path or "").strip() for path in paths]
268 +
269 + @staticmethod
270 + def _query(query: str, kwargs: dict) -> str:
271 + if str(query or "").strip():
272 + return str(query).strip()
273 + for key in ("prompt", "question", "instruction", "focus", "request"):
274 + if str(kwargs.get(key) or "").strip():
275 + return str(kwargs[key]).strip()
276 + return DEFAULT_VISION_QUERY
277 +
278 @staticmethod
279 def _is_data_image_url(value: str) -> bool:
280 normalized = str(value or "").strip().lower()
@@ -125,57 +285,5 @@ class VisionLoad(Tool):
285 if ephemeral_images.is_ref(value):
286 return ephemeral_images.display_ref(value)
287 if cls._is_data_image_url(value):
128 - prefix = value.split(",", 1)[0]
129 - return f"{prefix},<ephemeral-image-{index}>"
288 + return f"{value.split(',', 1)[0]},<ephemeral-image-{index}>"
289 return value
131 -
132 - async def after_execution(self, response: Response, **kwargs):
133 -
134 - # build image data messages for LLMs, or error message
135 - content = []
136 - loaded_count = len(self.loaded_paths)
137 - skipped_count = len(self.skipped_paths)
138 - loaded_summary = "\n".join(self.loaded_paths) if self.loaded_paths else "none"
139 - skipped_summary = "\n".join(self.skipped_paths) if self.skipped_paths else "none"
140 - summary = (
141 - f"Loaded images: {loaded_count}\n"
142 - f"Loaded images:\n{loaded_summary}\n\n"
143 - f"Skipped images: {skipped_count}\n"
144 - f"Skipped images (max {self._get_max_embeds()} loaded at a time according to model configuration):\n{skipped_summary}"
145 - )
146 - if self.images_dict:
147 - self.agent.hist_add_tool_result(self.name, summary, id=self.log.id if self.log else "")
148 - for path, image_path in self.images_dict.items():
149 - if image_path:
150 - content.append(
151 - {
152 - "type": "image_url",
153 - "image_url": {"url": image_path},
154 - }
155 - )
156 - else:
157 - content.append(
158 - {
159 - "type": "text",
160 - "text": "Error processing image " + path,
161 - }
162 - )
163 - # append as raw message content for LLMs with vision tokens estimate
164 - msg = history.RawMessage(raw_content=content, preview="<Image attachments loaded by path>")
165 - self.agent.hist_add_message(
166 - False, content=msg, tokens=TOKENS_ESTIMATE * len(content)
167 - )
168 - else:
169 - self.agent.hist_add_tool_result(self.name, summary if self.skipped_paths else "No images processed", id=self.log.id if self.log else "")
170 -
171 - # print and log short version
172 - message = (
173 - "No images processed"
174 - if not self.images_dict and not self.skipped_paths
175 - else f"{loaded_count} images loaded, {skipped_count} skipped"
176 - )
177 - PrintStyle(
178 - font_color="#1B4F72", background_color="white", padding=True, bold=True
179 - ).print(f"{self.agent.agent_name}: Response from tool '{self.name}'")
180 - PrintStyle(font_color="#85C1E9").print(message)
181 - self.log.update(result=message)
tools/vision_load.py.dox.md
+9 -3
@@ -3,7 +3,7 @@
3 ## Purpose
4
5 - Own the `vision_load.py` agent tool.
6 -- This module loads images into model-visible content for vision-capable models.
6 +- This module routes images either into Main model-visible content or through the preset's optional Vision Model.
7 - Keep this file-level DOX profile synchronized with `vision_load.py` because this directory is intentionally flat.
8
9 ## Ownership
@@ -12,13 +12,19 @@
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: list[str]=..., **kwargs) -> Response`
15 + - `async execute(self, paths, query="", raw=False, **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.
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.
27 +- `max_embeds` comes from the model that actually receives the images.
28 - Update this file whenever tool arguments, output shape, `break_loop` behavior, intervention handling, prompt instructions, or side effects change.
29 - `VisionLoad` is a `Tool`.
30 - `VisionLoad` defines `execute(...)`.
@@ -27,7 +33,7 @@
33
34 ## Key Concepts
35
30 -- Important called helpers/classes observed in the source: `self._get_max_embeds`, `Response`, `str.strip`, `self._context_id`, `chat_media.infer_source`, `chat_media.category_for_source`, `chat_media.save_image_base64`, `chat_media.save_image_data_url`, `chat_media.materialize_image_ref`, `str.strip.lower`, `ephemeral_images.is_ref`, `cls._is_data_image_url`, `self._is_data_image_url`, `plugins.get_plugin_config`, `images.to_data_url`, `normalized.startswith`, `ephemeral_images.display_ref`, `join`, `self.agent.hist_add_tool_result`, `history.RawMessage`.
36 +- 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`.
37 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
38
39 ## Work Guidance