Files
Cyrene_v2/plugins/tools/browser/tool.go
T
AskaEth 2d211c2d16 feat(backthink,browser): 自排工具面 C1-3 + 浏览器两处偏离收口(X2+X3 同文件一并落)
X2 C1-3(自排工具面与提示词):
- request_think(thinktool.go,照 transcode/tool.go 范式)+ reason 必填/urgency 闭集 + 双守门
  (安全闸 + owner 判定:群聊/陌生人一律拒)
- **成对**进两张唤醒态允许集(quiet + awake;CAPACITY_TOOL_SETS 是整体替换)
- 提示词抽共用段落 + 自排前缀优先(不枚举工具清单);面板加 self_wake 标签与筛选轴
- 4 个变异自检(拿掉 owner 判定/只加一张表/放宽 urgency/前端标签恒等)

X3 收口(与 X2 共用 wire_services.go ⇒ 一并落):
- 删掉第三份 SSRF 实现(guard.go),改由 io.ValidateRequestURL 唯一判定 + 薄门面(保两个错误身份)
- 浏览器会话挂进 a.close(真机:真 Chrome 被关、无残留、生产未扰动)
- a.wireBrowser() 归位到 wire_runtime.go,删掉 ioToolOptions 的临时挂点
- 5 个变异自检

顺带补提交 C1-2 漏掉的 internal/config/runtimeconfig/backthink_test.go(ed50053 漏 add)。
2026-10-06 21:16:23 +08:00

