feat(search): 实现 SearchToolDispatcher 工具调度器 (Task 7)

- 新增 app/search/tools.py:
  - get_tool_descriptions() 工具描述文本(与 TRM4 一致)
  - SearchToolDispatcher 类实现 ToolDispatcher Protocol
  - dispatch() 按工具名路由: view_node / search_similar /
    observe_frame / submit_answer / read_skill
  - ValueError(未知工具)上抛,KeyError/FileNotFoundError 捕获返回错误文本
  - view_node: env.get_node_text + summarize_node + get_children_info + summarize_children
  - search_similar: env.search_similar + summarize_nodes_batch
  - observe_frame: env.resolve_frame_paths + get_subtitle + observe_frame + 字幕前置

- 修复 app/tree/environment.py get_children_info():
  - 原实现返回 _format_time_range (str) 导致 summarize_children 解包失败
  - 改为返回原始数值元组 via 新增 _node_time_range_raw 静态方法

- 新增 tests/unit/test_search_tools.py (14 tests):
  - get_tool_descriptions 含/不含 read_skill
  - 五种工具 dispatch 路由验证
  - 未知工具 ValueError + 节点不存在错误文本

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 06:07:27 -04:00
parent ca3ea1cdf2
commit f4f92b0938
3 changed files with 788 additions and 1 deletions
+321
View File
@@ -0,0 +1,321 @@
"""搜索 Agent 工具调度器 — 工具描述与 dispatch 分发。
实现 ``core/agent/protocols.ToolDispatcher`` Protocol。
连接 TreeEnvironment(数据)、summarizerLLM 摘要)、
visionVLM 观察)和 skills(策略加载)。
与 TRM4 ``core/tree/tools.py`` 的差异:
- 自由函数 ``dispatch()`` → ``SearchToolDispatcher`` 类(依赖注入);
- 同步 → 全异步;
- view_node / search_similar 内部拆分为 env 数据读取 + summarizer LLM 摘要。
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from app.search.summarizer import summarize_children, summarize_node, summarize_nodes_batch
from app.search.vision import observe_frame
from app.tree.environment import _LEVEL_LABEL, TreeEnvironment, _node_level
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
import numpy as np
from app.ports import OCRProvider
from app.search.skills import SkillRegistry
from core.protocols import LLMProvider, VLMProvider
# ── 工具描述文本(与 TRM4 core/tree/tools.py 完全一致) ─────────────────
_BASE_DESCRIPTIONS = """\
## 可用工具
在 action 中指定 tool 和 args 来调用工具。
### view_node
查看节点信息,获取与问题相关的内容摘要和子节点概览。
- args: {"node_id": "节点 ID", "question": "当前关注的具体问题"}
### search_similar
语义检索最相关的节点,返回与问题相关的内容摘要。
- args: {"query": "搜索关键词(2-4 词)", "question": "当前关注的具体问题", "k": 返回数量(可选,默认 5}
### observe_frame
调用视觉模型查看关键帧图像,回答针对性的视觉问题。
- args: {"node_ids": ["L3 节点 ID 列表(1-4 个),或单个 L2 节点 ID"], "question": "针对帧内容的具体视觉问题"}
### submit_answer
提交最终答案。
- args: {"answer": "选项字母 A/B/C/D", "evidence": "关键证据摘要", "reasoning": "每个选项的判断理由"}"""
_SKILL_DESCRIPTION = """
### read_skill
加载指定题型技能的详细搜索策略。
- args: {"name": "技能名称"}"""
def get_tool_descriptions(include_read_skill: bool = False) -> str:
"""返回工具描述文本,用于写入 system prompt。
参数:
include_read_skill: 是否包含 read_skill 工具(manual 模式用)。
返回:
Markdown 格式的工具描述文本。
"""
text = _BASE_DESCRIPTIONS
if include_read_skill:
text += _SKILL_DESCRIPTION
return text
# ── SearchToolDispatcher ──────────────────────────────────────────────
class SearchToolDispatcher:
"""搜索 Agent 工具调度器,实现 ToolDispatcher Protocol。
按工具名路由到对应私有处理方法。未知工具抛 ValueError
(AgentLoop 捕获后不计步数);节点不存在等运行时错误
捕获后返回错误文本。
参数:
env: 视频树运行时环境(纯数据访问)。
tool_llm: 摘要用 LLM 端口。
vlm: 视觉模型端口。
ocr: 帧文字转录端口(None 不启用)。
prompts_dir: prompt 文件目录。
skills: 技能注册表(None 不启用 read_skill)。
embed_fn: 文本嵌入函数(search_similar 用)。
verify_vision: observe_frame 是否执行验证轮。
anchor: view_node 是否启用行号锚模式。
assemble_mode: 锚模式装配形态("ids"/"ids_expand"/"expand_only")。
stats_sink: 统计回调(None 不收集)。
"""
def __init__(
self,
env: TreeEnvironment,
tool_llm: LLMProvider,
vlm: VLMProvider,
ocr: OCRProvider | None,
prompts_dir: Path,
skills: SkillRegistry | None,
*,
embed_fn: Callable[[str | list[str]], np.ndarray],
verify_vision: bool,
anchor: bool,
assemble_mode: str,
stats_sink: Callable[[dict[str, Any]], None] | None = None,
) -> None:
self._env = env
self._tool_llm = tool_llm
self._vlm = vlm
self._ocr = ocr
self._prompts_dir = prompts_dir
self._skills = skills
self._embed_fn = embed_fn
self._verify_vision = verify_vision
self._anchor = anchor
self._assemble_mode = assemble_mode
self._stats_sink = stats_sink
# ── ToolDispatcher Protocol 实现 ──────────────────────────────────
async def dispatch(
self, tool_name: str, args: dict[str, Any], *, context: dict[str, Any]
) -> str:
"""按工具名分发到对应处理方法。
参数:
tool_name: 工具名称。
args: 工具参数字典。
context: 调用上下文(含 session_id、parent_call_id 等遥测字段)。
返回:
工具执行结果文本。
异常:
ValueError: 未知工具名——上抛给 AgentLoop,不计步数。
"""
try:
if tool_name == "view_node":
return await self._handle_view_node(args, context)
if tool_name == "search_similar":
return await self._handle_search_similar(args, context)
if tool_name == "observe_frame":
return await self._handle_observe_frame(args, context)
if tool_name == "submit_answer":
return f"[ok] 答案已提交: {args['answer']}"
if tool_name == "read_skill":
return self._handle_read_skill(args)
except (KeyError, FileNotFoundError) as e:
return f"工具执行错误: {e}"
raise ValueError(f"未知工具: {tool_name}")
# ── 私有处理方法 ──────────────────────────────────────────────────
async def _handle_view_node(self, args: dict[str, Any], context: dict[str, Any]) -> str:
"""view_node:节点摘要 + 子节点概览。
参数:
args: {"node_id": str, "question": str}。
context: 遥测上下文。
返回:
"[节点] {id} | {level} | {time}\\n\\n{summary}\\n\\n[子节点概览] ..."
"""
node_id: str = args["node_id"]
question: str = args["question"]
session_id = context.get("session_id")
parent_call_id = context.get("parent_call_id")
# Phase 1: 节点元数据(头部格式化)
node = self._env._id_to_node[node_id]
level = _node_level(node)
level_label = _LEVEL_LABEL[level]
time_str = TreeEnvironment._format_time_range(node)
# Phase 2: 节点内容摘要
raw_text, anchor_map = self._env.get_node_text(node_id, anchor=self._anchor)
summary = await summarize_node(
self._tool_llm,
raw_text,
question,
self._prompts_dir,
anchor_map=anchor_map,
assemble_mode=self._assemble_mode,
stats_sink=self._stats_sink,
session_id=session_id,
parent_call_id=parent_call_id,
)
parts: list[str] = [
f"[节点] {node_id} | {level_label} | {time_str}",
"",
summary,
]
# Phase 3: 子节点概览
children_info = self._env.get_children_info(node_id)
if children_info:
children_text = await summarize_children(
self._tool_llm,
children_info,
question,
self._prompts_dir,
session_id=session_id,
parent_call_id=parent_call_id,
)
parts.append(f"\n[子节点概览] {len(children_info)} 个子节点\n{children_text}")
return "\n".join(parts)
async def _handle_search_similar(self, args: dict[str, Any], context: dict[str, Any]) -> str:
"""search_similar:语义检索 + 批量摘要。
参数:
args: {"query": str, "question": str, "k": int (可选)}。
context: 遥测上下文。
返回:
"[搜索结果] 查询 \\"{query}\\" → N 个相关节点\\n\\n1. ..."
"""
query: str = args["query"]
question: str = args["question"]
top_k: int = args.get("k", 5)
session_id = context.get("session_id")
parent_call_id = context.get("parent_call_id")
# Phase 1: 语义检索
results = self._env.search_similar(query, top_k=top_k, embed_fn=self._embed_fn)
if not results:
return f'[搜索结果] 查询 "{query}" → 0 个相关节点'
# Phase 2: 构建摘要输入
items: list[tuple[str, str, str]] = []
for nid, score in results:
node = self._env._id_to_node[nid]
raw_text, _ = self._env.get_node_text(nid)
level = _node_level(node)
time_str = TreeEnvironment._format_time_range(node)
extra = f"{level} score={score:.4f} [{time_str}]"
items.append((nid, raw_text, extra))
# Phase 3: 并发批量摘要
summaries = await summarize_nodes_batch(
self._tool_llm,
items,
question,
self._prompts_dir,
session_id=session_id,
parent_call_id=parent_call_id,
)
# Phase 4: 格式化输出
lines: list[str] = []
for i, (nid, summary_text) in enumerate(summaries):
_, _, extra = items[i]
lines.append(f"{i + 1}. {nid} | {extra}\n {summary_text}")
header = f'[搜索结果] 查询 "{query}"{len(results)} 个相关节点'
return header + "\n\n" + "\n\n".join(lines)
async def _handle_observe_frame(self, args: dict[str, Any], context: dict[str, Any]) -> str:
"""observe_frameVLM 帧观察 + 字幕前置。
参数:
args: {"node_ids": list[str], "question": str}。
context: 遥测上下文。
返回:
"[字幕上下文] ...\\n[视觉观察] ...""[视觉观察] ..."
"""
node_ids: list[str] = args["node_ids"]
question: str = args.get("question", "")
session_id = context.get("session_id")
parent_call_id = context.get("parent_call_id")
if not question.strip():
return "工具执行错误: question 不能为空"
# Phase 1: 解析帧路径和字幕
frame_paths = self._env.resolve_frame_paths(node_ids)
subtitle = self._env.get_subtitle(node_ids[0])
# Phase 2: VLM 调用
result = await observe_frame(
self._vlm,
frame_paths,
question,
self._prompts_dir,
ocr=self._ocr,
verify=self._verify_vision,
stats_sink=self._stats_sink,
session_id=session_id,
parent_call_id=parent_call_id,
)
# Phase 3: 字幕前置拼接
if subtitle:
return f"[字幕上下文] {subtitle}\n{result}"
return result
def _handle_read_skill(self, args: dict[str, Any]) -> str:
"""read_skill:加载指定技能的搜索策略正文。
参数:
args: {"name": str}。
返回:
技能正文或错误提示。
"""
if self._skills is None:
return "错误: skills 未启用"
return self._skills.read(args["name"])
+21 -1
View File
@@ -339,6 +339,7 @@ class TreeEnvironment:
返回: 返回:
子节点信息列表,每项包含 {"id", "time_range", "summary"}。 子节点信息列表,每项包含 {"id", "time_range", "summary"}。
time_range 为 (start, end) 数值元组(L3 节点退化为 (ts, ts))。
L3 叶子节点返回空列表。 L3 叶子节点返回空列表。
异常: 异常:
@@ -357,7 +358,7 @@ class TreeEnvironment:
result.append( result.append(
{ {
"id": child.id, "id": child.id,
"time_range": self._format_time_range(child), "time_range": self._node_time_range_raw(child),
"summary": desc, "summary": desc,
} }
) )
@@ -504,6 +505,25 @@ class TreeEnvironment:
return f"{node.timestamp:.1f}s" return f"{node.timestamp:.1f}s"
return "N/A" return "N/A"
@staticmethod
def _node_time_range_raw(node: AnyNode) -> tuple[float, float]:
"""提取节点时间范围的原始数值元组。
L1/L2 返回 time_range 元组;L3 退化为 (timestamp, timestamp)
全部为 None 时兜底 (0.0, 0.0)。
参数:
node: 树节点。
返回:
(start, end) 秒级数值元组。
"""
if isinstance(node, (L1Node, L2Node)) and node.time_range:
return node.time_range
if isinstance(node, L3Node) and node.timestamp is not None:
return (node.timestamp, node.timestamp)
return (0.0, 0.0)
@staticmethod @staticmethod
def _get_children(node: AnyNode) -> list[AnyNode]: def _get_children(node: AnyNode) -> list[AnyNode]:
"""获取节点的直接子节点列表。 """获取节点的直接子节点列表。
+446
View File
@@ -0,0 +1,446 @@
"""SearchToolDispatcher 与 get_tool_descriptions 单元测试。
验证工具描述生成和五种工具的 dispatch 路由:
view_node、search_similar、observe_frame、submit_answer、read_skill
以及未知工具 ValueError 和节点不存在错误文本。
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pathlib import Path
import numpy as np
import pytest
from app.search.skills import SkillRegistry
from app.search.tools import SearchToolDispatcher, get_tool_descriptions
from app.tree.environment import TreeEnvironment
from app.tree.index import (
IndexMeta,
L1Card,
L1Node,
L2Card,
L2Node,
L3Card,
L3Node,
TreeIndex,
)
from core.types import LLMResponse
# ── 假实现 ────────────────────────────────────────────────────────────
def _make_llm_response(content: str = "fake summary") -> LLMResponse:
"""构造固定的 LLMResponse 实例。"""
return LLMResponse(
content=content,
thinking="",
model="fake-model",
provider="fake",
prompt_tokens=10,
completion_tokens=5,
latency_ms=50,
ttft_ms=None,
max_inter_token_ms=None,
cache_hit=False,
call_id="fake-call-id",
)
class FakeLLM:
"""最小 LLMProvider 假实现。"""
async def chat(
self,
messages: list[dict[str, Any]],
*,
session_id: str | None = None,
parent_call_id: str | None = None,
) -> LLMResponse:
"""返回固定摘要内容。"""
return _make_llm_response("fake summary")
class FakeVLM:
"""最小 VLMProvider 假实现。"""
async def chat_with_images(
self,
messages: list[dict[str, Any]],
images: list[str | Path],
*,
session_id: str | None = None,
parent_call_id: str | None = None,
) -> LLMResponse:
"""返回固定视觉观察内容。"""
return _make_llm_response("fake visual observation")
class FakeOCR:
"""最小 OCRProvider 假实现。"""
async def transcribe_frames(self, frame_paths: list[Path]) -> str:
"""返回固定 OCR 文本。"""
return "OCR text"
def _fake_embed_fn(texts: str | list[str]) -> np.ndarray:
"""返回固定维度的 L2 归一化嵌入向量。"""
if isinstance(texts, str):
vec = np.ones((1, 4), dtype=np.float32)
else:
vec = np.ones((len(texts), 4), dtype=np.float32)
norms = np.linalg.norm(vec, axis=1, keepdims=True)
return vec / norms
# ── Fixtures ──────────────────────────────────────────────────────────
def _make_test_tree() -> TreeIndex:
"""构建包含 L1→L2→L3 的最小测试树。"""
l3 = L3Node(
id="vid_L1_000_L2_000_L3_000",
card=L3Card(
frame_summary="test frame summary",
visible_entities=["person"],
ongoing_actions=["walking"],
visible_text=[],
spatial_layout="center",
visual_attributes={},
),
timestamp=10.0,
frame_path="frames/L1_000_L2_000_L3_000.jpg",
subtitle="test subtitle text",
)
l2 = L2Node(
id="vid_L1_000_L2_000",
card=L2Card(
event_description="test event description",
entities=["person"],
actions=["walking"],
action_subjects=["person"],
visible_text=[],
spatial_relations="none",
state_changes=None,
),
time_range=(5.0, 15.0),
children=[l3],
)
l1 = L1Node(
id="vid_L1_000",
card=L1Card(
scene_summary="test scene summary",
main_setting="outdoor",
key_entities=["person"],
main_actions=["walking"],
topic_keywords=["outdoor"],
visible_text=[],
temporal_flow="linear",
),
time_range=(0.0, 30.0),
children=[l2],
)
return TreeIndex(
metadata=IndexMeta(source_path="test.mp4", modality="video"),
roots=[l1],
)
@pytest.fixture()
def env() -> TreeEnvironment:
"""带最小树的 TreeEnvironment。"""
return TreeEnvironment(_make_test_tree())
@pytest.fixture()
def prompts_dir(tmp_path: Path) -> Path:
"""在 tmp 目录中创建必需的 prompt 文件。"""
prompt_files = [
"view_node_extract.md",
"view_node_verify.md",
"view_node_children_extract.md",
"view_node_children_verify.md",
"search_similar_extract.md",
"search_similar_verify.md",
"observe_frame_extract.md",
"observe_frame_verify.md",
]
for name in prompt_files:
(tmp_path / name).write_text(f"fake prompt for {name}", encoding="utf-8")
return tmp_path
@pytest.fixture()
def skills_registry(tmp_path: Path) -> SkillRegistry:
"""带一个预注册技能的 SkillRegistry。"""
skill_path = tmp_path / "test_skill.md"
skill_path.write_text(
"---\nname: test_skill\ndescription: test\n---\nskill body content",
encoding="utf-8",
)
registry = SkillRegistry()
registry.set_paths({"test_skill": skill_path})
return registry
@pytest.fixture()
def dispatcher(
env: TreeEnvironment,
prompts_dir: Path,
skills_registry: SkillRegistry,
) -> SearchToolDispatcher:
"""标准配置的 SearchToolDispatcher 实例。"""
return SearchToolDispatcher(
env=env,
tool_llm=FakeLLM(),
vlm=FakeVLM(),
ocr=FakeOCR(),
prompts_dir=prompts_dir,
skills=skills_registry,
embed_fn=_fake_embed_fn,
verify_vision=False,
anchor=False,
assemble_mode="ids",
)
@pytest.fixture()
def dispatcher_no_skills(
env: TreeEnvironment,
prompts_dir: Path,
) -> SearchToolDispatcher:
"""skills=None 的 SearchToolDispatcher 实例。"""
return SearchToolDispatcher(
env=env,
tool_llm=FakeLLM(),
vlm=FakeVLM(),
ocr=None,
prompts_dir=prompts_dir,
skills=None,
embed_fn=_fake_embed_fn,
verify_vision=False,
anchor=False,
assemble_mode="ids",
)
# ── get_tool_descriptions 测试 ───────────────────────────────────────
class TestGetToolDescriptions:
"""get_tool_descriptions 工具描述生成测试。"""
def test_without_read_skill(self) -> None:
"""不含 read_skill 时应包含四个基础工具。"""
text = get_tool_descriptions(include_read_skill=False)
assert "view_node" in text
assert "search_similar" in text
assert "observe_frame" in text
assert "submit_answer" in text
assert "read_skill" not in text
def test_with_read_skill(self) -> None:
"""含 read_skill 时应额外包含 read_skill 工具描述。"""
text = get_tool_descriptions(include_read_skill=True)
assert "view_node" in text
assert "read_skill" in text
assert "加载指定题型技能" in text
# ── dispatch 路由测试 ─────────────────────────────────────────────────
class TestDispatchViewNode:
"""dispatch view_node 工具测试。"""
@pytest.mark.asyncio()
async def test_view_node_returns_header_and_summary(
self, dispatcher: SearchToolDispatcher
) -> None:
"""view_node 应返回含节点头部、摘要和子节点概览的文本。"""
result = await dispatcher.dispatch(
"view_node",
{"node_id": "vid_L1_000", "question": "what happens?"},
context={},
)
# 头部格式
assert "[节点] vid_L1_000 | 场景层 |" in result
assert "0.0-30.0s" in result
# 摘要内容(来自 FakeLLM
assert "fake summary" in result
# 子节点概览(L1 有 L2 子节点)
assert "[子节点概览]" in result
assert "1 个子节点" in result
@pytest.mark.asyncio()
async def test_view_node_l3_no_children(self, dispatcher: SearchToolDispatcher) -> None:
"""L3 叶子节点应无子节点概览段。"""
result = await dispatcher.dispatch(
"view_node",
{"node_id": "vid_L1_000_L2_000_L3_000", "question": "test"},
context={},
)
assert "[节点] vid_L1_000_L2_000_L3_000 | 关键帧层 |" in result
assert "[子节点概览]" not in result
class TestDispatchSearchSimilar:
"""dispatch search_similar 工具测试。"""
@pytest.mark.asyncio()
async def test_search_similar_returns_results(self, dispatcher: SearchToolDispatcher) -> None:
"""search_similar 应返回搜索头部和编号结果列表。"""
result = await dispatcher.dispatch(
"search_similar",
{"query": "walking", "question": "what is the person doing?"},
context={},
)
assert '[搜索结果] 查询 "walking"' in result
assert "个相关节点" in result
# 至少有一个编号结果
assert "1." in result
# 包含分数信息
assert "score=" in result
@pytest.mark.asyncio()
async def test_search_similar_custom_k(self, dispatcher: SearchToolDispatcher) -> None:
"""search_similar 的 k 参数应限制返回数量。"""
result = await dispatcher.dispatch(
"search_similar",
{"query": "test", "question": "test", "k": 1},
context={},
)
assert "1 个相关节点" in result
class TestDispatchObserveFrame:
"""dispatch observe_frame 工具测试。"""
@pytest.mark.asyncio()
async def test_observe_frame_with_subtitle(
self, dispatcher: SearchToolDispatcher, tmp_path: Path
) -> None:
"""有字幕的 L3 节点应在输出前添加字幕上下文。"""
# 创建帧文件使路径存在检查通过
frame_file = tmp_path / "L1_000_L2_000_L3_000.jpg"
frame_file.write_bytes(b"\xff\xd8\xff\xe0")
# 重建 dispatcher 指定 frames_dir
tree = _make_test_tree()
env_with_frames = TreeEnvironment(tree, frames_dir=tmp_path)
d = SearchToolDispatcher(
env=env_with_frames,
tool_llm=FakeLLM(),
vlm=FakeVLM(),
ocr=FakeOCR(),
prompts_dir=dispatcher._prompts_dir,
skills=None,
embed_fn=_fake_embed_fn,
verify_vision=False,
anchor=False,
assemble_mode="ids",
)
result = await d.dispatch(
"observe_frame",
{
"node_ids": ["vid_L1_000_L2_000_L3_000"],
"question": "what is visible?",
},
context={},
)
assert "[字幕上下文] test subtitle text" in result
assert "fake visual observation" in result
@pytest.mark.asyncio()
async def test_observe_frame_empty_question(self, dispatcher: SearchToolDispatcher) -> None:
"""空 question 应返回错误文本。"""
result = await dispatcher.dispatch(
"observe_frame",
{"node_ids": ["vid_L1_000_L2_000_L3_000"], "question": " "},
context={},
)
assert "question 不能为空" in result
class TestDispatchSubmitAnswer:
"""dispatch submit_answer 工具测试。"""
@pytest.mark.asyncio()
async def test_submit_answer_returns_confirmation(
self, dispatcher: SearchToolDispatcher
) -> None:
"""submit_answer 应返回确认文本。"""
result = await dispatcher.dispatch(
"submit_answer",
{"answer": "B", "evidence": "seen in frame", "reasoning": "clear visual"},
context={},
)
assert result == "[ok] 答案已提交: B"
class TestDispatchReadSkill:
"""dispatch read_skill 工具测试。"""
@pytest.mark.asyncio()
async def test_read_skill_returns_body(self, dispatcher: SearchToolDispatcher) -> None:
"""read_skill 应返回去除 frontmatter 后的技能正文。"""
result = await dispatcher.dispatch(
"read_skill",
{"name": "test_skill"},
context={},
)
assert "skill body content" in result
@pytest.mark.asyncio()
async def test_read_skill_disabled(self, dispatcher_no_skills: SearchToolDispatcher) -> None:
"""skills=None 时 read_skill 应返回未启用提示。"""
result = await dispatcher_no_skills.dispatch(
"read_skill",
{"name": "anything"},
context={},
)
assert result == "错误: skills 未启用"
# ── 错误处理测试 ──────────────────────────────────────────────────────
class TestDispatchErrors:
"""dispatch 错误处理测试。"""
@pytest.mark.asyncio()
async def test_unknown_tool_raises_value_error(self, dispatcher: SearchToolDispatcher) -> None:
"""未知工具应抛出 ValueError。"""
with pytest.raises(ValueError, match="未知工具: nonexistent_tool"):
await dispatcher.dispatch("nonexistent_tool", {}, context={})
@pytest.mark.asyncio()
async def test_node_not_found_returns_error_text(
self, dispatcher: SearchToolDispatcher
) -> None:
"""节点不存在时应返回错误文本(非异常)。"""
result = await dispatcher.dispatch(
"view_node",
{"node_id": "nonexistent_node", "question": "test"},
context={},
)
assert "工具执行错误" in result
assert "nonexistent_node" in result
@pytest.mark.asyncio()
async def test_read_skill_not_found_returns_error_text(
self, dispatcher: SearchToolDispatcher
) -> None:
"""未注册的技能名应返回错误文本。"""
result = await dispatcher.dispatch(
"read_skill",
{"name": "nonexistent_skill"},
context={},
)
assert "工具执行错误" in result