Skip to content

Commit 27706d8

Browse files
committed
fix: refactor handoff image_urls preprocessing flow
1 parent 123efc2 commit 27706d8

2 files changed

Lines changed: 74 additions & 45 deletions

File tree

astrbot/core/astr_agent_tool_exec.py

Lines changed: 44 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import traceback
66
import typing as T
77
import uuid
8+
from collections.abc import Sequence
9+
from collections.abc import Set as AbstractSet
810

911
import mcp
1012

@@ -66,22 +68,23 @@ def _is_supported_image_ref(cls, image_ref: str) -> bool:
6668
return ext in cls._ALLOWED_IMAGE_EXTENSIONS
6769

6870
@classmethod
69-
async def _prepare_handoff_image_urls(
70-
cls,
71-
run_context: ContextWrapper[AstrAgentContext],
72-
tool_args: dict[str, T.Any],
73-
) -> list[str]:
74-
image_urls = tool_args.get("image_urls")
71+
def _coerce_image_urls(cls, image_urls: T.Any) -> list[T.Any]:
7572
if image_urls is None:
76-
candidates: list[T.Any] = []
77-
elif isinstance(image_urls, str):
78-
candidates = [image_urls]
79-
else:
80-
try:
81-
candidates = list(image_urls)
82-
except (TypeError, ValueError):
83-
candidates = [image_urls]
73+
return []
74+
if isinstance(image_urls, str):
75+
return [image_urls]
76+
if isinstance(image_urls, (Sequence, AbstractSet)) and not isinstance(
77+
image_urls, (str, bytes, bytearray)
78+
):
79+
return list(image_urls)
80+
logger.warning(
81+
"Unsupported image_urls type in handoff tool args: %s",
82+
type(image_urls).__name__,
83+
)
84+
return []
8485

86+
@classmethod
87+
def _filter_supported_image_urls(cls, candidates: list[T.Any]) -> list[str]:
8588
normalized = normalize_and_dedupe_strings(candidates)
8689
sanitized = [item for item in normalized if cls._is_supported_image_ref(item)]
8790
dropped_count = len(normalized) - len(sanitized)
@@ -90,8 +93,13 @@ async def _prepare_handoff_image_urls(
9093
"Dropped %d invalid image_urls entries in handoff tool args.",
9194
dropped_count,
9295
)
96+
return sanitized
9397

94-
# Merge current event image attachments so sub-agent behavior matches main-agent flow.
98+
@classmethod
99+
async def _iter_event_image_paths(
100+
cls, run_context: ContextWrapper[AstrAgentContext]
101+
) -> list[str]:
102+
paths: list[str] = []
95103
event = getattr(run_context.context, "event", None)
96104
message_obj = getattr(event, "message_obj", None)
97105
message = getattr(message_obj, "message", None)
@@ -101,22 +109,27 @@ async def _prepare_handoff_image_urls(
101109
continue
102110
try:
103111
path = await component.convert_to_file_path()
104-
if (
105-
path
106-
and cls._is_supported_image_ref(path)
107-
and path not in sanitized
108-
):
109-
sanitized.append(path)
112+
if path and cls._is_supported_image_ref(path):
113+
paths.append(path)
110114
except Exception as e:
111115
logger.error(
112116
"Failed to convert handoff image component at index %d: %s",
113117
idx,
114118
e,
115119
exc_info=True,
116120
)
121+
return paths
117122

118-
tool_args["image_urls"] = sanitized
119-
return sanitized
123+
@classmethod
124+
async def _prepare_handoff_image_urls(
125+
cls,
126+
run_context: ContextWrapper[AstrAgentContext],
127+
image_urls: T.Any,
128+
) -> list[str]:
129+
candidates = cls._coerce_image_urls(image_urls)
130+
event_paths = await cls._iter_event_image_paths(run_context)
131+
candidates.extend(event_paths)
132+
return cls._filter_supported_image_urls(candidates)
120133

121134
@classmethod
122135
async def execute(cls, tool, run_context, **tool_args):
@@ -138,7 +151,7 @@ async def execute(cls, tool, run_context, **tool_args):
138151
):
139152
yield r
140153
return
141-
async for r in cls._execute_handoff(tool, run_context, **tool_args):
154+
async for r in cls._execute_handoff(tool, run_context, tool_args):
142155
yield r
143156
return
144157

