Forráskód Böngészése

feat(tools): self-contained CLI entries for toolhub and librarian

Enable cross-framework usage: the same tool file can be registered via
@tool inside our own Agent framework, and invoked from the shell or by
other LLM tools (Claude Code etc.) via `python -m ... <cmd>`.

toolhub.py changes:
- Add `if __name__ == "__main__"` CLI with health/search/call subcommands
- Demote image_uploader/image_downloader from @tool to internal functions —
  toolhub_call already handles local path upload + CDN download internally,
  so these are redundant and just add LLM cognitive load
- toolhub_call docstring now emphasizes local file paths as the primary
  interface; saved_files (local paths) are surfaced before cdn_urls in the
  output dict
- Support image_urls / reference_image / reference_images parameter names
  in _preprocess_params (nano_banana uses image_urls, not images)
- Structured error messages: every except branch includes type(e).__name__
  so httpx timeouts no longer report empty error strings
- CLI output unwraps double-JSON encoding when toolhub_call's output is
  already a JSON string
- trace_id three-tier fallback: CLI arg > TRACE_ID env var > auto-generated,
  so `export TRACE_ID=session-xxx` ties all CLI calls into one image output
  directory

librarian.py changes:
- Mirror CLI structure with ask/upload subcommands
- Default KNOWHUB_API corrected to http://43.106.118.91:9999
- Same trace_id fallback — enables Librarian Agent context reuse across
  multiple CLI ask calls

.gitignore: add .mcp.json (contains API keys) to avoid committing
MCP server configs for Claude Code browser-use integration
Talegorithm 3 napja
szülő
commit
26905481ab

+ 2 - 1
.gitignore

@@ -1,5 +1,6 @@
 # API-KEY
 .env
+.mcp.json
 
 # Python
 __pycache__/
@@ -82,4 +83,4 @@ knowhub/milvus_data/
 vendor/browser-use/
 
 # im-client data
-data/
+data/.mcp.json

+ 2 - 3
agent/tools/builtin/__init__.py

@@ -20,7 +20,7 @@ from agent.tools.builtin.search import search_posts, get_search_suggestions
 from agent.tools.builtin.knowledge import(knowledge_search,knowledge_save,knowledge_list,knowledge_update,knowledge_batch_update,knowledge_slim)
 from agent.tools.builtin.librarian import ask_knowledge, upload_knowledge
 from agent.tools.builtin.context import get_current_context
-from agent.tools.builtin.toolhub import toolhub_health, toolhub_search, toolhub_call, image_uploader, image_downloader
+from agent.tools.builtin.toolhub import toolhub_health, toolhub_search, toolhub_call
 from agent.tools.builtin.resource import resource_list_tools, resource_get_tool
 from agent.tools.builtin.crawler import youtube_search, youtube_detail, x_search, import_content, extract_video_clip
 from agent.trace.goal_tool import goal
