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