@@ -241,10 +254,14 @@ async def _execute_handoff(
241254
cls,
242255
tool: HandoffTool,
243256
run_context: ContextWrapper[AstrAgentContext],
244-
**tool_args,
257+
tool_args: dict[str, T.Any],
245258
):
246259
input_ = tool_args.get("input")
247-
image_urls = await cls._prepare_handoff_image_urls(run_context, tool_args)
260+
image_urls = await cls._prepare_handoff_image_urls(
261+
run_context,
262+
tool_args.get("image_urls"),
263+
)
264+
tool_args["image_urls"] = image_urls
248265

249266
# Build handoff toolset from registered tools plus runtime computer tools.
250267
toolset = cls._build_handoff_toolset(run_context, tool.agent.tools)
@@ -345,10 +362,7 @@ async def _do_handoff_background(
345362
result_text = ""
346363
prepared_tool_args = dict(tool_args)
347364
try:
348-
await cls._prepare_handoff_image_urls(run_context, prepared_tool_args)
349-
async for r in cls._execute_handoff(
350-
tool, run_context, **prepared_tool_args
351-
):
365+
async for r in cls._execute_handoff(tool, run_context, prepared_tool_args):
352366
if isinstance(r, mcp.types.CallToolResult):
353367
for content in r.content:
354368
if isinstance(content, mcp.types.TextContent):

tests/unit/test_astr_agent_tool_exec.py

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,26 +39,41 @@ async def _fake_convert_to_file_path(self):
3939
monkeypatch.setattr(Image, "convert_to_file_path", _fake_convert_to_file_path)
4040

4141
run_context = _build_run_context([Image(file="file:///tmp/original.png")])
42-
tool_args = {
43-
"image_urls": (
44-
" https://example.com/a.png ",
45-
"/tmp/not_an_image.txt",
46-
"/tmp/local.webp",
47-
123,
48-
)
49-
}
42+
image_urls_input = (
43+
" https://example.com/a.png ",
44+
"/tmp/not_an_image.txt",
45+
"/tmp/local.webp",
46+
123,
47+
)
5048

5149
image_urls = await FunctionToolExecutor._prepare_handoff_image_urls(
5250
run_context,
53-
tool_args,
51+
image_urls_input,
5452
)
5553

5654
assert image_urls == [
5755
"https://example.com/a.png",
5856
"/tmp/local.webp",
5957
"/tmp/event_image.png",
6058
]
61-
assert tool_args["image_urls"] == image_urls
59+
60+
61+
@pytest.mark.asyncio
62+
async def test_prepare_handoff_image_urls_skips_failed_event_image_conversion(
63+
monkeypatch: pytest.MonkeyPatch,
64+
):
65+
async def _fake_convert_to_file_path(self):
66+
raise RuntimeError("boom")
67+
68+
monkeypatch.setattr(Image, "convert_to_file_path", _fake_convert_to_file_path)
69+
70+
run_context = _build_run_context([Image(file="file:///tmp/original.png")])
71+
image_urls = await FunctionToolExecutor._prepare_handoff_image_urls(
72+
run_context,
73+
["https://example.com/a.png"],
74+
)
75+
76+
assert image_urls == ["https://example.com/a.png"]
6277

6378

6479
@pytest.mark.asyncio
@@ -67,11 +82,11 @@ async def test_do_handoff_background_reports_prepared_image_urls(
6782
):
6883
captured: dict = {}
6984

70-
async def _fake_prepare(cls, run_context, tool_args):
71-
tool_args["image_urls"] = ["prepared://image.png"]
72-
return tool_args["image_urls"]
85+
async def _unexpected_prepare(cls, run_context, image_urls):
86+
raise AssertionError("background path should not pre-prepare image urls")
7387

74-
async def _fake_execute_handoff(cls, tool, run_context, **tool_args):
88+
async def _fake_execute_handoff(cls, tool, run_context, tool_args):
89+
tool_args["image_urls"] = ["prepared://image.png"]
7590
yield mcp.types.CallToolResult(
7691
content=[mcp.types.TextContent(type="text", text="ok")]
7792
)
@@ -82,7 +97,7 @@ async def _fake_wake(cls, run_context, **kwargs):
8297
monkeypatch.setattr(
8398
FunctionToolExecutor,
8499
"_prepare_handoff_image_urls",
85-
classmethod(_fake_prepare),
100+
classmethod(_unexpected_prepare),
86101
)
87102
monkeypatch.setattr(
88103
FunctionToolExecutor,

0 commit comments

Comments
 (0)