Skip to content

Commit 7f898cf

Browse files
committed
feat: 添加内容工具send_image,优化未展开tool的调用
1 parent 4c051af commit 7f898cf

3 files changed

Lines changed: 214 additions & 11 deletions

File tree

src/maisaka/builtin_tool/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
from .reply import handle_tool as handle_reply_tool
2727
from .send_emoji import get_tool_spec as get_send_emoji_tool_spec
2828
from .send_emoji import handle_tool as handle_send_emoji_tool
29+
from .send_image import get_tool_spec as get_send_image_tool_spec
30+
from .send_image import handle_tool as handle_send_image_tool
2931
from .tool_search import get_tool_spec as get_tool_search_tool_spec
3032
from .tool_search import handle_tool as handle_tool_search_tool
3133
from .view_complex_message import get_tool_spec as get_view_complex_message_tool_spec
@@ -98,6 +100,7 @@ def _get_query_person_profile_tool_spec() -> ToolSpec:
98100
stage="action",
99101
),
100102
BuiltinToolEntry("send_emoji", get_send_emoji_tool_spec, handle_send_emoji_tool, stage="action"),
103+
BuiltinToolEntry("better_image_send_context", get_send_image_tool_spec, handle_send_image_tool, stage="action"),
101104
BuiltinToolEntry("tool_search", get_tool_search_tool_spec, handle_tool_search_tool, stage="action"),
102105
]
103106

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
"""图片发送内置动作。"""
2+
3+
from base64 import b64encode
4+
from typing import Any, Optional
5+
6+
from src.common.data_models.message_component_data_model import ImageComponent, MessageSequence
7+
from src.common.logger import get_logger
8+
from src.core.tooling import ToolExecutionContext, ToolExecutionResult, ToolInvocation, ToolSpec
9+
from src.maisaka.context_messages import SessionBackedMessage
10+
from src.services import send_service
11+
12+
from .context import BuiltinToolRuntimeContext
13+
14+
logger = get_logger("maisaka_builtin_send_image")
15+
16+
17+
def get_tool_spec() -> ToolSpec:
18+
"""获取图片发送工具声明。"""
19+
20+
return ToolSpec(
21+
name="better_image_send_context",
22+
description=(
23+
"发送聊天上下文或工具返回结果中的图片。按 msg_id 和 index 发送指定消息里的原图;"
24+
"也可以把工具返回媒体索引 tool_result:<call_id>:<item_index> 填入 msg_id、media_index 或 tool_result_index。"
25+
),
26+
parameters_schema={
27+
"type": "object",
28+
"properties": {
29+
"msg_id": {
30+
"type": "string",
31+
"description": "包含图片的上下文消息编号,也可以是工具返回媒体索引 tool_result:<call_id>:<item_index>。",
32+
"default": "",
33+
},
34+
"media_index": {
35+
"type": "string",
36+
"description": "工具返回媒体索引,例如 tool_result:call_x:1;与 msg_id 二选一。",
37+
"default": "",
38+
},
39+
"tool_result_index": {
40+
"type": "string",
41+
"description": "media_index 的别名,用于发送工具返回的图片。",
42+
"default": "",
43+
},
44+
"index": {
45+
"type": "integer",
46+
"description": "同一消息中的图片序号,从 0 开始。",
47+
"default": 0,
48+
},
49+
},
50+
},
51+
provider_name="maisaka_builtin",
52+
provider_type="builtin",
53+
)
54+
55+
56+
def _find_context_message_by_id(tool_ctx: BuiltinToolRuntimeContext, message_id: str) -> SessionBackedMessage | None:
57+
"""从 Maisaka 历史里按 message_id 查找上下文消息。"""
58+
59+
normalized_message_id = str(message_id or "").strip()
60+
if not normalized_message_id:
61+
return None
62+
63+
for history_message in reversed(tool_ctx.runtime._chat_history):
64+
if str(getattr(history_message, "message_id", "") or "").strip() != normalized_message_id:
65+
continue
66+
if isinstance(history_message, SessionBackedMessage):
67+
return history_message
68+
return None
69+
70+
71+
def _collect_images_from_sequence(message_sequence: MessageSequence | None) -> list[ImageComponent]:
72+
"""从消息组件序列中收集图片组件。"""
73+
74+
components = list(getattr(message_sequence, "components", []) or [])
75+
return [component for component in components if isinstance(component, ImageComponent)]
76+
77+
78+
async def _load_readable_images(
79+
tool_ctx: BuiltinToolRuntimeContext,
80+
images: list[ImageComponent],
81+
source_id: str,
82+
) -> tuple[list[ImageComponent], str | None]:
83+
"""确保图片组件已经加载二进制数据。"""
84+
85+
if not images:
86+
return [], f"目标消息中没有可读取的图片:msg_id={source_id}"
87+
88+
for image in images:
89+
if image.binary_data:
90+
continue
91+
try:
92+
await image.load_image_binary()
93+
except Exception as exc:
94+
logger.warning(f"{tool_ctx.runtime.log_prefix} 加载消息图片失败: msg_id={source_id} error={exc}")
95+
96+
readable_images = [image for image in images if image.binary_data]
97+
if not readable_images:
98+
return [], f"目标消息中的图片数据不可读取:msg_id={source_id}"
99+
return readable_images, None
100+
101+
102+
async def _collect_message_images(tool_ctx: BuiltinToolRuntimeContext, msg_id: str) -> tuple[list[ImageComponent], str | None]:
103+
"""从 Maisaka 历史消息或工具返回媒体消息里读取图片组件。"""
104+
105+
target_message_id = str(msg_id or "").strip()
106+
if not target_message_id:
107+
return [], "需要提供 msg_id。"
108+
109+
context_message = _find_context_message_by_id(tool_ctx, target_message_id)
110+
if context_message is not None:
111+
images = _collect_images_from_sequence(context_message.raw_message)
112+
return await _load_readable_images(tool_ctx, images, target_message_id)
113+
114+
target_message = tool_ctx.runtime.find_source_message_by_id(target_message_id)
115+
if target_message is None:
116+
return [], f"没有找到消息:msg_id={target_message_id}"
117+
118+
images = _collect_images_from_sequence(getattr(target_message, "raw_message", None))
119+
return await _load_readable_images(tool_ctx, images, target_message_id)
120+
121+
122+
def _normalize_image_index(arguments: dict[str, Any]) -> int:
123+
"""兼容旧工具的 image_index 参数别名。"""
124+
125+
raw_index = arguments.get("image_index", arguments.get("index", 0))
126+
try:
127+
return int(raw_index or 0)
128+
except (TypeError, ValueError):
129+
return 0
130+
131+
132+
async def handle_tool(
133+
tool_ctx: BuiltinToolRuntimeContext,
134+
invocation: ToolInvocation,
135+
context: Optional[ToolExecutionContext] = None,
136+
) -> ToolExecutionResult:
137+
"""执行图片发送内置动作。"""
138+
139+
del context
140+
arguments = dict(invocation.arguments or {})
141+
target_message_id = (
142+
str(arguments.get("media_index") or "").strip()
143+
or str(arguments.get("tool_result_index") or "").strip()
144+
or str(arguments.get("msg_id") or "").strip()
145+
)
146+
image_index = _normalize_image_index(arguments)
147+
structured_content: dict[str, Any] = {
148+
"success": False,
149+
"stream_id": tool_ctx.runtime.session_id,
150+
"msg_id": target_message_id,
151+
"index": image_index,
152+
}
153+
154+
images, error = await _collect_message_images(tool_ctx, target_message_id)
155+
if error is not None:
156+
return tool_ctx.build_failure_result(
157+
invocation.tool_name,
158+
error,
159+
structured_content=structured_content,
160+
)
161+
162+
if image_index < 0 or image_index >= len(images):
163+
return tool_ctx.build_failure_result(
164+
invocation.tool_name,
165+
f"图片序号超出范围:index={image_index},该消息共有 {len(images)} 张图片。",
166+
structured_content=structured_content,
167+
)
168+
169+
image_base64 = b64encode(images[image_index].binary_data).decode("utf-8")
170+
source_label = f"{target_message_id} 的第 {image_index} 张图片"
171+
success = await send_service.image_to_stream(
172+
image_base64=image_base64,
173+
stream_id=tool_ctx.runtime.session_id,
174+
sync_to_maisaka_history=True,
175+
maisaka_source_kind="better_image_send_context",
176+
)
177+
if not success:
178+
return tool_ctx.build_failure_result(
179+
invocation.tool_name,
180+
f"发送上下文图片失败:{source_label}",
181+
structured_content=structured_content,
182+
)
183+
184+
structured_content["success"] = True
185+
return tool_ctx.build_success_result(
186+
invocation.tool_name,
187+
f"已发送上下文图片:{source_label}",
188+
structured_content=structured_content,
189+
)

