Skip to content

Commit 03c1dfd

Browse files
author
zhansheng.lzs
committed
feat(acli): add built-in view_image tool so the agent can analyze images
The only multimodal entry point was user-input @path expansion, so after scrape_web_screenshot/camera_capture the model could not see the saved image and sometimes echoed the user-facing @path hint verbatim, ending the turn prematurely. view_image routes a local image to a vision-capable model on the active provider (active model if vision- capable, else the provider's declared vision model, else a per- provider fallback), and screenshot/camera hints now point at it.
1 parent ea3aaf7 commit 03c1dfd

7 files changed

Lines changed: 271 additions & 4 deletions

File tree

‎dashscope/acli/cli/repl.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ async def _run_loop(config: Config):
195195
get_mcp_clients_fn=lambda: _mcp_clients,
196196
)
197197

198+
from dashscope.acli.tools.vision import register_vision_tools
199+
200+
register_vision_tools(config, get_provider_fn=get_provider)
201+
198202
from dashscope.acli.tools.evolution import register_evolution_tools
199203

200204
register_evolution_tools(get_agent=lambda: agent)

‎dashscope/acli/cli/runners.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,10 @@ def _run_tui_mode(config: Config):
358358
get_mcp_clients_fn=lambda: _mcp_clients,
359359
)
360360

361+
from dashscope.acli.tools.vision import register_vision_tools
362+
363+
register_vision_tools(config, get_provider_fn=get_provider)
364+
361365
from dashscope.acli.tools.evolution import register_evolution_tools
362366

363367
register_evolution_tools(get_agent=lambda: agent)

‎dashscope/acli/tools/browser.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,7 @@ async def scrape_web_screenshot(
488488
return f"Error: screenshot failed — {e}"
489489

490490
return (
491-
f"Screenshot saved to {output_path}. Reference it with @path "
492-
f"to send the image to a vision model for analysis."
491+
f"Screenshot saved to {output_path}. Call the view_image tool "
492+
f"with this path to analyze it (the user can also attach it "
493+
f"via @path)."
493494
)

‎dashscope/acli/tools/camera.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
name="camera_capture",
1212
description=(
1313
"Take a photo with the webcam and save it to the given path. "
14-
"The user can then reference it via @path to send it to a "
15-
"vision model for analysis."
14+
"The photo can then be analyzed with the view_image tool (the "
15+
"user can also attach it via @path)."
1616
),
1717
permission=PermissionLevel.CONFIRM,
1818
)

‎dashscope/acli/tools/registry.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,7 @@ def to_schema_list(self, user_input: str = "") -> list[dict]:
368368
"switch_provider",
369369
"capability_enable",
370370
"capability_disable",
371+
"view_image",
371372
],
372373
)
373374
include_tools.update(["mcp_connect", "mcp_disconnect"])

