Skip to content
7 changes: 5 additions & 2 deletions extensions/python/system_prompt/_11_tools_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,13 @@ 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,
get_vision_model_config,
)

chat_cfg = get_chat_model_config(agent)
if chat_cfg.get("vision", False):
if get_vision_model_config(agent) or chat_cfg.get("vision", False):
prompt += "\n\n" + agent.read_prompt("agent.system.tools_vision.md")

return prompt
5 changes: 5 additions & 0 deletions helpers/parallel_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -477,10 +477,15 @@ async def _run_direct_tool_job(parent_context_id: str, job: ParallelJob) -> str:
worker_context.set_data(PARALLEL_WORKER_PARENT_CONTEXT_KEY, parent_context_id)
worker_context.set_data(PARALLEL_WORKER_JOB_KEY, job.id)
worker_context.set_data(PARALLEL_WORKER_KIND_KEY, job.kind)
worker_context.set_data(
"chat_model_override",
parent_context.get_data("chat_model_override"),
)
job.worker_context_id = worker_context.id
_copy_project(parent_context, worker_context)

worker_agent = worker_context.agent0
worker_agent.last_user_message = parent_context.agent0.last_user_message
worker_agent.loop_data = LoopData()
return await execute_tool_call(
worker_agent,
Expand Down
1 change: 1 addition & 0 deletions helpers/parallel_tools.py.dox.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
- Subordinate child chats are tagged with job metadata, remain outside the scheduler task list, and may use normal child-chat tools including `parallel`.
- Nested parallel jobs started by a parallel subordinate are registered as child `DeferredTask` instances so stopping the ancestor also stops its descendants.
- Direct tool jobs run in isolated background contexts and are blocked from recursively invoking `parallel`.
- Direct tool jobs inherit the parent's active per-chat model override and current user message.
- Direct tool background context cleanup removes both the in-memory context and any transient chat folder left on disk.
- Parent-visible child log items are created for each wrapped call so the WebUI can inspect concurrent children separately while the wrapper result remains model-history-only.
- Child tool logs mirror normal tool-call visible args; job ids remain available through wrapper results and prompt extras rather than visible process-step args.
Expand Down
10 changes: 8 additions & 2 deletions helpers/responses_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,15 @@ 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,
get_vision_model_config,
)

if not get_chat_model_config(agent).get("vision", False):
if not (
get_vision_model_config(agent)
or get_chat_model_config(agent).get("vision", False)
):
return ""
return agent.read_prompt("agent.system.tools_vision.md")
except Exception:
Expand Down
6 changes: 3 additions & 3 deletions helpers/responses_tools.py.dox.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 canonical vision 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.
Expand All @@ -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
Expand Down
9 changes: 7 additions & 2 deletions plugins/_model_config/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,20 @@

## 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.
- Keep provider metadata and API-key checks safe around secrets.
- 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 the separate Vision Model 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
50 changes: 48 additions & 2 deletions plugins/_model_config/helpers/model_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,21 @@
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,
Expand Down Expand Up @@ -339,7 +349,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)
Expand Down Expand Up @@ -659,10 +674,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,
)
Expand Down Expand Up @@ -738,6 +755,22 @@ 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 active Vision Model config after applying Main-first routing."""
cfg = get_effective_config(agent)
vision_cfg = cfg.get("vision_model", {})
if not all(
str(vision_cfg.get(key) or "").strip() for key in ("provider", "name")
):
return {}
chat_cfg = cfg.get("chat_model", {})
return (
vision_cfg
if not chat_cfg.get("vision") or vision_cfg.get("override_main")
else {}
)


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", {})
Expand Down Expand Up @@ -835,6 +868,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)
Expand Down Expand Up @@ -881,6 +924,9 @@ def get_missing_api_key_providers(agent=None) -> list[dict]:
("Utility Model", cfg.get("utility_model", {})),
("Embedding Model", get_embedding_model_config(agent)),
]
vision_cfg = get_vision_model_config(agent)
if vision_cfg:
checks.insert(1, ("Vision Model", vision_cfg))

for label, model_cfg in checks:
provider = model_cfg.get("provider", "")
Expand Down
21 changes: 19 additions & 2 deletions plugins/_model_config/webui/main.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -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;
Expand Down
Loading