222 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package browser 是 `browser` 工具(design/28 B2 的**自研薄层**)。
//
// 它只补本项目独有的那一层:把「本机真 Chromium 有头」的**导航 / 截图 / 指纹自检**
// 暴露给模型,并把截图按 0600 落盘成一个 **vision/image-crop 能直接读的本地路径** ——
// 那才是「截图进上下文」的闭环入口(design/28 §1 M4、§5)。
//
// 为什么只有三个动作(AGENTS §9.5):`click` / `type` / `snapshot` / `extract` /
// `wait` 这一整套成熟工具面**优先复用 MCP**(Playwright MCP / Puppeteer MCP 官方
// server,`design/09 §1`:MCP server = 外部工具提供者,工具自动注册进 registry)。
// 本项目**不重造**一遍等价物;本包只补 MCP 给不了的两件事 ——
// ① 宿主真 Chromium 的默认有头 + 持久 profile 启动(过风控三件套);
// ② 截图落盘 → 经既有 image/vision 管线进上下文的闭环。
//
// 权限(design/28 §4 第 2 条):本工具**默认 owner-only** —— 与其它能力一样,
// 注册时带的是零值 ToolPolicy(registry 的口径:两布尔皆 false = 只有主人环境可调),
// 群聊/陌生人不因它放宽。
package browser
import (
"context"
"fmt"
"io"
"log/slog"
"strings"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 工具名与动作名(模型看到的那一面)。名字一旦漂移,`cmd/cyrene` 的接线测试会红。
const (
// ToolName 是工具在能力注册表里的名字。
ToolName = "browser"
// actionOpen / actionScreenshot / actionInfo 是三个动作。
actionOpen = "open"
actionScreenshot = "screenshot"
actionInfo = "info"
)
// logSource 是本包的日志来源(AGENTS §10:禁止无来源裸日志)。
const logSource = "plugins/tools/browser"
// Compile-time assertion that Tool implements sdk.Tool.
var _ sdk.Tool = (*Tool)(nil)
// Tool 是 `browser` 工具。它只依赖驱动层的 Manager(进程内依赖注入,不起第二套
// 浏览器栈),因此本包可单测(假 Manager 接口)。
type Tool struct {
mgr Driver
log *slog.Logger
}
// Driver 是 Tool 需要的最小驱动面(*browsercap.Manager 实现它)。用接口而不是具体
// 类型,是为了让工具层能在**不装浏览器**的机器上被单测覆盖。
type Driver interface {
Available() bool
Source() string
Open(ctx context.Context, rawURL string) (browsercap.PageResult, error)
Screenshot(ctx context.Context) (browsercap.Shot, error)
Fingerprint(ctx context.Context) (browsercap.Fingerprint, error)
}
// New 构造工具;mgr 为 nil 时工具**仍可构造**但每个动作都如实报「不可用」
// (组合根的正确做法是压根不注册它,见 wire_browser.go)。
func New(mgr Driver, opts ...Option) *Tool {
t := &Tool{mgr: mgr, log: discardLog()}
for _, opt := range opts {
if opt != nil {
opt(t)
}
}
return t
}
// discardLog 返回一个「丢弃输出但**带来源标签**」的 logger:New(nil) 或 WithLogger(nil)
// 都不该让 t.log 变成 nil(AGENTS §10 禁止无来源裸日志,也不该在 nil 上 panic)。
func discardLog() *slog.Logger {
return logger.WithSource(slog.New(slog.NewTextHandler(io.Discard, nil)), logSource)
}
// Option 配置 Tool。
type Option func(*Tool)
// WithLogger 注入基日志(本包会补 `plugins/tools/browser` 来源标签)。
func WithLogger(l *slog.Logger) Option {
if l == nil {
return func(t *Tool) { t.log = discardLog() }
}
return func(t *Tool) { t.log = logger.WithSource(l, logSource) }
}
// Name 实现 sdk.Tool。
func (t *Tool) Name() string { return ToolName }
// Close 释放驱动占用的资源(本机浏览器进程)。驱动实现 io.Closer 时生效,否则空操作。
//
// **生产调用者**(切片 X3 接上):组合根的关闭链 `a.close`(`wire_services.go`)按工具名
// 从能力面取回本工具并关闭 —— 它持有进程外资源(我们起的真 Chromium 子进程、CDP 连接、
// profile 锁),进程退出不会带走它(Windows 上父进程死不会杀子进程),因此必须显式关。
//
// 驱动层另有两道兜底,覆盖「Close 没被调用 / 关闭失败」的情形:① 空闲回收时会发
// `Browser.close` 优雅关停;② 下一次会话**认领**同一个 profile 上还活着的实例
// (不再起第二个、也不会被 profile 锁卡死)。
func (t *Tool) Close() error {
if t == nil {
return nil
}
if c, ok := t.mgr.(io.Closer); ok {
return c.Close()
}
return nil
}
// Description 实现 sdk.Tool。**能力边界写在这里**:模型只读这段文字,所以「哪些
// 动作没做、 richer 动作去 MCP」必须说清楚,不能让模型以为它有 click/type。
func (t *Tool) Description() string {
return "Drive a real headful Chromium on this host and bring a page into the conversation. " +
"Actions: " +
"'open' navigates to an http(s) URL and returns the page's own title/url/readyState; " +
"'screenshot' captures the current viewport, saves it to disk (0600) and returns its path — " +
"pass that path to the vision or image-crop tool to actually read the page, which is how a " +
"rendered chart/QR/login page becomes visible to a text-only model; " +
"'info' returns the browser fingerprint (headless, navigator.webdriver, screen, timezone, language) " +
"for verifying that a headful session really is headful. " +
"Rich interaction (click/type/wait/extract/network) is NOT implemented here on purpose: " +
"configure a Playwright or Puppeteer MCP server and use its tools for that."
}
// Parameters 实现 sdk.Tool。
func (t *Tool) Parameters() (map[string]any, error) {
return map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"enum": []any{actionOpen, actionScreenshot, actionInfo},
"description": "Which browser action to run.",
},
"url": map[string]any{
"type": "string",
"description": "Absolute http(s) URL. Required for action=open, ignored otherwise.",
},
},
"required": []any{"action"},
}, nil
}
// Execute 实现 sdk.Tool。
func (t *Tool) Execute(ctx context.Context, args any) (any, error) {
m, ok := args.(map[string]any)
if !ok {
return nil, fmt.Errorf("browser: args must be an object")
}
action := strings.ToLower(strings.TrimSpace(argString(m, "action")))
if t.mgr == nil || !t.mgr.Available() {
return nil, fmt.Errorf("browser: 本机没有可用的浏览器来源(未探测到 Chrome/Chromium,也没有在跑的 CDP 端点)")
}
switch action {
case actionOpen:
return t.open(ctx, m)
case actionScreenshot:
return t.screenshot(ctx)
case actionInfo:
return t.info(ctx)
default:
return nil, fmt.Errorf("browser: 未知 action %q(可用:%s / %s / %s)", action,
actionOpen, actionScreenshot, actionInfo)
}
}
// open 处理 action=open。
func (t *Tool) open(ctx context.Context, args map[string]any) (any, error) {
rawURL := strings.TrimSpace(argString(args, "url"))
if rawURL == "" {
return nil, fmt.Errorf("browser: action=open 需要 url")
}
res, err := t.mgr.Open(ctx, rawURL)
if err != nil {
return nil, err
}
return res, nil
}
// screenshot 处理 action=screenshot:返回落盘路径与统计,「路径交 vision 读」这一步
// 由模型按描述自行发起(本层不替它调模型,也不把图塞进上下文)。
func (t *Tool) screenshot(ctx context.Context) (any, error) {
shot, err := t.mgr.Screenshot(ctx)
if err != nil {
return nil, err
}
return map[string]any{
"path": shot.Path,
"bytes": shot.Bytes,
"sha256": shot.SHA256,
"format": shot.Format,
"width": shot.Width,
"height": shot.Height,
"dimensionsKnown": shot.DimensionsKnown,
"degraded": shot.Degraded,
"hint": "Pass this path to the vision or image-crop tool to read the page content; the image is not inlined into the conversation.",
}, nil
}
// info 处理 action=info。
func (t *Tool) info(ctx context.Context) (any, error) {
fp, err := t.mgr.Fingerprint(ctx)
if err != nil {
return nil, err
}
return fp, nil
}
// argString 取一个字符串参数(缺失/类型不符一律当空串,与 io 包的同名口径一致)。
func argString(m map[string]any, key string) string {
if raw, ok := m[key].(string); ok {
return raw
}
return ""
}