‎dashscope/acli/tools/vision.py‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# -*- coding: utf-8 -*-
2+
"""Built-in vision tool — let the agent analyze local images itself.
3+
4+
Previously the only multimodal entry point was user-input ``@path``
5+
expansion, so after ``scrape_web_screenshot`` / ``camera_capture`` the
6+
model could not see the saved image and sometimes echoed the
7+
user-facing ``@path`` hint verbatim, ending the turn prematurely.
8+
``view_image`` closes that gap by routing the image to a
9+
vision-capable model on the active provider.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from typing import Callable
15+
16+
from dashscope.acli.config import Config, is_vision_model
17+
from dashscope.acli.tools.registry import (
18+
PermissionLevel,
19+
ToolDefinition,
20+
registry,
21+
)
22+
23+
_FALLBACK_VISION_MODELS = {
24+
"tongyi": "qwen-vl-max",
25+
"openai": "gpt-4o",
26+
"anthropic": "claude-sonnet-4-20250514",
27+
}
28+
29+
30+
def pick_vision_model(config: Config) -> str:
31+
"""Choose a vision-capable model on the active provider.
32+
33+
Preference: the active model when it accepts images, then the first
34+
vision model declared on the provider's extension block, then a
35+
per-provider built-in fallback.
36+
"""
37+
if is_vision_model(config.model):
38+
return config.model
39+
40+
from dashscope.acli.extensions import find_provider
41+
42+
ext = find_provider(config.provider)
43+
if ext and ext.vision_models:
44+
return ext.vision_models[0]
45+
return _FALLBACK_VISION_MODELS.get(config.provider, "qwen-vl-max")
46+
47+
48+
def register_vision_tools(config: Config, *, get_provider_fn: Callable):
49+
"""Register ``view_image``; called once after agent construction."""
50+
51+
async def view_image(
52+
image_path: str,
53+
question: str = (
54+
"Describe this image in detail. If it contains text or "
55+
"tables, transcribe them verbatim."
56+
),
57+
) -> str:
58+
from dashscope.acli.utils.images import image_to_data_url
59+
from dashscope.acli.utils.paths import validate_path
60+
61+
try:
62+
safe_path = validate_path(image_path)
63+
data_url = image_to_data_url(safe_path)
64+
except (ValueError, OSError) as e:
65+
return f"Error: failed to read image: {e}"
66+
67+
model = pick_vision_model(config)
68+
provider = get_provider_fn(
69+
config.provider,
70+
model,
71+
config.api_key,
72+
base_url=config.base_url or None,
73+
)
74+
messages = [
75+
{
76+
"role": "user",
77+
"content": [
78+
{"type": "text", "text": question},
79+
{"type": "image_url", "image_url": {"url": data_url}},
80+
],
81+
},
82+
]
83+
try:
84+
response = await provider.chat(messages)
85+
except Exception as e: # pylint: disable=broad-except
86+
return f"Error: vision call failed: {e}"
87+
return response.content or "(vision model returned no content)"
88+
89+
registry.register(
90+
ToolDefinition(
91+
name="view_image",
92+
description=(
93+
"Analyze a local image file (screenshot, photo, chart) "
94+
"with a vision model: OCR/transcribe text, describe "
95+
"layout or content. Use after scrape_web_screenshot or "
96+
"camera_capture, or whenever you need to read an image."
97+
),
98+
permission=PermissionLevel.AUTO,
99+
func=view_image,
100+
parameters={
101+
"type": "object",
102+
"properties": {
103+
"image_path": {
104+
"type": "string",
105+
"description": (
106+
"Path to the image file (relative to the "
107+
"workspace or absolute)"
108+
),
109+
},
110+
"question": {
111+
"type": "string",
112+
"description": (
113+
"What to extract from the image; defaults "
114+
"to a full description + verbatim OCR"
115+
),
116+
},
117+
},
118+
"required": ["image_path"],
119+
},
120+
),
121+
)
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright (c) Alibaba, Inc. and its affiliates.
3+
# pylint: disable=protected-access
4+
5+
from types import SimpleNamespace
6+
7+
import pytest
8+
9+
from dashscope.acli.tools import vision
10+
from dashscope.acli.tools.registry import registry
11+
12+
13+
class FakeProvider:
14+
def __init__(self):
15+
self.messages = None
16+
17+
async def chat(self, messages):
18+
self.messages = messages
19+
return SimpleNamespace(content="transcribed table text")
20+
21+
22+
def _config():
23+
return SimpleNamespace(
24+
provider="tongyi",
25+
model="qwen3.8-max",
26+
api_key="sk-test",
27+
base_url="",
28+
)
29+
30+
31+
def _register(fake_provider):
32+
captured = {}
33+
34+
def get_provider_fn(provider, model, api_key, base_url=None):
35+
captured.update(
36+
provider=provider,
37+
model=model,
38+
api_key=api_key,
39+
base_url=base_url,
40+
)
41+
return fake_provider
42+
43+
vision.register_vision_tools(
44+
_config(),
45+
get_provider_fn=get_provider_fn,
46+
)
47+
return captured
48+
49+
50+
class TestViewImage:
51+
async def test_sends_image_to_vision_model(self, tmp_path, monkeypatch):
52+
monkeypatch.setattr(
53+
vision,
54+
"is_vision_model",
55+
lambda model: False,
56+
)
57+
monkeypatch.setattr(
58+
"dashscope.acli.extensions.find_provider",
59+
lambda name: SimpleNamespace(vision_models=["qwen-vl-plus"]),
60+
)
61+
fake_provider = FakeProvider()
62+
captured = _register(fake_provider)
63+
64+
image = tmp_path / "shot.png"
65+
image.write_bytes(b"\x89PNG fake bytes")
66+
67+
result = await registry.get("view_image").func(
68+
image_path=str(image),
69+
)
70+
71+
assert result == "transcribed table text"
72+
assert captured["model"] == "qwen-vl-plus"
73+
content = fake_provider.messages[0]["content"]
74+
assert content[0]["type"] == "text"
75+
assert content[1]["image_url"]["url"].startswith(
76+
"data:image/png;base64,",
77+
)
78+
79+
async def test_missing_file_returns_error(self):
80+
_register(FakeProvider())
81+
result = await registry.get("view_image").func(
82+
image_path=str("no-such-image.png"),
83+
)
84+
assert result.startswith("Error: failed to read image")
85+
86+
async def test_vision_call_failure_returns_error(self, tmp_path):
87+
class BrokenProvider:
88+
async def chat(self, messages):
89+
raise RuntimeError("boom")
90+
91+
_register(BrokenProvider())
92+
image = tmp_path / "shot.png"
93+
image.write_bytes(b"\x89PNG fake bytes")
94+
result = await registry.get("view_image").func(
95+
image_path=str(image),
96+
)
97+
assert result == "Error: vision call failed: boom"
98+
99+
100+
class TestPickVisionModel:
101+
def test_active_model_when_vision_capable(self, monkeypatch):
102+
monkeypatch.setattr(vision, "is_vision_model", lambda m: True)
103+
assert vision.pick_vision_model(_config()) == "qwen3.8-max"
104+
105+
def test_extension_vision_models(self, monkeypatch):
106+
monkeypatch.setattr(vision, "is_vision_model", lambda m: False)
107+
monkeypatch.setattr(
108+
"dashscope.acli.extensions.find_provider",
109+
lambda name: SimpleNamespace(vision_models=["qwen-vl-max"]),
110+
)
111+
assert vision.pick_vision_model(_config()) == "qwen-vl-max"
112+
113+
def test_fallback_per_provider(self, monkeypatch):
114+
monkeypatch.setattr(vision, "is_vision_model", lambda m: False)
115+
monkeypatch.setattr(
116+
"dashscope.acli.extensions.find_provider",
117+
lambda name: None,
118+
)
119+
assert vision.pick_vision_model(_config()) == "qwen-vl-max"
120+
cfg = _config()
121+
cfg.provider = "openai"
122+
assert vision.pick_vision_model(cfg) == "gpt-4o"
123+
124+
125+
class TestSchemaExposure:
126+
def test_view_image_always_offered(self):
127+
vision.register_vision_tools(
128+
_config(),
129+
get_provider_fn=lambda *a, **k: FakeProvider(),
130+
)
131+
names = [t["name"] for t in registry.to_schema_list()]
132+
assert "view_image" in names
133+
134+
135+
if __name__ == "__main__":
136+
pytest.main([__file__, "-v"])

0 commit comments

Comments
 (0)