File size: 5,637 Bytes
fbd9d3d e0ea7df e5e756a e0ea7df fbd9d3d 0d8512e e0ea7df fbd9d3d 0d8512e e0ea7df 0d8512e e0ea7df 0d8512e e0ea7df fbd9d3d 0d8512e e0ea7df fbd9d3d e0ea7df 0d8512e e0ea7df 0d8512e e0ea7df fbd9d3d 0d8512e cc826a1 e0ea7df 0d8512e e0ea7df 0d8512e e0ea7df 0d8512e e0ea7df 0d8512e e0ea7df 0d8512e e0ea7df 0d8512e e0ea7df 0d8512e e0ea7df 0d8512e e5e756a e0ea7df e5e756a 0d8512e e0ea7df 0d8512e e5e756a e0ea7df e5e756a e0ea7df cc826a1 e0ea7df e5e756a e0ea7df cc826a1 e0ea7df cc826a1 e0ea7df | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 | """
浏览器MCP插件 MCP 工具定义(v3.0 纯 Lightpanda 透传)。
28 个工具,每个由工厂从 tools_meta 生成,统一入参 params: dict 透传给 Lightpanda
上游,平台层不定义任何浏览器语义:
- 工具名/参数/返回/错误码均由 Lightpanda 上游决定。
- 平台只负责:run_log 埋点、调用透传、错误码归一为 BrowserError。
- 单例长驻子进程,复用会话,支持 goto→click→extract 链式操作。
"""
import logging
from typing import Any, Callable, Dict, Optional
from app.mcp.decorators import mcp_tool
from app.plugins.run_log import get_run_log_service, EventLevel, RunStatus
from .browser.client import LightpandaClient, DEFAULT_TOOL_TIMEOUT
from .browser.errors import BrowserError, BrowserErrorCode
from .browser.tools_meta import TOOLS_META, ToolMeta
logger = logging.getLogger(__name__)
def _annotations(meta: ToolMeta) -> Dict[str, Any]:
"""从上游谓词推导 MCP annotations,让 agent 知道工具是否只读。"""
return {
"readOnlyHint": meta.read_only,
"destructiveHint": meta.destructive,
}
def _summarize_args(args: Optional[dict]) -> str:
"""生成参数摘要用于 run_log(截断长值,避免日志爆炸)。"""
if not args:
return "{}"
parts = []
for k, v in args.items():
s = v if isinstance(v, str) else repr(v)
if len(s) > 80:
s = s[:77] + "..."
parts.append(f"{k}={s}")
return ", ".join(parts)
async def _run_with_log(tool: str, params: Optional[dict]) -> Dict[str, Any]:
"""统一执行包装:create_run → 透传调用 → finish_run。
返回 {success, run_id, tool, result/error_code}。
"""
run_service = get_run_log_service()
run = run_service.create_run("browser")
run_service.add_event(run.run_id, "init", f"调用工具: {tool}")
args = params or {}
run_service.add_event(run.run_id, "call", f"{tool}({_summarize_args(args)})")
try:
text = await LightpandaClient.instance().call(tool, args, timeout=DEFAULT_TOOL_TIMEOUT)
except BrowserError as e:
_safe_event(run_service, run.run_id, "error", f"{e.code.value}: {e}", level=EventLevel.ERROR)
_safe_finish(run_service, run.run_id, RunStatus.FAILED, error=str(e))
return {
"success": False,
"run_id": run.run_id,
"tool": tool,
"error": str(e),
"error_code": e.code.value,
}
except Exception as e:
# 未归一的异常兜底
_safe_event(run_service, run.run_id, "error", f"未捕获: {e}", level=EventLevel.ERROR)
_safe_finish(run_service, run.run_id, RunStatus.FAILED, error=str(e))
return {
"success": False,
"run_id": run.run_id,
"tool": tool,
"error": str(e),
"error_code": BrowserErrorCode.TOOL_ERROR.value,
}
_safe_event(run_service, run.run_id, "complete", f"{tool} 完成")
result = {
"success": True,
"run_id": run.run_id,
"tool": tool,
"result": text,
}
# run_log 是辅助观测,持久化失败不应拖垮工具调用的正常返回
_safe_finish(run_service, run.run_id, RunStatus.SUCCEEDED, result=result)
return result
def _safe_event(run_service, run_id: str, stage: str, message: str, level: EventLevel = EventLevel.INFO) -> None:
"""记录 run_log 事件,失败仅记日志不抛(不阻断主链路)。"""
try:
run_service.add_event(run_id=run_id, stage=stage, message=message, level=level)
except Exception as e:
logger.warning("run_log add_event 失败(忽略): %s", e)
def _safe_finish(run_service, run_id: str, status: RunStatus, result=None, error=None) -> None:
"""结束 run,失败仅记日志不抛(不阻断主链路)。"""
try:
run_service.finish_run(run_id, status=status, result=result, error=error)
except Exception as e:
logger.warning("run_log finish_run 失败(忽略): %s", e)
def _make_tool(meta: ToolMeta) -> Callable:
"""为单个工具元数据生成一个 @mcp_tool 装饰的透传函数。
入参统一 params: dict,透传给上游,校验由 Lightpanda 负责。
"""
# 命名:{插件名}-{上游工具名},保留上游 camelCase
tool_name = f"browser-{meta.name}"
@mcp_tool(
name=tool_name,
title=meta.title,
description=meta.description,
annotations=_annotations(meta),
risk_level="low" if meta.read_only else "medium",
)
async def _tool(params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""透传调用 Lightpanda 上游工具。params 为该工具的入参字典。"""
return await _run_with_log(meta.name, params)
# 函数名与 doc 影响可读性;description 已由装饰器记录
_tool.__name__ = f"browser_{meta.name}"
_tool.__doc__ = meta.description
return _tool
# 模块级生成 28 个工具函数,供 plugin_registry._scan_tools 扫描注册。
# globals() 注入使 dir(module) 能发现带 _mcp_tool_def 的属性。
for _meta in TOOLS_META:
globals()[f"browser_{_meta.name}"] = _make_tool(_meta)
def list_tool_defs() -> list[dict]:
"""列出本模块所有已注册工具的 _mcp_tool_def(供 api.py / 测试用)。"""
import sys
mod = sys.modules[__name__]
defs = []
for attr_name in dir(mod):
attr = getattr(mod, attr_name, None)
if callable(attr) and hasattr(attr, "_mcp_tool_def"):
defs.append(attr._mcp_tool_def)
return defs
|