@@ -62,8 +62,7 @@ __all__ = [
     "toolhub_health",
     "toolhub_search",
     "toolhub_call",
-    "image_uploader",
-    "image_downloader",
+    # image_uploader / image_downloader 已内化到 toolhub_call 的图片管线中,不再单独暴露
     # 资源查询
     "resource_list_tools",
     "resource_get_tool",

+ 51 - 1
agent/tools/builtin/librarian.py

@@ -17,7 +17,7 @@ from agent.tools import tool, ToolResult, ToolContext
 
 logger = logging.getLogger(__name__)
 
-KNOWHUB_API = os.getenv("KNOWHUB_API", "http://localhost:8000").rstrip("/")
+KNOWHUB_API = os.getenv("KNOWHUB_API", "http://43.106.118.91:9999").rstrip("/")
 
 
 @tool(
@@ -178,3 +178,53 @@ async def upload_knowledge(
             output=f"错误: {str(e)}",
             error=str(e)
         )
+
+
+if __name__ == "__main__":
+    import sys
+    import asyncio
+
+    COMMANDS = {
+        "ask": ask_knowledge,
+        "upload": upload_knowledge,
+    }
+
+    def _parse_args(argv):
+        kwargs = {}
+        for arg in argv:
+            if arg.startswith("--") and "=" in arg:
+                k, v = arg.split("=", 1)
+                k = k.lstrip("-").replace("-", "_")
+                try:
+                    import json as _json
+                    v = _json.loads(v)
+                except (json.JSONDecodeError, ValueError):
+                    pass
+                kwargs[k] = v
+        return kwargs
+
+    if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
+        print(f"用法: python {sys.argv[0]} <command> [--key=value ...]")
+        print(f"可用命令: {', '.join(COMMANDS.keys())}")
+        print(f"示例: python {sys.argv[0]} ask --query='ControlNet 相关的工具'")
+        sys.exit(0)
+
+    cmd = sys.argv[1]
+    if cmd not in COMMANDS:
+        print(f"未知命令: {cmd},可用: {', '.join(COMMANDS.keys())}")
+        sys.exit(1)
+
+    kwargs = _parse_args(sys.argv[2:])
+
+    # trace_id:CLI 参数 > 环境变量 > 自动生成
+    if "trace_id" not in kwargs:
+        import uuid
+        kwargs["trace_id"] = os.getenv("TRACE_ID", f"cli-{uuid.uuid4().hex[:8]}")
+
+    result = asyncio.run(COMMANDS[cmd](**kwargs))
+    out = {"trace_id": kwargs.get("trace_id", ""), "output": result.output}
+    if result.error:
+        out["error"] = result.error
+    if result.metadata:
+        out["metadata"] = result.metadata
+    print(json.dumps(out, ensure_ascii=False, indent=2))

+ 219 - 111
agent/tools/builtin/toolhub.py

@@ -7,11 +7,20 @@ ToolHub - 远程工具库集成模块
 2. toolhub_search   - 搜索/发现远程工具(GET /tools)
 3. toolhub_call     - 调用远程工具(POST /run_tool)
 
+图片参数统一使用本地文件路径:
+  - 输入:params 中的 image/image_url 等参数直接传本地路径,内部自动上传
+  - 输出:生成的图片自动保存到 outputs/ 目录,返回本地路径
+
 实际 API 端点(通过 /openapi.json 确认):
   GET  /health      → 健康检查
   GET  /tools       → 列出所有工具(含分组、参数 schema)
   POST /run_tool    → 调用工具 {"tool_id": str, "params": dict}
   POST /chat        → 对话接口(不在此封装)
+
+CLI 用法:
+  python -m agent.tools.builtin.toolhub health
+  python -m agent.tools.builtin.toolhub search --keyword=image
+  python -m agent.tools.builtin.toolhub call --tool_id=flux_gen --params='{"prompt":"a cat"}'
 """
 
 import base64
@@ -19,6 +28,7 @@ import contextvars
 import json
 import logging
 import mimetypes
+import os
 import time
 from pathlib import Path
 from typing import Any, Dict, List, Optional
@@ -159,62 +169,77 @@ async def _process_images(raw_images: List[str], tool_id: str) -> tuple:
     return images_for_llm, cdn_urls, saved_paths
 
 
+_SINGLE_IMAGE_PARAMS = ("image", "image_url", "mask_image", "pose_image", "reference_image")
+_ARRAY_IMAGE_PARAMS = ("images", "image_urls", "reference_images")
+
+
+async def _maybe_upload_local(val: str) -> Optional[str]:
+    """如果 val 是存在的本地文件路径,上传 OSS 并返回 CDN URL;否则返回 None。"""
+    if not isinstance(val, str):
+        return None
+    if val.startswith(("http://", "https://", "data:")):
+        return None
+    try:
+        p = Path(val)
+        if p.exists() and p.is_file():
+            return await _upload_to_oss(str(p.resolve()))
+    except Exception as e:
+        logger.warning(f"[ToolHub] 本地路径处理失败 {val}: {e}")
+    return None
+
+
 async def _preprocess_params(params: Dict[str, Any]) -> Dict[str, Any]:
     """
     预处理工具参数:检测本地文件路径,自动上传到 OSS 并替换为 CDN URL。
 
-    支持的参数名:image, image_url, mask_image, pose_image, images (数组)
+    支持的单值参数:image, image_url, mask_image, pose_image, reference_image
+    支持的数组参数:images, image_urls, reference_images
+
+    设计要点:远程工具服务的 cwd 和调用方不一样,相对路径在服务器上会找不到文件。
+    所以必须在客户端就把本地路径转成 CDN URL,不能期望服务器侧有 fallback。
     """
     if not params:
         return params
 
     processed = params.copy()
 
-    # 单个图片参数
-    for key in ("image", "image_url", "mask_image", "pose_image"):
+    # 单图片参数
+    for key in _SINGLE_IMAGE_PARAMS:
         if key in processed and isinstance(processed[key], str):
             val = processed[key]
-            # 检测是否为本地路径(不是 http/https/data: 开头)
-            if not val.startswith(("http://", "https://", "data:")):
-                # 尝试读取本地文件
-                try:
-                    from pathlib import Path
-                    p = Path(val)
-                    if p.exists() and p.is_file():
-                        logger.info(f"[ToolHub] 检测到本地文件 {key}={val},上传到 OSS...")
-                        cdn_url = await _upload_to_oss(str(p.resolve()))
-                        if cdn_url:
-                            processed[key] = cdn_url
-                            logger.info(f"[ToolHub] {key} 已替换为 CDN URL: {cdn_url}")
-                        else:
-                            logger.warning(f"[ToolHub] {key} 上传失败,保持原路径")
-                except Exception as e:
-                    logger.warning(f"[ToolHub] {key} 路径处理失败: {e}")
-
-    # images 数组参数
-    if "images" in processed and isinstance(processed["images"], list):
-        new_images = []
-        for idx, img in enumerate(processed["images"]):
-            if isinstance(img, str) and not img.startswith(("http://", "https://", "data:")):
-                try:
-                    from pathlib import Path
-                    p = Path(img)
-                    if p.exists() and p.is_file():
-                        logger.info(f"[ToolHub] 检测到本地文件 images[{idx}]={img},上传到 OSS...")
-                        cdn_url = await _upload_to_oss(str(p.resolve()))
-                        if cdn_url:
-                            new_images.append(cdn_url)
-                            logger.info(f"[ToolHub] images[{idx}] 已替换为 CDN URL: {cdn_url}")
-                        else:
-                            new_images.append(img)
-                    else:
-                        new_images.append(img)
-                except Exception as e:
-                    logger.warning(f"[ToolHub] images[{idx}] 路径处理失败: {e}")
-                    new_images.append(img)
+            if val.startswith(("http://", "https://", "data:")):
+                continue
+            cdn_url = await _maybe_upload_local(val)
+            if cdn_url:
+                processed[key] = cdn_url
+                logger.info(f"[ToolHub] {key} 本地路径已替换为 CDN: {cdn_url}")
+            elif not os.path.isfile(val):
+                # 既不是远程 URL 也不是已存在的本地文件,直接报错比让远程服务抛神秘的 base64 错误强
+                logger.warning(f"[ToolHub] {key}={val!r} 既不是 URL 也不是存在的本地文件")
+
+    # 数组型图片参数
+    for array_key in _ARRAY_IMAGE_PARAMS:
+        if array_key not in processed or not isinstance(processed[array_key], list):
+            continue
+        new_list = []
+        for idx, item in enumerate(processed[array_key]):
+            if not isinstance(item, str):
+                new_list.append(item)
+                continue
+            if item.startswith(("http://", "https://", "data:")):
+                new_list.append(item)
+                continue
+            cdn_url = await _maybe_upload_local(item)
+            if cdn_url:
+                new_list.append(cdn_url)
+                logger.info(f"[ToolHub] {array_key}[{idx}] 本地路径已替换为 CDN: {cdn_url}")
             else:
-                new_images.append(img)
-        processed["images"] = new_images
+                new_list.append(item)
+                if not os.path.isfile(item):
+                    logger.warning(
+                        f"[ToolHub] {array_key}[{idx}]={item!r} 既不是 URL 也不是存在的本地文件"
+                    )
+        processed[array_key] = new_list
 
     return processed
 
@@ -254,10 +279,11 @@ async def toolhub_health() -> ToolResult:
             error=f"无法连接到 ToolHub 服务 {TOOLHUB_BASE_URL},请确认服务已启动。",
         )
     except Exception as e:
+        err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
         return ToolResult(
             title="ToolHub 健康检查",
             output="",
-            error=str(e),
+            error=err_msg,
         )
 
 
@@ -292,16 +318,46 @@ async def toolhub_search(keyword: Optional[str] = None) -> ToolResult:
         tools = data.get("tools", [])
         groups = data.get("groups", [])
 
-        # 客户端关键词过滤
+        # 客户端关键词过滤:三层匹配策略
+        # 1) 原始子串匹配(最严格,但会被分隔符切断:nanobanana vs nano_banana)
+        # 2) 归一化子串匹配(去掉 _ - 空格 .,解决分隔符问题)
+        # 3) 分词交集匹配(keyword 拆成 token,任意 token 命中即保留,解决多词查询)
         if keyword:
-            kw = keyword.lower()
-            tools = [
-                t for t in tools
-                if kw in t.get("name", "").lower()
-                or kw in t.get("description", "").lower()
-                or kw in t.get("tool_id", "").lower()
-                or kw in t.get("category", "").lower()
-            ]
+            def _normalize(s: str) -> str:
+                """去掉分隔符和空白,全小写"""
+                return "".join(c for c in s.lower() if c.isalnum())
+
+            def _tokenize(s: str) -> set:
+                """按分隔符拆成 token 集合"""
+                import re
+                return {t for t in re.split(r"[\s_\-.,/]+", s.lower()) if t}
+
+            kw_raw = keyword.lower()
+            kw_norm = _normalize(keyword)
+            kw_tokens = _tokenize(keyword)
+
+            def _matches(t: dict) -> bool:
+                fields = [
+                    t.get("name", ""),
+                    t.get("description", ""),
+                    t.get("tool_id", ""),
+                    t.get("category", ""),
+                ]
+                combined = " ".join(fields).lower()
+                # 原始子串
+                if kw_raw in combined:
+                    return True
+                # 归一化子串(容忍分隔符差异)
+                if kw_norm and kw_norm in _normalize(combined):
+                    return True
+                # token 交集(多词关键词的 OR 匹配)
+                if kw_tokens:
+                    field_tokens = _tokenize(combined)
+                    if kw_tokens & field_tokens:
+                        return True
+                return False
+
+            tools = [t for t in tools if _matches(t)]
 
         total = len(tools)
 
@@ -337,9 +393,16 @@ async def toolhub_search(keyword: Optional[str] = None) -> ToolResult:
 
             summaries.append(tool_block)
 
-        # 分组使用说明
+        # 分组使用说明:仅显示搜出的工具实际所属的分组,避免噪音
+        relevant_group_ids = set()
+        for t in tools:
+            for gid in t.get("group_ids", []) or []:
+                relevant_group_ids.add(gid)
+
         group_summary = []
         for g in groups:
+            if g["group_id"] not in relevant_group_ids:
+                continue
             group_summary.append(
                 f"[组: {g['group_id']}] {g['name']}\n"
                 f"  调用顺序: {' → '.join(g.get('usage_order', []))}\n"
@@ -360,11 +423,26 @@ async def toolhub_search(keyword: Optional[str] = None) -> ToolResult:
                 + ("..." if total > 15 else "")
             ),
         )
+    except (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.WriteTimeout, httpx.PoolTimeout) as e:
+        return ToolResult(
+            title="ToolHub /tools 超时",
+            output="",
+            error=f"ToolHub 的 /tools 接口在 {DEFAULT_TIMEOUT:.0f}s 内未响应({type(e).__name__})。"
+                  f"服务器可能在检测各工具状态导致列表慢,请稍后重试或联系维护者。",
+        )
+    except httpx.ConnectError as e:
+        return ToolResult(
+            title="ToolHub 连接失败",
+            output="",
+            error=f"无法连接到 ToolHub 服务 {TOOLHUB_BASE_URL}:{type(e).__name__}: {e}",
+        )
     except Exception as e:
+        # 注意 httpx 的部分异常 str(e) 是空的,必须带 type name
+        err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
         return ToolResult(
             title="搜索 ToolHub 工具失败",
             output="",
-            error=str(e),
+            error=err_msg,
         )
 
 
@@ -389,20 +467,20 @@ async def toolhub_call(
     通过 tool_id 调用 ToolHub 工具库中的某个工具,传入该工具所需的参数。
     不同工具的参数不同,请先用 toolhub_search 查询目标工具的参数说明。
 
+    图片参数(image、image_url、mask_image、pose_image、images)直接传本地文件路径即可,
+    系统会自动上传。生成的图片会自动保存到本地 outputs/ 目录,返回结果中的
+    saved_files 字段包含本地文件路径。
+
     注意:部分工具为异步生命周期(如 RunComfy、即梦、FLUX),需要按分组顺序
     依次调用多个工具(如先 launch → 再 executor → 再 stop)。
 
-    参数通过 params 字典传入,键名和类型需与工具定义一致。
-    例如调用图片拼接工具:
-        tool_id="image_stitcher"
-        params={"images": [...], "direction": "grid", "columns": 2}
-
     Args:
         tool_id: 要调用的工具 ID(从 toolhub_search 获取)
-        params: 工具参数字典,键值对根据目标工具的参数定义决定
+        params: 工具参数字典,键值对根据目标工具的参数定义决定。
+                图片参数可直接使用本地文件路径(如 "/path/to/image.png")。
 
     Returns:
-        ToolResult 包含工具执行结果
+        ToolResult 包含工具执行结果,图片结果通过 saved_files 返回本地路径
     """
     try:
         # 预处理参数:本地文件路径自动上传成 CDN URL
@@ -443,17 +521,13 @@ async def toolhub_call(
                 if raw_images:
                     images, cdn_urls, saved_paths = await _process_images(raw_images, tool_id)
 
-                    # 构建文本输出(去掉原始图片数据)
+                    # 构建文本输出(去掉原始图片数据,以本地路径为主
                     result_display = {k: v for k, v in result.items() if k not in ("image", "images")}
-                    if cdn_urls:
-                        result_display["cdn_urls"] = cdn_urls
-                        result_display["_note"] = (
-                            "图片已上传至 CDN(永久链接),可通过 cdn_urls 访问、传给其他工具或下载保存。"
-                            "同时也作为附件附加在本条消息中可直接查看。"
-                        )
+                    result_display["image_count"] = len(images)
                     if saved_paths:
                         result_display["saved_files"] = saved_paths
-                    result_display["image_count"] = len(images)
+                    if cdn_urls:
+                        result_display["cdn_urls"] = cdn_urls
                     result_str = json.dumps(result_display, ensure_ascii=False, indent=2)
 
             return ToolResult(
@@ -469,41 +543,29 @@ async def toolhub_call(
                 output=json.dumps(data, ensure_ascii=False, indent=2),
                 error=error_msg,
             )
-    except httpx.TimeoutException:
+    except httpx.TimeoutException as e:
         return ToolResult(
             title=f"ToolHub [{tool_id}] 调用超时",
             output="",
-            error=f"调用工具 {tool_id} 超时({CALL_TIMEOUT:.0f}s),图像生成类工具可能需要更长时间。",
+            error=f"调用工具 {tool_id} 超时({CALL_TIMEOUT:.0f}s,{type(e).__name__}),"
+                  f"图像生成类工具可能需要更长时间。",
         )
     except Exception as e:
+        err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
         return ToolResult(
             title=f"ToolHub [{tool_id}] 调用失败",
             output="",
-            error=str(e),
+            error=err_msg,
         )
 
 
-@tool(
-    display={
-        "zh": {"name": "上传本地图片", "params": {"local_path": "本地文件路径"}},
-        "en": {"name": "Upload Local Image", "params": {"local_path": "Local file path"}},
-    }
-)
-async def image_uploader(local_path: str) -> ToolResult:
-    """将本地图片上传到 OSS,返回可用的 CDN URL(image_url)
+# 注意:image_uploader 和 image_downloader 不再注册为 Agent 工具。
+# toolhub_call 已内置完整的图片管线(输入自动上传,输出自动下载保存),
+# 无需单独暴露上传/下载工具。以下函数保留供内部或 CLI 使用。
 
-    当你需要获取一张本地图片的 HTTP 链接时使用此工具。
-    传入本地文件路径,自动上传到 OSS 并返回永久 CDN URL。
 
-    注意:在调用 toolhub_call 时,image/image_url 等参数可以直接传本地路径,
-    系统会自动上传。此工具适用于你需要单独获取图片 URL 的场景。
-
-    Args:
-        local_path: 本地图片文件路径(相对路径或绝对路径均可)
-
-    Returns:
-        ToolResult 包含上传后的 CDN URL
-    """
+async def image_uploader(local_path: str) -> ToolResult:
+    """将本地图片上传到 OSS,返回可用的 CDN URL(内部工具,不注册给 Agent)"""
     import os
     from pathlib import Path
 
@@ -541,26 +603,8 @@ async def image_uploader(local_path: str) -> ToolResult:
         )
 
 
-@tool(
-    display={
-        "zh": {"name": "下载图片到本地", "params": {"url": "图片URL", "save_path": "保存路径"}},
-        "en": {"name": "Download Image", "params": {"url": "Image URL", "save_path": "Save path"}},
-    }
-)
 async def image_downloader(url: str, save_path: str = "") -> ToolResult:
-    """下载网络图片到本地文件
-
-    从 HTTP/HTTPS 链接下载图片并保存到本地。
-    适用于需要将 CDN 图片、生成结果等保存到本地目录的场景。
-
-    Args:
-        url: 图片的 HTTP/HTTPS 链接
-        save_path: 本地保存路径(相对或绝对路径均可)。
-                   如不指定,自动保存到当前输出目录,文件名从 URL 提取。
-
-    Returns:
-        ToolResult 包含下载后的本地文件路径和文件大小
-    """
+    """下载网络图片到本地文件(内部工具,不注册给 Agent)"""
     import os
     from pathlib import Path
     from urllib.parse import urlparse, unquote
@@ -615,3 +659,67 @@ async def image_downloader(url: str, save_path: str = "") -> ToolResult:
             output="",
             error=f"下载失败: {e}",
         )
+
+
+if __name__ == "__main__":
+    import sys
+
+    COMMANDS = {
+        "health": toolhub_health,
+        "search": toolhub_search,
+        "call": toolhub_call,
+    }
+
+    def _parse_args(argv):
+        kwargs = {}
+        for arg in argv:
+            if arg.startswith("--") and "=" in arg:
+                k, v = arg.split("=", 1)
+                k = k.lstrip("-").replace("-", "_")
+                try:
+                    v = json.loads(v)
+                except (json.JSONDecodeError, ValueError):
+                    pass
+                kwargs[k] = v
+        return kwargs
+
+    if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
+        print(f"用法: python {sys.argv[0]} <command> [--key=value ...]")
+        print(f"可用命令: {', '.join(COMMANDS.keys())}")
+        sys.exit(0)
+
+    cmd = sys.argv[1]
+    if cmd not in COMMANDS:
+        print(f"未知命令: {cmd},可用: {', '.join(COMMANDS.keys())}")
+        sys.exit(1)
+
+    import asyncio
+    import uuid
+    import os
+
+    kwargs = _parse_args(sys.argv[2:])
+
+    # trace_id:CLI 参数 > 环境变量 > 自动生成(用于图片输出目录)
+    trace_id = kwargs.pop("trace_id", None) or os.getenv("TRACE_ID") or f"cli-{uuid.uuid4().hex[:8]}"
+    set_trace_context(trace_id)
+
+    result = asyncio.run(COMMANDS[cmd](**kwargs))
+
+    # 修复双重 JSON 编码:如果 output 已经是一段 JSON 字符串(toolhub_call 内部
+    # 把 result dict 做过 json.dumps),解析回原生 dict 再嵌入 CLI 的最终 JSON,
+    # 避免调用方拿到"output 字段是被字符串化的 JSON"这种反人类形式。
+    output_value = result.output
+    if isinstance(output_value, str):
+        stripped = output_value.lstrip()
+        if stripped.startswith("{") or stripped.startswith("["):
+            try:
+                output_value = json.loads(output_value)
+            except (json.JSONDecodeError, ValueError):
+                pass  # 非 JSON 文本,保持原样
+
+    out = {"trace_id": trace_id, "output": output_value}
+    if result.error:
+        out["error"] = result.error
+    if result.metadata:
+        out["metadata"] = result.metadata
+    print(json.dumps(out, ensure_ascii=False, indent=2))