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.
This commit is contained in:
Alessandro 2026-08-25 15:14:03 +02:00
parent 80bcb3cf0d
commit 59f5ae03ea
21 changed files with 880 additions and 150 deletions

View file

@ -51,10 +51,15 @@ async def build_prompt(agent: Agent) -> str:
prompt = agent.read_prompt("agent.system.tools.md", tools=tools_str)
# vision support
from plugins._model_config.helpers.model_config import get_chat_model_config
from plugins._model_config.helpers.model_config import (
get_chat_model_config,
use_vision_sidecar,
)
chat_cfg = get_chat_model_config(agent)
if chat_cfg.get("vision", False):
if use_vision_sidecar(agent):
prompt += "\n\n" + agent.read_prompt("agent.system.tools_vision_sidecar.md")
elif chat_cfg.get("vision", False):
prompt += "\n\n" + agent.read_prompt("agent.system.tools_vision.md")
return prompt

View file

@ -114,8 +114,13 @@ def _local_tool_prompts(agent: Any) -> list[tuple[str, str]]:
def _vision_tool_prompt(agent: Any) -> str:
try:
from plugins._model_config.helpers.model_config import get_chat_model_config
from plugins._model_config.helpers.model_config import (
get_chat_model_config,
use_vision_sidecar,
)
if use_vision_sidecar(agent):
return agent.read_prompt("agent.system.tools_vision_sidecar.md")
if not get_chat_model_config(agent).get("vision", False):
return ""
return agent.read_prompt("agent.system.tools_vision.md")

View file

@ -12,7 +12,7 @@
## Local Contracts
- 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.
- 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.
- Discover local prompt files through `helpers.subagents.get_paths`; this module
owns the Responses-specific prompt-name compatibility rules.
- 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 @@
- Preserve original Agent Zero tool names through the native Responses name map.
- Keep MCP tool schemas merged after local prompt-derived tools.
- Apply `helpers.tool_policy` before emitting local or MCP schemas; a blocked
capability is absent from provider-native tool definitions. Vision remains
controlled solely by the active chat model configuration.
capability is absent from provider-native tool definitions. Vision routing
is controlled by the effective model preset rather than Agent Editor.
- Connector remote tools are advertised only when `_a0_connector` runtime metadata says the matching connected CLI capability is currently available.
## Work Guidance

View file

