Restore route-agnostic vision tool contract

Reuse the stock public vision_load prompt and remove the sidecar-specific schema and query argument. Keep delegated analysis instructions in a private framework prompt, preserve the parent request in parallel workers, and retain one-call multi-image batching.

Alessandro committed Aug 25, 2026 at 21:45 UTC 540ef8d8154315789ecf02921540ec5b321ab422
10 files changed +66 -61
helpers/parallel_tools.py
+1
@@ -485,6 +485,7 @@ async def _run_direct_tool_job(parent_context_id: str, job: ParallelJob) -> str:
485 _copy_project(parent_context, worker_context)
486
487 worker_agent = worker_context.agent0
488 + worker_agent.last_user_message = parent_context.agent0.last_user_message
489 worker_agent.loop_data = LoopData()
490 return await execute_tool_call(
491 worker_agent,
helpers/parallel_tools.py.dox.md
+1 -1
@@ -32,7 +32,7 @@
32 - Subordinate child chats are tagged with job metadata, remain outside the scheduler task list, and may use normal child-chat tools including `parallel`.
33 - Nested parallel jobs started by a parallel subordinate are registered as child `DeferredTask` instances so stopping the ancestor also stops its descendants.
34 - Direct tool jobs run in isolated background contexts and are blocked from recursively invoking `parallel`.
35 -- Direct tool jobs inherit the parent's active per-chat model override.
35 +- Direct tool jobs inherit the parent's active per-chat model override and current user message.
36 - Direct tool background context cleanup removes both the in-memory context and any transient chat folder left on disk.
37 - 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.
38 - 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.
prompts/AGENTS.md
+1 -1
@@ -25,7 +25,7 @@
25 - Read the rendering path before changing placeholders or filenames.
26 - Prefer small prompt additions over broad rewrites when fixing a specific behavior.
27 - Keep document/OCR routing explicit: image files, screenshots, scans, charts, photos, and diagrams should prefer vision tools when available, while `document_query` is for documents, large text-heavy files, and fallback OCR.
28 -- Keep the single `vision_load` prompt accurate for both native and separate Vision Model routing: related images belong in one call, and a Vision Model result is text-only.
28 +- Keep the single `vision_load` tool prompt route-agnostic and preserve one-call loading for related images; internal image-analysis instructions belong in framework prompts, not Python strings.
29 - Update tests or snapshots when prompt budget, required sections, or generated system content changes.
30
31 ## Verification
prompts/agent.system.tools_vision.md
+6 -23
@@ -1,40 +1,23 @@
1 ## multimodal vision tools
2
3 ### vision_load
4 -load or analyze images for visual reasoning
5 -args: `paths` list of absolute image paths or ephemeral image refs, `query` optional focused instruction
6 -Input schema for tool_args:
7 -```json
8 -{
9 - "type": "object",
10 - "properties": {
11 - "paths": {
12 - "type": "array",
13 - "items": {"type": "string"}
14 - },
15 - "query": {"type": "string"}
16 - },
17 - "required": ["paths"],
18 - "additionalProperties": false
19 -}
20 -```
4 +load images into the model for visual reasoning
5 +args: `paths` list of absolute image paths or tool-returned ephemeral image refs
6 rules:
22 -- put all images needed for one comparison or visual task in the same `paths` array
7 +- load all relevant images in one call when comparing screenshots or pages
8 - use when the task depends on screenshots, diagrams, scanned documents, charts, or photos
24 -- use a focused `query` when asking the configured Vision Model to inspect, compare, locate, or read something
9 - only bitmaps are supported; convert other formats first if needed
26 -- the tool result reports loaded and skipped image counts
10 +- the tool result includes loaded/skipped image totals and the corresponding path lists
11 example:
12 ```json
13 {
14 "thoughts": [
15 "I need to inspect the screenshot before answering."
16 ],
33 - "headline": "Comparing screenshots",
17 + "headline": "Loading screenshot for visual analysis",
18 "tool_name": "vision_load",
19 "tool_args": {
36 - "paths": ["/path/to/before.png", "/path/to/after.png"],
37 - "query": "Compare the error banners and describe what changed."
20 + "paths": ["/path/to/screenshot.png"]
21 }
22 }
23 ```
prompts/fw.vision_load.md new
+4
@@ -0,0 +1,4 @@
1 +Analyze the attached image(s) for the current request. Return only relevant visible details, text, and layout.
2 +
3 +Current request:
4 +{{request}}
tests/test_parallel_tool.py
+4
@@ -360,10 +360,13 @@ async def test_direct_parallel_worker_inherits_chat_model_override(monkeypatch)
360 )
361 override = {"preset_name": "Text only"}
362 parent.set_data("chat_model_override", override)
363 + current_user_message = object()
364 + parent.agent0.last_user_message = current_user_message
365 observed = {}
366
367 async def fake_execute_tool_call(agent, *_args, **_kwargs):
368 observed["override"] = agent.context.get_data("chat_model_override")
369 + observed["last_user_message"] = agent.last_user_message
370 return "done"
371
372 async def remove_context(context_id):
@@ -383,6 +386,7 @@ async def test_direct_parallel_worker_inherits_chat_model_override(monkeypatch)
386 try:
387 assert await parallel_tools._run_direct_tool_job(parent_id, job) == "done"
388 assert observed["override"] == override
389 + assert observed["last_user_message"] is current_user_message
390 finally:
391 AgentContext.remove(parent_id)
392
tests/test_tool_policy.py
+9 -8
@@ -580,7 +580,7 @@ async def test_active_vision_model_uses_canonical_vision_prompt(
580 assert schemas[0]["description"] == "canonical vision"
581
582
583 -def test_vision_prompt_declares_multi_image_native_schema() -> None:
583 +def test_vision_prompt_stays_route_agnostic_and_batches_paths() -> None:
584 source = (
585 Path(__file__).resolve().parents[1]
586 / "prompts"
@@ -588,14 +588,15 @@ def test_vision_prompt_declares_multi_image_native_schema() -> None:
588 ).read_text(encoding="utf-8")
589 schema = responses_tools._schema_from_prompt(source)
590
591 - assert schema["required"] == ["paths"]
592 - assert schema["properties"]["paths"] == {
593 - "type": "array",
594 - "items": {"type": "string"},
591 + assert schema == {
592 + "type": "object",
593 + "properties": {},
594 + "additionalProperties": True,
595 }
596 - assert "query" in schema["properties"]
597 - assert "raw" not in schema["properties"]
598 - assert "Vision Model" in source
596 + assert "load all relevant images in one call" in source
597 + assert "Input schema for tool_args" not in source
598 + assert "Vision Model" not in source
599 + assert "query" not in source
600
601
602 def test_mcp_prompt_and_native_schema_omit_blocked_tool(
tests/test_vision_load_image_refs.py
+20 -12
@@ -124,9 +124,9 @@ async def test_vision_load_materializes_local_image_to_chat_artifact(monkeypatch
124 tool.log = SimpleNamespace(id="vision-log", update=lambda **kwargs: updates.append(kwargs))
125
126 invalid = await tool.execute(paths=None)
127 - assert invalid.message == "vision_load error: `paths` must be an array."
127 + assert invalid.message == "vision_load error: `paths` must be a string or an array."
128
129 - response = await tool.execute(paths=[str(image_path)])
129 + response = await tool.execute(paths=str(image_path))
130 image_path.unlink()
131 await tool.after_execution(response)
132
@@ -205,6 +205,10 @@ async def test_vision_model_sends_multiple_images_once_and_keeps_history_text_on
205 agent = SimpleNamespace(
206 context=SimpleNamespace(id=""),
207 agent_name="Agent 0",
208 + last_user_message=SimpleNamespace(
209 + output_text=lambda: "Compare the login errors."
210 + ),
211 + read_prompt=lambda _name, request: f"Analyze: {request}",
212 hist_add_tool_result=lambda *args, **kwargs: tool_results.append((args, kwargs)),
213 hist_add_message=lambda *args, **kwargs: raw_messages.append((args, kwargs)),
214 )
@@ -218,16 +222,16 @@ async def test_vision_model_sends_multiple_images_once_and_keeps_history_text_on
222 )
223 tool.log = SimpleNamespace(id="vision-log", update=lambda **kwargs: None)
224
221 - response = await tool.execute(
222 - paths=[str(path) for path in image_paths],
223 - query="Compare the login errors.",
224 - )
225 + response = await tool.execute(paths=[str(path) for path in image_paths])
226 response.additional = {"_responses_output_item": {"output": response.message}}
227 await tool.after_execution(response)
228
229 assert len(calls) == 1
230 content = calls[0]["messages"][0].content
230 - assert content[0] == {"type": "text", "text": "Compare the login errors."}
231 + assert content[0] == {
232 + "type": "text",
233 + "text": "Analyze: Compare the login errors.",
234 + }
235 assert [item["type"] for item in content].count("image_url") == 2
236 assert "max_tokens" not in calls[0]
237 assert "explicit_caching" not in calls[0]
@@ -326,7 +330,14 @@ async def test_independent_vision_model_calls_can_run_concurrently(monkeypatch,
330 path.write_bytes(b"png-data")
331
332 def make_tool(index):
329 - agent = SimpleNamespace(context=SimpleNamespace(id=""), agent_name=f"Agent {index}")
333 + agent = SimpleNamespace(
334 + context=SimpleNamespace(id=""),
335 + agent_name=f"Agent {index}",
336 + last_user_message=SimpleNamespace(
337 + output_text=lambda: f"inspection {index}"
338 + ),
339 + read_prompt=lambda _name, request: request,
340 + )
341 return vision_load_module.VisionLoad(
342 agent=agent,
343 name="vision_load",
@@ -338,10 +349,7 @@ async def test_independent_vision_model_calls_can_run_concurrently(monkeypatch,
349
350 responses = await asyncio.gather(
351 *(
341 - make_tool(index).execute(
342 - paths=[str(path) for path in image_paths],
343 - query=f"inspection {index}",
344 - )
352 + make_tool(index).execute(paths=[str(path) for path in image_paths])
353 for index in range(4)
354 )
355 )
tools/vision_load.py
+17 -14
@@ -13,23 +13,20 @@ from plugins._model_config.helpers.model_config import (
13
14 # image token estimation for context window
15 TOKENS_ESTIMATE = 1500
16 -DEFAULT_VISION_QUERY = (
17 - "Describe the images precisely, including the key objects, visible text, and layout."
18 -)
16
17
18 class VisionLoad(Tool):
22 - async def execute(
23 - self, paths: list[str] = [], query: str = "", **kwargs
24 - ) -> Response:
19 + async def execute(self, paths: list[str] | str = [], **kwargs) -> Response:
20
21 self.images_dict = {}
22 self.loaded_paths: list[str] = []
23 self.skipped_paths: list[str] = []
24 self.vision_config = get_vision_model_config(self.agent)
25 + if isinstance(paths, str):
26 + paths = [paths]
27 if not isinstance(paths, list):
28 return Response(
32 - message="vision_load error: `paths` must be an array.",
29 + message="vision_load error: `paths` must be a string or an array.",
30 break_loop=False,
31 )
32
@@ -83,15 +80,13 @@ class VisionLoad(Tool):
80 message = self._summary() if self.images_dict or self.skipped_paths else "No images processed"
81 if self.vision_config and self.images_dict:
82 try:
86 - capsule = await self._call_vision_model(
87 - list(self.images_dict.values()), query
88 - )
83 + capsule = await self._call_vision_model(list(self.images_dict.values()))
84 message = (
90 - f"Vision Model analyzed {len(self.images_dict)} image(s)"
85 + f"Analyzed {len(self.images_dict)} image(s)"
86 f"; {len(self.skipped_paths)} skipped.\n\n{capsule.strip()}"
87 )
88 except Exception as exc:
94 - message = f"Vision Model error: {str(exc)[:1000]}"
89 + message = f"Image analysis error: {str(exc)[:1000]}"
90 return Response(message=message, break_loop=False)
91
92 def _get_max_embeds(self) -> int:
@@ -104,8 +99,16 @@ class VisionLoad(Tool):
99 parent_id = get_data(PARALLEL_WORKER_PARENT_CONTEXT_KEY) if get_data else ""
100 return str(parent_id or getattr(context, "id", "") or "").strip()
101
107 - async def _call_vision_model(self, image_paths: list[str], query: str) -> str:
108 - content = [{"type": "text", "text": str(query or "").strip() or DEFAULT_VISION_QUERY}]
102 + async def _call_vision_model(self, image_paths: list[str]) -> str:
103 + user_message = getattr(self.agent, "last_user_message", None)
104 + output_text = getattr(user_message, "output_text", None)
105 + request = str(output_text() if callable(output_text) else "").strip()
106 + content = [
107 + {
108 + "type": "text",
109 + "text": self.agent.read_prompt("fw.vision_load.md", request=request),
110 + }
111 + ]
112 content.extend(
113 {"type": "image_url", "image_url": {"url": path}}
114 for path in image_paths
tools/vision_load.py.dox.md
+3 -2
@@ -12,15 +12,16 @@
12 - `vision_load.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
13 - Classes:
14 - `VisionLoad` (`Tool`)
15 - - `async execute(self, paths, query="", **kwargs) -> Response`
15 + - `async execute(self, paths, **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 Vision Model route sends every selected path in one request and returns one textual capsule.
22 +- One call may contain multiple paths; a bare string is treated as one path. The Vision Model route sends every selected path in one request and returns one textual capsule.
23 - Model configuration exposes a Vision Model only when the effective preset selects that route; otherwise this tool follows Main's native vision path.
24 +- The public tool contract is route-agnostic. A Vision Model receives the current user request through `fw.vision_load.md`; direct parallel workers inherit that request from their parent.
25 - Delegation completes during `execute(...)` so native Responses function output contains the real capsule before `after_execution(...)` persists it.
26 - Delegated history contains the text capsule only. Native history contains the tool result followed by one raw message holding all loaded image blocks.
27 - Direct parallel workers inherit the parent's model override generically. This tool uses their recorded parent context only to resolve ephemeral refs and durable chat media.