src/maisaka/reasoning_engine.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1691,6 +1691,24 @@ def _build_tool_result_summary(self, tool_call: ToolCall, result: ToolExecutionR
16911691
normalized_content = self._truncate_tool_record_text(history_content, max_length=200)
16921692
return f"- {tool_call.func_name} {summary_prefix}: {normalized_content}"
16931693

1694+
@staticmethod
1695+
def _append_deferred_tool_parameter_hint(result: ToolExecutionResult) -> ToolExecutionResult:
1696+
"""给未展开工具的失败结果补充参数查看提示。"""
1697+
1698+
hint = "请通过 tool_search 查看具体的工具参数后再重试。"
1699+
if result.success:
1700+
return result
1701+
if result.error_message:
1702+
if hint not in result.error_message:
1703+
result.error_message = f"{result.error_message}\n{hint}"
1704+
return result
1705+
if result.content:
1706+
if hint not in result.content:
1707+
result.content = f"{result.content}\n{hint}"
1708+
return result
1709+
result.error_message = hint
1710+
return result
1711+
16941712
def _build_tool_monitor_result(
16951713
self,
16961714
tool_call: ToolCall,
@@ -1782,17 +1800,10 @@ async def _handle_tool_calls(
17821800
f"第 {tool_index}/{total_tool_count} 个工具",
17831801
)
17841802
tool_started_at = time.time()
1785-
if not self._runtime.is_action_tool_currently_available(invocation.tool_name):
1786-
result = ToolExecutionResult(
1787-
tool_name=invocation.tool_name,
1788-
success=False,
1789-
error_message=(
1790-
f"工具 {invocation.tool_name} 当前未直接暴露给 planner。"
1791-
"如果它在 deferred tools 提示中,请先调用 tool_search。"
1792-
),
1793-
)
1794-
else:
1795-
result = await self._runtime._tool_registry.invoke(invocation, execution_context)
1803+
is_unexpanded_tool = not self._runtime.is_action_tool_currently_available(invocation.tool_name)
1804+
result = await self._runtime._tool_registry.invoke(invocation, execution_context)
1805+
if is_unexpanded_tool and not result.success:
1806+
result = self._append_deferred_tool_parameter_hint(result)
17961807
tool_duration_ms = (time.time() - tool_started_at) * 1000
17971808
await self._store_tool_execution_record(
17981809
invocation,

0 commit comments

Comments
 (0)