@ -14,7 +14,7 @@
## Local Contracts
- `Default` is the first global preset and cannot be deleted or renamed. It owns the complete main, utility, and embedding baseline.
- `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.
- 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.
- Preserve scoped plugin resolution order and fall back invalid or missing scope/chat references to `Default`.
- 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 @@
- Check API-key readiness only for the effective model configuration; unused global presets must not produce Welcome-screen warnings.
- Coordinate OAuth-backed providers with `_oauth` instead of hardcoding provider-specific auth here.
- `model_config_get` exposes `model_configured` as a derived chat-model readiness flag from provider, model name, and API-key availability.
- 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.
- 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.
- The optional `vision` slot is strictly per preset and never inherited from `Default`; an empty slot disables sidecar vision for that preset.
- 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`.
- 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.
- 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.
- 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.
- Changing a model provider in the settings UI must clear `api_base` and `kwargs` because both may be provider-specific.
- 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`.
- `modelConfig.createPresetEditor()` owns local preset drafts, row actions, and stable UI-only row keys so deletion or renaming cannot rebind nested model fields.

View file

@ -65,6 +65,16 @@
</span>
</div>
</template>
<template x-if="preset.vision?.name && (!preset.chat?.vision || preset.vision?.override_main)">
<div class="model-switcher-model-row">
<span class="model-switcher-model-label">Vision</span>
<span class="model-switcher-model-value">
<span x-text="preset.vision.provider" style="opacity:0.5;"></span>
<span style="opacity:0.3; margin:0 3px;">/</span>
<span x-text="preset.vision.name"></span>
</span>
</div>
</template>
<template x-if="preset.utility?.name">
<div class="model-switcher-model-row">
<span class="model-switcher-model-label">Utility</span>

View file

@ -16,11 +16,21 @@ PRESET_SCOPE_GLOBAL = "global"
PRESET_SCOPE_PROJECT = "project"
PRESET_SLOT_CONFIG_SECTIONS = {
"chat": "chat_model",
"vision": "vision_model",
"utility": "utility_model",
"embedding": "embedding_model",
}
MODEL_SLOT_PRESET_REPLACE_FIELDS = {"kwargs"}
IMPLICIT_PRESET_SLOT_DEFAULTS = {
"vision": {
"vision": True,
"max_embeds": 10,
"override_main": False,
"rl_requests": 0,
"rl_input": 0,
"rl_output": 0,
"kwargs": {},
},
"utility": {
"ctx_length": 128000,
"ctx_input": 0.7,
@ -267,6 +277,9 @@ def _clean_preset_for_file(preset: dict) -> dict:
slot_config = preset.get(slot)
if isinstance(slot_config, dict):
slot_clean = _strip_ui_fields(slot_config, strip_api_key=True)
if slot == "vision" and _slot_has_identity(slot_clean):
slot_clean["vision"] = True
slot_clean.setdefault("max_embeds", 10)
cleaned[slot] = (
slot_clean
if name == DEFAULT_PRESET_NAME
@ -339,7 +352,12 @@ def validate_presets(presets: list, *, require_default: bool = True) -> list:
def normalize_config_for_save(config: dict) -> dict:
"""Remove UI-only fields and inline API keys before storing scoped config."""
cleaned = deepcopy(config or {})
for section_name in ("chat_model", "utility_model", "embedding_model"):
for section_name in (
"chat_model",
"vision_model",
"utility_model",
"embedding_model",
):
section = cleaned.get(section_name)
if isinstance(section, dict):
cleaned[section_name] = _strip_ui_fields(section, strip_api_key=True)
@ -659,10 +677,12 @@ def build_config_from_preset(
continue
slot_config = _get_preset_slot_config(preset, slot)
if not _should_apply_preset_slot(slot, slot_config):
if slot == "vision":
config[section] = {}
continue
config[section] = _merge_model_slot(
slot,
config.get(section, {}),
{} if slot == "vision" else config.get(section, {}),
slot_config,
strip_api_key=strip_api_key,
)
@ -738,6 +758,23 @@ def get_chat_model_config(agent=None) -> dict:
return get_effective_config(agent).get("chat_model", {})
def get_vision_model_config(agent=None) -> dict:
"""Get the optional, strictly per-preset Vision Model config."""
return get_effective_config(agent).get("vision_model", {})
def use_vision_sidecar(agent=None) -> bool:
"""Return whether vision_load should delegate to the preset's Vision Model."""
vision_cfg = get_vision_model_config(agent)
if not (
str(vision_cfg.get("provider") or "").strip()
and str(vision_cfg.get("name") or "").strip()
):
return False
chat_cfg = get_chat_model_config(agent)
return not bool(chat_cfg.get("vision")) or bool(vision_cfg.get("override_main"))
def get_utility_model_config(agent=None) -> dict:
"""Get utility model config, with per-chat override if active."""
return get_effective_config(agent).get("utility_model", {})
@ -835,6 +872,16 @@ def build_utility_model(agent=None):
)
def build_vision_model(agent=None):
"""Build the optional Vision Model selected by the effective preset."""
cfg = get_vision_model_config(agent)
mc = build_model_config(cfg, models.ModelType.CHAT)
mc.vision = True
return models.get_chat_model(
mc.provider, mc.name, model_config=mc, **mc.build_kwargs()
)
def build_embedding_model(agent=None):
"""Build and return an embedding model wrapper."""
cfg = get_embedding_model_config(agent)
@ -881,6 +928,8 @@ def get_missing_api_key_providers(agent=None) -> list[dict]:
("Utility Model", cfg.get("utility_model", {})),
("Embedding Model", get_embedding_model_config(agent)),
]
if use_vision_sidecar(agent):
checks.insert(1, ("Vision Model", cfg.get("vision_model", {})))
for label, model_cfg in checks:
provider = model_cfg.get("provider", "")

View file

@ -21,7 +21,7 @@
<div class="preset-editor-intro">
<div class="field-title">Model Presets</div>
<div class="field-description">
Each preset contains the complete main, utility, and embedding model setup. Default is always available.
Each preset contains the main, utility, embedding, and optional vision-sidecar setup. Default is always available.
</div>
</div>
@ -67,9 +67,21 @@
<div class="section-title">Main Model</div>
<div class="section-description">Primary model for conversations, reasoning, and tools.</div>
</div>
<div x-data="{ get model() { return selectedPreset.chat; }, modelType: 'chat', providers: $store.modelConfig.chatProviders, searchType: 'chat', apiKeyMode: 'store' }">
<div x-data="{ get model() { return selectedPreset.chat; }, get visionModel() { return selectedPreset.vision; }, modelType: 'chat', providers: $store.modelConfig.chatProviders, searchType: 'chat', apiKeyMode: 'store' }">
<x-component path="/plugins/_model_config/webui/model-field.html"></x-component>
</div>
<template x-if="!selectedPreset.chat.vision || selectedPreset.vision.override_main">
<div class="vision-sidecar-selector">
<div class="field-title">Vision sidecar</div>
<div class="field-description"
x-text="selectedPreset.chat.vision
? 'Overrides Main model vision for vision_load.'
: 'Used by vision_load while Main model vision is disabled. Leave empty to disable image analysis.'"></div>
<div x-data="{ get model() { return selectedPreset.vision; }, modelType: 'vision', providers: $store.modelConfig.chatProviders, searchType: 'chat', apiKeyMode: 'store' }">
<x-component path="/plugins/_model_config/webui/model-field.html"></x-component>
</div>
</div>
</template>
</section>
<section class="preset-model-section">
@ -166,6 +178,11 @@
margin-top: 0;
}
.vision-sidecar-selector {
margin: 0.75rem 0 0;
padding: 0.25rem 0 0;
}
.preset-editor-secondary-actions {
display: flex;
flex-wrap: wrap;

View file

@ -7,6 +7,7 @@ import { switcherState, switcherMethods } from "/plugins/_model_config/webui/swi
export const MODEL_SECTIONS = [
{ key: 'chat_model', title: 'Main Model', desc: 'Primary model for chat, reasoning, and browser tasks.' },
{ key: 'vision_model', title: 'Vision Model', desc: 'Optional model used by vision_load when Main vision is unavailable or overridden.' },
{ key: 'utility_model', title: 'Utility Model', desc: 'Lightweight model for background tasks: memory management, prompt preparation, summarization.' },
{ key: 'embedding_model', title: 'Embedding Model', desc: 'Model for generating vector embeddings used in knowledge retrieval.' }
];
@ -59,6 +60,15 @@ function isBlankPresetValue(value) {
}
const IMPLICIT_PRESET_SLOT_DEFAULTS = {
vision: {
vision: true,
max_embeds: 10,
override_main: false,
rl_requests: 0,
rl_input: 0,
rl_output: 0,
kwargs: {},
},
utility: {
ctx_length: 128000,
ctx_input: 0.7,
@ -143,15 +153,21 @@ export function configFromPreset(preset, baseConfig, stripApiKey = true) {
const config = clonePlain(baseConfig || {});
const slots = [
['chat', 'chat_model'],
['vision', 'vision_model'],
['utility', 'utility_model'],
['embedding', 'embedding_model'],
];
for (const [slotKey, sectionKey] of slots) {
const slot = preset?.[slotKey];
if (!slot || typeof slot !== 'object') continue;
if (!hasModelIdentity(slot)) continue;
config[sectionKey] = mergeModelSlot(config[sectionKey] || {}, slot, stripApiKey, slotKey);
if (slotKey === 'vision') config[sectionKey] = {};
if (!slot || typeof slot !== 'object' || !hasModelIdentity(slot)) continue;
config[sectionKey] = mergeModelSlot(
slotKey === 'vision' ? {} : (config[sectionKey] || {}),
slot,
stripApiKey,
slotKey,
);
}
return config;
@ -206,8 +222,10 @@ export const store = createStore("modelConfig", {
const source = (rawPresets || []).filter(p => p && typeof p === 'object');
const rawDefault = source.find(p => String(p.name || '').toLowerCase() === 'default') || {};
const slot = value => ({ provider: '', name: '', api_key: '', api_base: '', kwargs: {}, ...(value || {}) });
const visionSlot = value => ({ ...slot(value), vision: true, max_embeds: Number(value?.max_embeds ?? 10), override_main: !!value?.override_main });
const defaultConfig = {
chat_model: slot(rawDefault.chat),
vision_model: hasModelIdentity(rawDefault.vision) ? visionSlot(rawDefault.vision) : {},
utility_model: slot(rawDefault.utility),
embedding_model: slot(rawDefault.embedding),
};
@ -219,6 +237,7 @@ export const store = createStore("modelConfig", {
return {
name: p.name || '',
chat: { ...slot(effective.chat_model), _kwargs_text: kwargsToText(effective.chat_model?.kwargs) },
vision: { ...visionSlot(effective.vision_model), _kwargs_text: kwargsToText(effective.vision_model?.kwargs) },
utility: { ...slot(effective.utility_model), _kwargs_text: kwargsToText(effective.utility_model?.kwargs) },
embedding: { ...slot(effective.embedding_model), _kwargs_text: kwargsToText(effective.embedding_model?.kwargs) },
};
@ -273,6 +292,7 @@ export const store = createStore("modelConfig", {
// Config field initialization (converts kwargs dicts to editable text)
initConfigFields(config) {
if (config?.chat_model) config.chat_model._kwargs_text = kwargsToText(config.chat_model.kwargs);
if (config?.vision_model) config.vision_model._kwargs_text = kwargsToText(config.vision_model.kwargs);
if (config?.utility_model) config.utility_model._kwargs_text = kwargsToText(config.utility_model.kwargs);
if (config?.embedding_model) config.embedding_model._kwargs_text = kwargsToText(config.embedding_model.kwargs);
},
@ -340,6 +360,7 @@ export const store = createStore("modelConfig", {
|| this.presets[0]
|| {
chat: { provider: '', name: '', api_base: '', kwargs: {}, _kwargs_text: '' },
vision: { provider: '', name: '', api_base: '', vision: true, max_embeds: 10, override_main: false, kwargs: {}, _kwargs_text: '' },
utility: { provider: '', name: '', api_base: '', kwargs: {}, _kwargs_text: '' },
embedding: { provider: '', name: '', api_base: '', kwargs: {}, _kwargs_text: '' },
}
@ -420,9 +441,10 @@ export const store = createStore("modelConfig", {
const clean = presets.map(p => {
const c = { name: p.name };
const isDefault = p._originalName === DEFAULT_PRESET_NAME;
for (const slot of ['chat', 'utility']) {
for (const slot of ['chat', 'vision', 'utility']) {
if (p[slot]) {
const rest = cleanPresetSlot(p[slot], true, slot, isDefault);
if (slot === 'vision' && hasModelIdentity(rest)) rest.vision = true;
if (hasModelIdentity(rest)) c[slot] = rest;
}
}
@ -608,9 +630,15 @@ export const store = createStore("modelConfig", {
const label = (list, id) => (list.find(x => x.value === id) || {}).label || id || '\u2014';
return [
{ icon: 'chat', title: 'Main', cfg: preset?.chat, pList: chatP },
{ icon: 'eye', title: 'Vision', cfg: preset?.vision, pList: chatP },
{ icon: 'manufacturing', title: 'Utility', cfg: preset?.utility, pList: chatP },
{ icon: 'database', title: 'Embedding', cfg: preset?.embedding, pList: embedP },
].map(s => ({ icon: s.icon, title: s.title, provider: label(s.pList, s.cfg?.provider), name: s.cfg?.name || '\u2014' }));
]
.filter(s => s.title !== 'Vision' || (
hasModelIdentity(s.cfg)
&& (!preset?.chat?.vision || s.cfg?.override_main)
))
.map(s => ({ icon: s.icon, title: s.title, provider: label(s.pList, s.cfg?.provider), name: s.cfg?.name || '\u2014' }));
},
getPreset(name) {
@ -747,9 +775,15 @@ export const store = createStore("modelConfig", {
const label = (list, id) => (list.find(x => x.value === id) || {}).label || id || '\u2014';
return [
{ icon: 'chat', title: 'Main', cfg: cfg.chat_model, pList: chatP },
{ icon: 'eye', title: 'Vision', cfg: cfg.vision_model, pList: chatP },
{ icon: 'manufacturing', title: 'Utility', cfg: cfg.utility_model, pList: chatP },
{ icon: 'database', title: 'Embedding', cfg: cfg.embedding_model, pList: embedP },
].map(s => ({ icon: s.icon, title: s.title, provider: label(s.pList, s.cfg?.provider), name: s.cfg?.name || '\u2014' }));
]
.filter(s => s.title !== 'Vision' || (
hasModelIdentity(s.cfg)
&& (!cfg.chat_model?.vision || s.cfg?.override_main)
))
.map(s => ({ icon: s.icon, title: s.title, provider: label(s.pList, s.cfg?.provider), name: s.cfg?.name || '\u2014' }));
},
async refreshModelsSummary(contextId = '') {

View file

@ -10,13 +10,14 @@
Reusable model configuration field set.
Parent x-data scope must provide:
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
modelType — 'chat' | 'utility' | 'embedding'
modelType — 'chat' | 'vision' | 'utility' | 'embedding'
providers — array of { value, label }
searchType — 'chat' | 'embedding'
apiKeyMode — 'store' (key lives in $store.modelConfig.apiKeyValues), 'inline' (key lives in model.api_key), or 'none'
Optional:
providerFallback — fallback provider string for search/API key status (e.g. preset.chat.provider for utility slot)
apiBaseFallback — fallback api_base string for search (e.g. preset.chat.api_base for utility slot)
visionModel — vision slot object, required by the chat model's sidecar switch
-->
<div x-data="{ get _prov() { return model.provider || (typeof providerFallback !== 'undefined' ? providerFallback : ''); }, get _apiBase() { return model.api_base || (typeof apiBaseFallback !== 'undefined' ? apiBaseFallback : ''); } }">
<!-- Provider -->
@ -146,8 +147,23 @@
</div>
</template>
<template x-if="modelType === 'chat' && model.vision">
<div class="field">
<div class="field-label">
<div class="field-title">Use separate Vision Model</div>
<div class="field-description">When disabled, vision_load uses this model's native vision.</div>
</div>
<div class="field-control">
<label class="toggle">
<input type="checkbox" x-model="visionModel.override_main" />
<span class="toggler"></span>
</label>
</div>
</div>
</template>
<!-- Context window size (main and utility only) -->
<template x-if="modelType !== 'embedding'">
<template x-if="modelType === 'chat' || modelType === 'utility'">
<div class="field">
<div class="field-label">
<div class="field-title">Context window size</div>
@ -207,6 +223,18 @@
</div>
</template>
<template x-if="modelType === 'vision'">
<div class="field">
<div class="field-label">
<div class="field-title">Max embeds</div>
<div class="field-description">Maximum number of images sent to the Vision Model in one call. Set to 0 for unlimited.</div>
</div>
<div class="field-control">
<input type="number" min="0" x-model.number="model.max_embeds" />
</div>
</div>
</template>
<!-- Utility-specific: ctx_input slider -->
<template x-if="modelType === 'utility'">
<div class="field">

View file

@ -17,10 +17,22 @@
<div class="model-preset-rows" aria-live="polite">
<template x-for="model in modelRows" :key="model.title">
<div class="model-preset-row">
<x-icon class="model-preset-icon" :name="model.icon"></x-icon>
<span class="model-preset-role" x-text="model.title"></span>
<span class="model-preset-identity">
<div class="model-preset-row"
:class="{ 'model-preset-row-nested': model.title === 'Vision' }">
<template x-if="model.title !== 'Vision'">
<x-icon class="model-preset-icon" :name="model.icon"></x-icon>
</template>
<template x-if="model.title !== 'Vision'">
<span class="model-preset-role" x-text="model.title"></span>
</template>
<span class="model-preset-identity"
:class="{ 'model-preset-identity-vision': model.title === 'Vision' }">
<template x-if="model.title === 'Vision'">
<span>
<span class="model-preset-role">Vision override</span>
<span class="model-preset-separator">/</span>
</span>
</template>
<span class="model-preset-provider" x-text="model.provider"></span>
<span class="model-preset-separator">/</span>
<span x-text="model.name"></span>
@ -86,6 +98,16 @@
border-top: 1px solid var(--color-border);
}
.model-preset-row-nested {
border-top: 0 !important;
padding-top: var(--spacing-xs);
font-size: 0.76rem;
}
.model-preset-identity-vision {
grid-column: 3;
}
.model-preset-icon,
.model-preset-provider,
.model-preset-separator {

View file

@ -25,6 +25,7 @@
- Read the rendering path before changing placeholders or filenames.
- Prefer small prompt additions over broad rewrites when fixing a specific behavior.
- 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.
- 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.
- Update tests or snapshots when prompt budget, required sections, or generated system content changes.
## Verification

View file

@ -0,0 +1,48 @@
## multimodal vision tools
### vision_load
analyze images with the separate Vision Model and return a text result
args: `paths` list of absolute image paths or ephemeral image refs, `query` optional focused instruction, `raw` optional boolean
Input schema for tool_args:
```json
{
"type": "object",
"properties": {
"paths": {
"type": "array",
"items": {"type": "string"},
"description": "Absolute image paths or ephemeral image refs."
},
"query": {
"type": "string",
"description": "What the Vision Model should inspect, compare, locate, or read."
},
"raw": {
"type": "boolean",
"description": "Use the Main model's native vision instead, when Main supports vision."
}
},
"required": ["paths"],
"additionalProperties": false
}
```
rules:
- put all images needed for one comparison or visual task in the same `paths` array; they are sent in one Vision Model call
- use a focused `query`; if omitted, the Vision Model returns a concise general description
- the result is a text capsule; the Main model does not receive the raw images
- use `raw=true` only when Main supports vision and must inspect the pixels itself
- only bitmaps are supported
example:
```json
{
"thoughts": [
"I need to compare both screenshots before answering."
],
"headline": "Comparing screenshots",
"tool_name": "vision_load",
"tool_args": {
"paths": ["/path/to/before.png", "/path/to/after.png"],
"query": "Compare the error banners and describe what changed."
}
}
```

View file

@ -36,8 +36,13 @@ class _TestAgentContextType:
class _TestResponse(SimpleNamespace):
def __init__(self, message="", break_loop=False, **kwargs):
super().__init__(message=message, break_loop=break_loop, **kwargs)
def __init__(self, message="", break_loop=False, additional=None, **kwargs):
super().__init__(
message=message,
break_loop=break_loop,
additional=additional,
**kwargs,
)
class _TestTool:
@ -96,6 +101,9 @@ _model_config_stub = ModuleType("plugins._model_config.helpers.model_config")
_model_config_stub.get_presets = lambda: []
_model_config_stub.get_preset_by_name = lambda name: None
_model_config_stub.get_chat_model_config = lambda agent=None: {}
_model_config_stub.get_vision_model_config = lambda agent=None: {}
_model_config_stub.use_vision_sidecar = lambda agent=None: False
_model_config_stub.build_vision_model = lambda agent=None: None
sys.modules.setdefault("plugins._model_config.helpers.model_config", _model_config_stub)
@ -4297,11 +4305,9 @@ async def test_vision_load_materializes_ephemeral_browser_refs(monkeypatch, tmp_
monkeypatch.setattr(vision_load_module.chat_media.files, "get_abs_path", fake_get_abs_path)
monkeypatch.setattr(vision_load_module.chat_media.files, "normalize_a0_path", fake_normalize_a0_path)
monkeypatch.setattr(
vision_load_module.plugins,
"get_plugin_config",
lambda *args, **kwargs: {"chat_model": {"max_embeds": 10}},
)
monkeypatch.setattr(vision_load_module, "get_chat_model_config", lambda _agent: {"vision": True, "max_embeds": 10})
monkeypatch.setattr(vision_load_module, "get_vision_model_config", lambda _agent: {})
monkeypatch.setattr(vision_load_module, "use_vision_sidecar", lambda _agent: False)
tool_results = []
messages = []

View file

@ -149,7 +149,7 @@ def test_model_config_frontend_tracks_provider_api_key_edits():
assert "/plugins/_model_config/missing_api_key_status" not in model_gate_content
assert '@input="$store.modelConfig.setApiKeyValue(_prov, $el.value)"' in config_content
assert "apiKeyMode: 'none'" not in preset_modal_content
assert preset_modal_content.count("apiKeyMode: 'store'") == 3
assert preset_modal_content.count("apiKeyMode: 'store'") == 4
assert "$store.modelConfig.resetApiKeyDrafts();" in preset_modal_content
assert "await $store.modelConfig.refreshApiKeyStatus();" in preset_modal_content
assert "await store.persistAllDirtyApiKeys();" in store_content

View file

@ -1092,6 +1092,44 @@ def test_preset_application_inherits_optional_slots(monkeypatch, tmp_path):
assert config["embedding_model"] == base_config["embedding_model"]
def test_preset_vision_slot_is_optional_and_never_inherited(monkeypatch, tmp_path):
_prepare_a0_tree(monkeypatch, tmp_path)
from plugins._model_config.helpers import model_config
base_config = {
"chat_model": {"provider": "openrouter", "name": "main", "vision": False},
"vision_model": {
"provider": "openrouter",
"name": "default-vision",
"max_embeds": 2,
},
}
without_sidecar = model_config.build_config_from_preset(
{"name": "Text only", "chat": {"provider": "openrouter", "name": "text"}},
base_config,
)
with_sidecar = model_config.build_config_from_preset(
{
"name": "Visual",
"chat": {"provider": "openrouter", "name": "text"},
"vision": {
"provider": "anthropic",
"name": "visual",
"max_embeds": 5,
},
},
base_config,
)
assert without_sidecar["vision_model"] == {}
assert with_sidecar["vision_model"]["provider"] == "anthropic"
assert with_sidecar["vision_model"]["name"] == "visual"
assert with_sidecar["vision_model"]["max_embeds"] == 5
assert "default-vision" not in str(with_sidecar["vision_model"])
def test_legacy_utility_preset_defaults_preserve_tuning_but_clear_kwargs(
monkeypatch,
tmp_path,

View file

@ -85,6 +85,49 @@ def test_preset_editor_uses_standard_modal_footer_buttons() -> None:
assert "preset-editor-footer" not in preset_modal
def test_preset_editor_nests_one_conditional_vision_selector_in_main() -> None:
preset_modal = read("plugins", "_model_config", "webui", "main.html")
model_field = read("plugins", "_model_config", "webui", "model-field.html")
preset_overview = read("plugins", "_model_config", "webui", "preset-overview.html")
preset_store = read("plugins", "_model_config", "webui", "model-config-store.js")
main_start = preset_modal.index('<div class="section-title">Main Model</div>')
utility_start = preset_modal.index('<div class="section-title">Utility Model</div>')
selector_start = preset_modal.index('class="vision-sidecar-selector"')
supports_start = model_field.index('<div class="field-title">Supports Vision</div>')
override_start = model_field.index('<div class="field-title">Use separate Vision Model</div>')
context_start = model_field.index('<div class="field-title">Context window size</div>')
advanced_start = model_field.index('<!-- Advanced Settings (collapsed by default) -->')
assert '<div class="section-title">Vision Model</div>' not in preset_modal
assert preset_modal.count('class="vision-sidecar-selector"') == 1
assert main_start < selector_start < utility_start
assert '!selectedPreset.chat.vision || selectedPreset.vision.override_main' in preset_modal
assert "get visionModel() { return selectedPreset.vision; }" in preset_modal
assert "modelType: 'vision'" in preset_modal
assert preset_modal.count("apiKeyMode: 'store'") == 4
assert "margin: 0.75rem 0 0;" in preset_modal
assert "padding: 0.25rem 0 0;" in preset_modal
assert "border-left: 2px solid var(--color-border);" not in preset_modal
assert "Use separate Vision Model" in model_field
assert supports_start < override_start < context_start < advanced_start
assert "When disabled, vision_load uses this model's native vision." in model_field
assert "When enabled, vision_load uses the preset's Vision Model" not in model_field
assert 'x-model="visionModel.override_main"' in model_field
assert '<div class="advanced-section" x-data="{ advOpen: false }">' in model_field
assert "model-preset-row-nested" in preset_overview
assert "model.title === 'Vision'" in preset_overview
assert preset_overview.count("model.title !== 'Vision'") == 2
assert '<span class="model-preset-role">Vision override</span>' in preset_overview
assert "'model-preset-identity-vision': model.title === 'Vision'" in preset_overview
assert "grid-column: 3;" in preset_overview
assert "padding-top: var(--spacing-xs);" in preset_overview
assert "margin-left: 2rem;" not in preset_overview
assert "border-left: 1px solid var(--color-border);" not in preset_overview
assert "if (slotKey === 'vision') config[sectionKey] = {};" in preset_store
assert "['chat', 'vision', 'utility']" in preset_store
def test_plugin_settings_reset_is_explicit_and_does_not_capture_toast_early() -> None:
settings_modal = read("webui", "components", "plugins", "plugin-settings.html")
settings_store = read("webui", "components", "plugins", "plugin-settings-store.js")

View file

@ -160,6 +160,42 @@ def test_normalize_parallel_tool_calls_accepts_json_string_array() -> None:
assert calls[1].tool_args["message"] == "Research nuclear fusion news in Italian."
def test_parallel_keeps_mixed_tools_and_batched_vision_paths() -> None:
calls = parallel_tools.normalize_parallel_tool_calls(
[
{"tool_name": "search_a", "tool_args": {"query": "one"}},
{"tool_name": "search_b", "tool_args": {"query": "two"}},
{"tool_name": "browser_agent", "tool_args": {"message": "open page"}},
{
"tool_name": "vision_load",
"tool_args": {
"paths": ["/tmp/before.png", "/tmp/after.png"],
"query": "compare",
},
},
]
)
assert [call.tool_name for call in calls] == [
"search_a",
"search_b",
"browser_agent",
"vision_load",
]
assert calls[3].tool_args["paths"] == ["/tmp/before.png", "/tmp/after.png"]
def test_parallel_allows_multiple_independent_vision_calls() -> None:
calls = parallel_tools.normalize_parallel_tool_calls(
[
{"tool_name": "vision_load", "tool_args": {"paths": ["/tmp/a.png"]}},
{"tool_name": "vision_load", "tool_args": {"paths": ["/tmp/b.png"]}},
]
)
assert [call.tool_name for call in calls] == ["vision_load", "vision_load"]
def test_subordinate_prompts_share_reusable_tree_contract() -> None:
call_prompt = (PROJECT_ROOT / "prompts/agent.system.tool.call_sub.md").read_text(
encoding="utf-8"

View file

@ -546,6 +546,59 @@ async def test_vision_tool_follows_chat_config_not_profile_policy(
assert tool_policy.resolve_tool(agent, "vision_load").source == "runtime-config"
@pytest.mark.asyncio
async def test_vision_sidecar_prompt_replaces_native_vision_prompt(
monkeypatch, tmp_path: Path
) -> None:
_write_prompt(tmp_path, "agent.system.tools.md", "TOOLS\n{{tools}}")
_write_prompt(
tmp_path,
"agent.system.tools_vision.md",
"### vision_load\nnative pixels\nargs: `paths`",
)
_write_prompt(
tmp_path,
"agent.system.tools_vision_sidecar.md",
"### vision_load\nsidecar capsule\nargs: `paths`, `query`",
)
monkeypatch.setattr(tool_policy.subagents, "get_paths", _prompt_paths(tmp_path))
monkeypatch.setattr(
"plugins._model_config.helpers.model_config.get_chat_model_config",
lambda agent: {"vision": True},
)
monkeypatch.setattr(
"plugins._model_config.helpers.model_config.use_vision_sidecar",
lambda agent: True,
)
monkeypatch.setattr(responses_tools, "_mcp_tools", lambda agent: [])
agent = _Agent(tmp_path)
prompt = await _11_tools_prompt.build_prompt(agent)
schemas, _name_map = responses_tools.build_responses_function_tools(agent)
assert "sidecar capsule" in prompt
assert "native pixels" not in prompt
assert schemas[0]["name"] == "vision_load"
assert schemas[0]["description"] == "sidecar capsule"
def test_vision_sidecar_prompt_declares_multi_image_native_schema() -> None:
prompt = (
Path(__file__).resolve().parents[1]
/ "prompts"
/ "agent.system.tools_vision_sidecar.md"
).read_text(encoding="utf-8")
schema = responses_tools._schema_from_prompt(prompt)
assert schema["required"] == ["paths"]
assert schema["properties"]["paths"] == {
"type": "array",
"items": {"type": "string"},
"description": "Absolute image paths or ephemeral image refs.",
}
assert {"query", "raw"} <= schema["properties"].keys()
def test_mcp_prompt_and_native_schema_omit_blocked_tool(
monkeypatch, tmp_path: Path
) -> None:

View file

@ -1,3 +1,4 @@
import asyncio
import types
from types import SimpleNamespace
import sys
@ -13,8 +14,13 @@ from helpers import images
class _TestResponse(SimpleNamespace):
def __init__(self, message="", break_loop=False, **kwargs):
super().__init__(message=message, break_loop=break_loop, **kwargs)
def __init__(self, message="", break_loop=False, additional=None, **kwargs):
super().__init__(
message=message,
break_loop=break_loop,
additional=additional,
**kwargs,
)
class _TestTool:
@ -74,11 +80,9 @@ async def test_vision_load_materializes_local_image_to_chat_artifact(monkeypatch
monkeypatch.setattr(vision_load_module.chat_media.files, "get_abs_path", fake_get_abs_path)
monkeypatch.setattr(vision_load_module.chat_media.files, "normalize_a0_path", fake_normalize_a0_path)
monkeypatch.setattr(
vision_load_module.plugins,
"get_plugin_config",
lambda *args, **kwargs: {"chat_model": {"max_embeds": 10}},
)
monkeypatch.setattr(vision_load_module, "get_chat_model_config", lambda _agent: {"vision": True, "max_embeds": 10})
monkeypatch.setattr(vision_load_module, "get_vision_model_config", lambda _agent: {})
monkeypatch.setattr(vision_load_module, "use_vision_sidecar", lambda _agent: False)
async def direct_call(func, *args, **kwargs):
return func(*args, **kwargs)
@ -121,3 +125,215 @@ async def test_vision_load_materializes_local_image_to_chat_artifact(monkeypatch
stored_path = tmp_path / stored_ref.removeprefix("/a0/")
assert stored_path.read_bytes() == b"png-data"
assert updates[-1]["result"] == "1 images loaded, 0 skipped"
def test_vision_sidecar_route_matrix_prefers_main_native_vision(monkeypatch):
from plugins._model_config.helpers import model_config
cases = [
({"vision": False}, {}, False),
({"vision": True}, {"provider": "p", "name": "v"}, False),
({"vision": False}, {"provider": "p", "name": "v"}, True),
(
{"vision": True},
{"provider": "p", "name": "v", "override_main": True},
True,
),
]
for chat, vision, expected in cases:
monkeypatch.setattr(
model_config,
"get_effective_config",
lambda _agent=None, chat=chat, vision=vision: {
"chat_model": chat,
"vision_model": vision,
},
)
assert model_config.use_vision_sidecar() is expected
@pytest.mark.anyio
async def test_vision_sidecar_sends_multiple_images_once_and_keeps_history_text_only(
monkeypatch,
tmp_path,
):
_install_tool_stub(monkeypatch)
import tools.vision_load as vision_load_module
async def direct_call(func, *args, **kwargs):
return func(*args, **kwargs)
calls = []
class FakeVisionModel:
async def unified_call(self, **kwargs):
calls.append(kwargs)
return "The second screenshot fixes the red login error.", ""
monkeypatch.setattr(vision_load_module.runtime, "call_development_function", direct_call)
monkeypatch.setattr(vision_load_module, "build_vision_model", lambda _agent: FakeVisionModel())
monkeypatch.setattr(
vision_load_module,
"get_chat_model_config",
lambda _agent: {"vision": True, "max_embeds": 1},
)
monkeypatch.setattr(
vision_load_module,
"get_vision_model_config",
lambda _agent: {"provider": "test", "name": "vision", "max_embeds": 5},
)
monkeypatch.setattr(vision_load_module, "use_vision_sidecar", lambda _agent: True)
image_paths = [tmp_path / "before.png", tmp_path / "after.png"]
for path in image_paths:
path.write_bytes(b"png-data")
tool_results = []
raw_messages = []
agent = SimpleNamespace(
context=SimpleNamespace(id=""),
agent_name="Agent 0",
hist_add_tool_result=lambda *args, **kwargs: tool_results.append((args, kwargs)),
hist_add_message=lambda *args, **kwargs: raw_messages.append((args, kwargs)),
)
tool = vision_load_module.VisionLoad(
agent=agent,
name="vision_load",
method=None,
args={"paths": [str(path) for path in image_paths]},
message="",
loop_data=None,
)
tool.log = SimpleNamespace(id="vision-log", update=lambda **kwargs: None)
response = await tool.execute(
paths=[str(path) for path in image_paths],
query="Compare the login errors.",
)
response.additional = {"_responses_output_item": {"output": response.message}}
await tool.after_execution(response)
assert len(calls) == 1
content = calls[0]["messages"][1].content
assert content[0] == {"type": "text", "text": "Compare the login errors."}
assert [item["type"] for item in content].count("image_url") == 2
assert "fixes the red login error" in response.message
assert response.message != "dummy"
assert raw_messages == []
assert tool.loaded_paths == [str(path) for path in image_paths]
assert tool_results[0][1]["_responses_output_item"]["output"] == response.message
@pytest.mark.anyio
async def test_parallel_worker_consumes_parent_ephemeral_image(monkeypatch, tmp_path):
_install_tool_stub(monkeypatch)
import tools.vision_load as vision_load_module
def fake_get_abs_path(*parts):
return str(tmp_path.joinpath(*parts))
def fake_normalize_a0_path(path):
return "/a0/" + str(Path(path).relative_to(tmp_path)).replace("\\", "/")
monkeypatch.setattr(vision_load_module.chat_media.files, "get_abs_path", fake_get_abs_path)
monkeypatch.setattr(vision_load_module.chat_media.files, "normalize_a0_path", fake_normalize_a0_path)
monkeypatch.setattr(vision_load_module.VisionLoad, "_config_agent", lambda self: self.agent)
monkeypatch.setattr(
vision_load_module,
"get_chat_model_config",
lambda _agent: {"vision": True, "max_embeds": 10},
)
monkeypatch.setattr(vision_load_module, "get_vision_model_config", lambda _agent: {})
monkeypatch.setattr(vision_load_module, "use_vision_sidecar", lambda _agent: False)
parent_id = "parent-vision"
ref = vision_load_module.ephemeral_images.put_image_bytes(
context_id=parent_id,
mime="image/png",
payload=b"png-data",
name="shot.png",
)
context = SimpleNamespace(
id="parallel-worker",
get_data=lambda key: parent_id
if key == vision_load_module.PARALLEL_WORKER_PARENT_CONTEXT_KEY
else None,
)
agent = SimpleNamespace(context=context, agent_name="Agent 0")
tool = vision_load_module.VisionLoad(
agent=agent,
name="vision_load",
method=None,
args={"paths": [ref]},
message="",
loop_data=None,
)
await tool.execute(paths=[ref])
assert tool.loaded_paths == ["shot.png"]
assert vision_load_module.ephemeral_images.get_image(ref, context_id=parent_id) is None
stored_ref = tool.images_dict["shot.png"]
assert stored_ref.startswith("/a0/usr/chats/parent-vision/images/vision-load/shot-")
@pytest.mark.anyio
async def test_independent_vision_sidecar_calls_can_run_concurrently(monkeypatch, tmp_path):
_install_tool_stub(monkeypatch)
import tools.vision_load as vision_load_module
active = 0
max_active = 0
call_count = 0
class FakeVisionModel:
async def unified_call(self, **kwargs):
nonlocal active, max_active, call_count
active += 1
call_count += 1
max_active = max(max_active, active)
await asyncio.sleep(0.02)
active -= 1
return "done", ""
async def direct_call(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(vision_load_module.runtime, "call_development_function", direct_call)
monkeypatch.setattr(vision_load_module, "build_vision_model", lambda _agent: FakeVisionModel())
monkeypatch.setattr(vision_load_module, "get_chat_model_config", lambda _agent: {"vision": False})
monkeypatch.setattr(
vision_load_module,
"get_vision_model_config",
lambda _agent: {"provider": "test", "name": "vision", "max_embeds": 10},
)
monkeypatch.setattr(vision_load_module, "use_vision_sidecar", lambda _agent: True)
image_paths = [tmp_path / "one.png", tmp_path / "two.png"]
for path in image_paths:
path.write_bytes(b"png-data")
def make_tool(index):
agent = SimpleNamespace(context=SimpleNamespace(id=""), agent_name=f"Agent {index}")
return vision_load_module.VisionLoad(
agent=agent,
name="vision_load",
method=None,
args={"paths": [str(path) for path in image_paths]},
message="",
loop_data=None,
)
responses = await asyncio.gather(
*(
make_tool(index).execute(
paths=[str(path) for path in image_paths],
query=f"inspection {index}",
)
for index in range(4)
)
)
assert call_count == 4
assert max_active == 4
assert all("done" in response.message for response in responses)

View file

@ -1,89 +1,219 @@
from helpers.print_style import PrintStyle
from helpers.tool import Tool, Response
from helpers import runtime, files, plugins, ephemeral_images, images, chat_media
import asyncio
import json
from mimetypes import guess_type
from helpers import history
# image token estimation for context window
from langchain_core.messages import HumanMessage, SystemMessage
from helpers import chat_media, ephemeral_images, files, history, images, runtime
from helpers.parallel_tools import PARALLEL_WORKER_PARENT_CONTEXT_KEY, coerce_bool
from helpers.print_style import PrintStyle
from helpers.tool import Response, Tool
from plugins._model_config.helpers.model_config import (
build_vision_model,
get_chat_model_config,
get_vision_model_config,
use_vision_sidecar,
)
TOKENS_ESTIMATE = 1500
VISION_TIMEOUT_SECONDS = 300
VISION_SYSTEM_PROMPT = (
"You are a precise vision analyst. Answer only what was asked about the images. "
"Be concise and factual. Preserve exact visible text when asked to read it."
)
DEFAULT_VISION_QUERY = (
"Describe the images precisely, including the key objects, visible text, and layout."
)
class VisionLoad(Tool):
async def execute(self, paths: list[str] = [], **kwargs) -> Response:
self.images_dict = {}
async def execute(
self,
paths: list[str] | str | None = None,
query: str = "",
raw: bool = False,
**kwargs,
) -> Response:
self.images_dict: dict[str, str] = {}
self.loaded_paths: list[str] = []
self.skipped_paths: list[str] = []
max_embeds = self._get_max_embeds()
requested = [
(str(path or "").strip(), self._display_input_path(str(path or "").strip(), idx + 1))
for idx, path in enumerate(paths)
]
limited_paths = requested if max_embeds <= 0 else requested[-max_embeds:]
self.skipped_paths = (
[display for _, display in requested[:-max_embeds]]
if max_embeds > 0 and len(requested) > max_embeds
else []
self._config_owner = self._config_agent()
self._main_has_vision = bool(
get_chat_model_config(self._config_owner).get("vision", False)
)
self._delegated = use_vision_sidecar(self._config_owner) and not (
coerce_bool(raw, False) and self._main_has_vision
)
self._max_embeds = self._get_max_embeds()
for idx, (path, display_path) in enumerate(limited_paths):
normalized = self._normalize_paths(paths)
if isinstance(normalized, str):
self._history_result = normalized
return Response(message=normalized, break_loop=False)
requested = [
(path.strip(), self._display_input_path(path.strip(), index + 1))
for index, path in enumerate(normalized)
]
limited = requested if self._max_embeds <= 0 else requested[-self._max_embeds :]
if self._max_embeds > 0 and len(requested) > self._max_embeds:
self.skipped_paths = [display for _, display in requested[: -self._max_embeds]]
for index, (path, display_path) in enumerate(limited):
if not path:
continue
if ephemeral_images.is_ref(path):
image = ephemeral_images.consume_image(
path,
context_id=self._context_id(),
)
image = ephemeral_images.consume_image(path, context_id=self._context_id())
if image is None:
continue
display = image.display_name or display_path
display_path = image.display_name or display_path
stored_ref = self._store_ephemeral_image(image)
if stored_ref:
self.images_dict[display] = stored_ref
self.loaded_paths.append(display)
continue
if self._is_data_image_url(path):
stored_ref = self._store_data_url(path, preferred_name=f"vision-load-{idx + 1}.png")
if stored_ref:
self.images_dict[display_path] = stored_ref
self.loaded_paths.append(display_path)
continue
if not await runtime.call_development_function(files.exists, str(path)):
elif self._is_data_image_url(path):
stored_ref = self._store_data_url(
path, preferred_name=f"vision-load-{index + 1}.png"
)
elif await runtime.call_development_function(files.exists, path):
mime_type, _ = guess_type(path)
if not mime_type or not mime_type.startswith("image/"):
continue
try:
stored_ref = self._store_local_image(
path, preferred_name=files.basename(path)
)
except (FileNotFoundError, OSError, ValueError):
continue
else:
continue
if path not in self.images_dict:
mime_type, _ = guess_type(str(path))
if mime_type and mime_type.startswith("image/"):
try:
stored_ref = self._store_local_image(path, preferred_name=files.basename(path))
self.images_dict[display_path] = stored_ref
self.loaded_paths.append(display_path)
except (FileNotFoundError, OSError, ValueError):
continue
if stored_ref:
self.images_dict[display_path] = stored_ref
self.loaded_paths.append(display_path)
return Response(message="dummy", break_loop=False)
summary = self._summary()
if self._delegated and self.images_dict:
try:
capsule = await self._call_vision_model(
list(self.images_dict.values()), self._query(query, kwargs)
)
message = (
f"Vision Model analyzed {len(self.images_dict)} image(s)"
f"; {len(self.skipped_paths)} skipped.\n\n{capsule.strip()}"
)
self._history_result = message
return Response(message=message, break_loop=False)
except Exception as exc:
message = f"Vision Model error: {str(exc)[:1000]}"
self._history_result = f"{summary}\n\n{message}"
return Response(message=message, break_loop=False)
if self.images_dict and not self._main_has_vision:
summary += (
"\n\nImages were not injected because neither Main native vision nor "
"a usable Vision Model is active."
)
self._history_result = (
summary if self.images_dict or self.skipped_paths else "No images processed"
)
message = (
"No images processed"
if not self.images_dict and not self.skipped_paths
else f"{len(self.images_dict)} images loaded, {len(self.skipped_paths)} skipped"
)
return Response(message=message, break_loop=False)
async def after_execution(self, response: Response, **kwargs):
log_id = str(getattr(getattr(self, "log", None), "id", "") or "")
self.agent.hist_add_tool_result(
self.name,
self._history_result,
id=log_id,
**(response.additional or {}),
)
if self.images_dict and self._main_has_vision and not self._delegated:
content = [
{"type": "image_url", "image_url": {"url": image_path}}
for image_path in self.images_dict.values()
]
self.agent.hist_add_message(
False,
content=history.RawMessage(
raw_content=content,
preview="<Image attachments loaded by path>",
),
tokens=TOKENS_ESTIMATE * len(content),
)
PrintStyle(
font_color="#1B4F72", background_color="white", padding=True, bold=True
).print(f"{self.agent.agent_name}: Response from tool '{self.name}'")
PrintStyle(font_color="#85C1E9").print(response.message)
if getattr(self, "log", None):
self.log.update(result=response.message)
def _get_max_embeds(self) -> int:
cfg = plugins.get_plugin_config("_model_config", agent=self.agent) or {}
chat_cfg = cfg.get("chat_model", {})
max_embeds = chat_cfg.get("max_embeds", 10)
return int(max_embeds or 0)
cfg = (
get_vision_model_config(self._config_owner)
if self._delegated
else get_chat_model_config(self._config_owner)
)
try:
return int(cfg.get("max_embeds", 10) or 0)
except (TypeError, ValueError):
return 10
def _context_id(self) -> str:
return str(getattr(getattr(self.agent, "context", None), "id", "") or "").strip()
context = getattr(self.agent, "context", None)
if not context:
return ""
get_data = getattr(context, "get_data", None)
parent_id = get_data(PARALLEL_WORKER_PARENT_CONTEXT_KEY) if get_data else ""
return str(parent_id or getattr(context, "id", "") or "").strip()
def _config_agent(self):
context = getattr(self.agent, "context", None)
get_data = getattr(context, "get_data", None)
parent_id = get_data(PARALLEL_WORKER_PARENT_CONTEXT_KEY) if get_data else ""
if parent_id:
from agent import AgentContext
parent = AgentContext.get(str(parent_id))
if parent:
return parent.agent0
return self.agent
async def _call_vision_model(self, image_paths: list[str], query: str) -> str:
model = build_vision_model(self._config_owner)
content = [{"type": "text", "text": query or DEFAULT_VISION_QUERY}]
content.extend(
{"type": "image_url", "image_url": {"url": path}}
for path in image_paths
)
response, _ = await asyncio.wait_for(
model.unified_call(
messages=[
SystemMessage(content=VISION_SYSTEM_PROMPT),
HumanMessage(content=content),
],
explicit_caching=False,
max_tokens=2000,
),
timeout=VISION_TIMEOUT_SECONDS,
)
if not str(response or "").strip():
raise RuntimeError("Vision Model returned an empty response.")
return str(response)
def _store_ephemeral_image(self, image: ephemeral_images.EphemeralImage) -> str:
context_id = self._context_id()
if not context_id:
return image.data_url
source = chat_media.infer_source(image.ref, image.display_name)
category = chat_media.category_for_source(source)
saved = chat_media.save_image_base64(
context_id=context_id,
data=image.data,
mime_type=image.mime,
category=category,
category=chat_media.category_for_source(source),
source=source,
preferred_name=image.display_name,
)
@ -94,11 +224,10 @@ class VisionLoad(Tool):
if not context_id:
return data_url
source = chat_media.infer_source(data_url, preferred_name)
category = chat_media.category_for_source(source)
saved = chat_media.save_image_data_url(
context_id=context_id,
data_url=data_url,
category=category,
category=chat_media.category_for_source(source),
source=source,
preferred_name=preferred_name,
)
@ -115,6 +244,37 @@ class VisionLoad(Tool):
preferred_name=preferred_name,
)
def _summary(self) -> str:
loaded = "\n".join(self.loaded_paths) if self.loaded_paths else "none"
skipped = "\n".join(self.skipped_paths) if self.skipped_paths else "none"
return (
f"Loaded images ({len(self.loaded_paths)}):\n{loaded}\n\n"
f"Skipped images ({len(self.skipped_paths)}, max {self._max_embeds}):\n{skipped}"
)
@staticmethod
def _normalize_paths(paths: list[str] | str | None) -> list[str] | str:
if isinstance(paths, str):
try:
decoded = json.loads(paths)
except json.JSONDecodeError:
decoded = paths
paths = decoded if isinstance(decoded, list) else [paths]
if paths is None:
return []
if not isinstance(paths, (list, tuple)):
return "vision_load error: `paths` must be an array of image paths."
return [str(path or "").strip() for path in paths]
@staticmethod
def _query(query: str, kwargs: dict) -> str:
if str(query or "").strip():
return str(query).strip()
for key in ("prompt", "question", "instruction", "focus", "request"):
if str(kwargs.get(key) or "").strip():
return str(kwargs[key]).strip()
return DEFAULT_VISION_QUERY
@staticmethod
def _is_data_image_url(value: str) -> bool:
normalized = str(value or "").strip().lower()
@ -125,57 +285,5 @@ class VisionLoad(Tool):
if ephemeral_images.is_ref(value):
return ephemeral_images.display_ref(value)
if cls._is_data_image_url(value):
prefix = value.split(",", 1)[0]
return f"{prefix},<ephemeral-image-{index}>"
return f"{value.split(',', 1)[0]},<ephemeral-image-{index}>"
return value
async def after_execution(self, response: Response, **kwargs):
# build image data messages for LLMs, or error message
content = []
loaded_count = len(self.loaded_paths)
skipped_count = len(self.skipped_paths)
loaded_summary = "\n".join(self.loaded_paths) if self.loaded_paths else "none"
skipped_summary = "\n".join(self.skipped_paths) if self.skipped_paths else "none"
summary = (
f"Loaded images: {loaded_count}\n"
f"Loaded images:\n{loaded_summary}\n\n"
f"Skipped images: {skipped_count}\n"
f"Skipped images (max {self._get_max_embeds()} loaded at a time according to model configuration):\n{skipped_summary}"
)
if self.images_dict:
self.agent.hist_add_tool_result(self.name, summary, id=self.log.id if self.log else "")
for path, image_path in self.images_dict.items():
if image_path:
content.append(
{
"type": "image_url",
"image_url": {"url": image_path},
}
)
else:
content.append(
{
"type": "text",
"text": "Error processing image " + path,
}
)
# append as raw message content for LLMs with vision tokens estimate
msg = history.RawMessage(raw_content=content, preview="<Image attachments loaded by path>")
self.agent.hist_add_message(
False, content=msg, tokens=TOKENS_ESTIMATE * len(content)
)
else:
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 "")
# print and log short version
message = (
"No images processed"
if not self.images_dict and not self.skipped_paths
else f"{loaded_count} images loaded, {skipped_count} skipped"
)
PrintStyle(
font_color="#1B4F72", background_color="white", padding=True, bold=True
).print(f"{self.agent.agent_name}: Response from tool '{self.name}'")
PrintStyle(font_color="#85C1E9").print(message)
self.log.update(result=message)

View file

@ -3,7 +3,7 @@
## Purpose
- Own the `vision_load.py` agent tool.
- This module loads images into model-visible content for vision-capable models.
- This module routes images either into Main model-visible content or through the preset's optional Vision Model.
- Keep this file-level DOX profile synchronized with `vision_load.py` because this directory is intentionally flat.
## Ownership
@ -12,13 +12,19 @@
- `vision_load.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
- Classes:
- `VisionLoad` (`Tool`)
- `async execute(self, paths: list[str]=..., **kwargs) -> Response`
- `async execute(self, paths, query="", raw=False, **kwargs) -> Response`
- `async after_execution(self, response: Response, **kwargs)`
- Notable constants/configuration names: `TOKENS_ESTIMATE`.
## Runtime Contracts
- Tool modules must define `helpers.tool.Tool` subclasses and return `helpers.tool.Response` from `execute(...)`.
- One call may contain multiple paths. The delegated route sends every selected path in one Vision Model request and returns one textual capsule.
- Main native vision wins unless the effective preset selects the sidecar route; `raw=true` returns to Main native vision only when Main supports it.
- Delegation completes during `execute(...)` so native Responses function output contains the real capsule before `after_execution(...)` persists it.
- Delegated history contains the text capsule only. Native history contains the tool result followed by one raw message holding all loaded image blocks.
- 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.
- `max_embeds` comes from the model that actually receives the images.
- Update this file whenever tool arguments, output shape, `break_loop` behavior, intervention handling, prompt instructions, or side effects change.
- `VisionLoad` is a `Tool`.
- `VisionLoad` defines `execute(...)`.
@ -27,7 +33,7 @@
## Key Concepts
- 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`.
- 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`.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
## Work Guidance