Skip to content

Commit daa2efd

Browse files
a61995987Soulterzouyonghe
authored
fix:修正子agent无法正确接收本地图片(参考图)路径的问题 (#5579)
* fix: 修复5081号PR在子代理执行后台任务时,未正确使用系统配置的流式/非流请求的问题(#5081) * feat:为子代理增加远程图片URL参数支持 * fix: update description for image_urls parameter in HandoffTool to clarify usage in multimodal tasks * ruff format * fix:修正子agent无法正确接收本地图片(参考图)路径的问题 * fix:增强image_urls接收的鲁棒性 * fix:ruff检查 * fix: harden handoff image_urls preprocessing * fix: refactor handoff image_urls preprocessing flow * refactor: simplify handoff image_urls data flow * fix: filter non-string handoff image_urls entries * refactor: streamline handoff image url collection * refactor: share handoff image ref validation utilities * refactor: simplify handoff image url processing * refactor: honor prepared handoff image urls contract --------- Co-authored-by: Soulter <905617992@qq.com> Co-authored-by: 邹永赫 <1259085392@qq.com>
1 parent d561046 commit daa2efd

File tree

4 files changed

+495
-13
lines changed

4 files changed

+495
-13
lines changed

astrbot/core/astr_agent_tool_exec.py

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import traceback
55
import typing as T
66
import uuid
7+
from collections.abc import Sequence
8+
from collections.abc import Set as AbstractSet
79

810
import mcp
911

@@ -26,6 +28,7 @@
2628
SEND_MESSAGE_TO_USER_TOOL,
2729
)
2830
from astrbot.core.cron.events import CronMessageEvent
31+
from astrbot.core.message.components import Image
2932
from astrbot.core.message.message_event_result import (
3033
CommandResult,
3134
MessageChain,
@@ -34,10 +37,86 @@
3437
from astrbot.core.platform.message_session import MessageSession
3538
from astrbot.core.provider.entites import ProviderRequest
3639
from astrbot.core.provider.register import llm_tools
40+
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
3741
from astrbot.core.utils.history_saver import persist_agent_history
42+
from astrbot.core.utils.image_ref_utils import is_supported_image_ref
43+
from astrbot.core.utils.string_utils import normalize_and_dedupe_strings
3844

3945

4046
class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
47+
@classmethod
48+
def _collect_image_urls_from_args(cls, image_urls_raw: T.Any) -> list[str]:
49+
if image_urls_raw is None:
50+
return []
51+
52+
if isinstance(image_urls_raw, str):
53+
return [image_urls_raw]
54+
55+
if isinstance(image_urls_raw, (Sequence, AbstractSet)) and not isinstance(
56+
image_urls_raw, (str, bytes, bytearray)
57+
):
58+
return [item for item in image_urls_raw if isinstance(item, str)]
59+
60+
logger.debug(
61+
"Unsupported image_urls type in handoff tool args: %s",
62+
type(image_urls_raw).__name__,
63+
)
64+
return []
65+
66+
@classmethod
67+
async def _collect_image_urls_from_message(
68+
cls, run_context: ContextWrapper[AstrAgentContext]
69+
) -> list[str]:
70+
urls: list[str] = []
71+
event = getattr(run_context.context, "event", None)
72+
message_obj = getattr(event, "message_obj", None)
73+
message = getattr(message_obj, "message", None)
74+
if message:
75+
for idx, component in enumerate(message):
76+
if not isinstance(component, Image):
77+
continue
78+
try:
79+
path = await component.convert_to_file_path()
80+
if path:
81+
urls.append(path)
82+
except Exception as e:
83+
logger.error(
84+
"Failed to convert handoff image component at index %d: %s",
85+
idx,
86+
e,
87+
exc_info=True,
88+
)
89+
return urls
90+
91+
@classmethod
92+
async def _collect_handoff_image_urls(
93+
cls,
94+
run_context: ContextWrapper[AstrAgentContext],
95+
image_urls_raw: T.Any,
96+
) -> list[str]:
97+
candidates: list[str] = []
98+
candidates.extend(cls._collect_image_urls_from_args(image_urls_raw))
99+
candidates.extend(await cls._collect_image_urls_from_message(run_context))
100+
101+
normalized = normalize_and_dedupe_strings(candidates)
102+
extensionless_local_roots = (get_astrbot_temp_path(),)
103+
sanitized = [
104+
item
105+
for item in normalized
106+
if is_supported_image_ref(
107+
item,
108+
allow_extensionless_existing_local_file=True,
109+
extensionless_local_roots=extensionless_local_roots,
110+
)
111+
]
112+
dropped_count = len(normalized) - len(sanitized)
113+
if dropped_count > 0:
114+
logger.debug(
115+
"Dropped %d invalid image_urls entries in handoff image inputs.",
116+
dropped_count,
117+
)
118+
return sanitized
119+
41120
@classmethod
42121
async def execute(cls, tool, run_context, **tool_args):
43122
"""执行函数调用。
@@ -161,10 +240,28 @@ async def _execute_handoff(
161240
cls,
162241
tool: HandoffTool,
163242
run_context: ContextWrapper[AstrAgentContext],
164-
**tool_args,
243+
*,
244+
image_urls_prepared: bool = False,
245+
**tool_args: T.Any,
165246
):
247+
tool_args = dict(tool_args)
166248
input_ = tool_args.get("input")
167-
image_urls = tool_args.get("image_urls")
249+
if image_urls_prepared:
250+
prepared_image_urls = tool_args.get("image_urls")
251+
if isinstance(prepared_image_urls, list):
252+
image_urls = prepared_image_urls
253+
else:
254+
logger.debug(
255+
"Expected prepared handoff image_urls as list[str], got %s.",
256+
type(prepared_image_urls).__name__,
257+
)
258+
image_urls = []
259+
else:
260+
image_urls = await cls._collect_handoff_image_urls(
261+
run_context,
262+
tool_args.get("image_urls"),
263+
)
264+
tool_args["image_urls"] = image_urls
168265

169266
# Build handoff toolset from registered tools plus runtime computer tools.
170267
toolset = cls._build_handoff_toolset(run_context, tool.agent.tools)
@@ -263,8 +360,18 @@ async def _do_handoff_background(
263360
) -> None:
264361
"""Run the subagent handoff and, on completion, wake the main agent."""
265362
result_text = ""
363+
tool_args = dict(tool_args)
364+
tool_args["image_urls"] = await cls._collect_handoff_image_urls(
365+
run_context,
366+
tool_args.get("image_urls"),
367+
)
266368
try:
267-
async for r in cls._execute_handoff(tool, run_context, **tool_args):
369+
async for r in cls._execute_handoff(
370+
tool,
371+
run_context,
372+
image_urls_prepared=True,
373+
**tool_args,
374+
):
268375
if isinstance(r, mcp.types.CallToolResult):
269376
for content in r.content:
270377
if isinstance(content, mcp.types.TextContent):
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from collections.abc import Sequence
5+
from pathlib import Path
6+
from urllib.parse import unquote, urlparse
7+
8+
ALLOWED_IMAGE_EXTENSIONS = {
9+
".png",
10+
".jpg",
11+
".jpeg",
12+
".gif",
13+
".webp",
14+
".bmp",
15+
".tif",
16+
".tiff",
17+
".svg",
18+
".heic",
19+
}
20+
21+
22+
def resolve_file_url_path(image_ref: str) -> str:
23+
parsed = urlparse(image_ref)
24+
if parsed.scheme != "file":
25+
return image_ref
26+
27+
path = unquote(parsed.path or "")
28+
netloc = unquote(parsed.netloc or "")
29+
30+
# Keep support for file://<host>/path and file://<path> forms.
31+
if netloc and netloc.lower() != "localhost":
32+
path = f"//{netloc}{path}" if path else netloc
33+
elif not path and netloc:
34+
path = netloc
35+
36+
if os.name == "nt" and len(path) > 2 and path[0] == "/" and path[2] == ":":
37+
path = path[1:]
38+
39+
return path or image_ref
40+
41+
42+
def _is_path_within_roots(path: str, roots: Sequence[str]) -> bool:
43+
try:
44+
candidate = Path(path).resolve(strict=False)
45+
except Exception:
46+
return False
47+
48+
for root in roots:
49+
try:
50+
root_path = Path(root).resolve(strict=False)
51+
candidate.relative_to(root_path)
52+
return True
53+
except Exception:
54+
continue
55+
return False
56+
57+
58+
def is_supported_image_ref(
59+
image_ref: str,
60+
*,
61+
allow_extensionless_existing_local_file: bool = False,
62+
extensionless_local_roots: Sequence[str] | None = None,
63+
) -> bool:
64+
if not image_ref:
65+
return False
66+
67+
lowered = image_ref.lower()
68+
if lowered.startswith(("http://", "https://", "base64://")):
69+
return True
70+
71+
file_path = (
72+
resolve_file_url_path(image_ref) if lowered.startswith("file://") else image_ref
73+
)
74+
ext = os.path.splitext(file_path)[1].lower()
75+
if ext in ALLOWED_IMAGE_EXTENSIONS:
76+
return True
77+
if not allow_extensionless_existing_local_file:
78+
return False
79+
if not extensionless_local_roots:
80+
return False
81+
# Keep support for extension-less temp files returned by image converters.
82+
return (
83+
ext == ""
84+
and os.path.exists(file_path)
85+
and _is_path_within_roots(file_path, extensionless_local_roots)
86+
)

astrbot/core/utils/quoted_message/image_refs.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,9 @@
33
import os
44
from urllib.parse import urlsplit
55

6-
IMAGE_EXTENSIONS = {
7-
".jpg",
8-
".jpeg",
9-
".png",
10-
".webp",
11-
".bmp",
12-
".tif",
13-
".tiff",
14-
".gif",
15-
}
6+
from astrbot.core.utils.image_ref_utils import ALLOWED_IMAGE_EXTENSIONS
7+
8+
IMAGE_EXTENSIONS = ALLOWED_IMAGE_EXTENSIONS
169

1710

1811
def normalize_file_like_url(path: str | None) -> str | None:

0 commit comments

Comments
 (0)