// 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 // outbox 是「本回合待送出的截图」附件箱(B3 闭环的浏览器侧,见 attach.go)。 // 它一定非 nil(New 里默认建一个):nil 箱子会让「截图静默不进上下文」变成 // 一个只能靠读代码才看得出的行为,而它恰恰是本片要修掉的缺口。 outbox *ShotOutbox } // 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(), outbox: NewShotOutbox()} for _, opt := range opts { if opt != nil { opt(t) } } if t.outbox == nil { // WithShotOutbox(nil) 是「显式关掉附件」的写法,但工具面必须能解释自己: // 出一个可用的箱子、由附件状态字段如实报 disabled 由**不带箱**的路径给出, // 因此这里只兜住 nil(构造期 invariants 一处收紧,动作里就不必到处判空)。 t.outbox = NewShotOutbox() } 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) } } // WithShotOutbox 换用给定的截图附件箱(B3 闭环的浏览器侧,见 attach.go)。 // // 传入 nil 当作「不换」:箱子是**必有**的(循环的装配钩子按工具名取回它,见 // `cmd/cyrene/wire_browser_context.go`),把它做成可选只会让「截图到底进不进上下文」 // 取决于装配顺序,而不是取决于设计。 func WithShotOutbox(o *ShotOutbox) Option { if o == nil { return func(*Tool) {} } return func(t *Tool) { t.outbox = o } } // ShotOutbox 返回本工具的附件箱。循环的装配钩子据此取走本回合待送出的截图 // (取走即消费,见 ShotOutbox.Drain)。永不为 nil。 func (t *Tool) ShotOutbox() *ShotOutbox { if t == nil { return nil } return t.outbox } // State 返回面板要的**只读事实快照**(design/28 §8 B4;形状见 // `internal/capability/browser/state.go`)。它**不启动浏览器、不发请求**: // 面板的只读区块连 viewer 都能看,让「看一眼」产生进程外副作用是越界。 // // 驱动不支持这一面(假驱动 / 未来的别的实现)时返回零值快照 —— 面板据此如实显示 // 「未知」,而不是编一组数字。 func (t *Tool) State() browsercap.StateSnapshot { if t == nil || t.mgr == nil { return browsercap.StateSnapshot{} } src, ok := t.mgr.(interface { State() browsercap.StateSnapshot }) if !ok { return browsercap.StateSnapshot{} } return src.State() } // Manager 返回驱动层的浏览器管理器(`*browsercap.Manager`;驱动不是它时返回 nil)。 // // 它存在只为一件事:design/28 §1 M5 / §8 B5 的 web-fetch 降级档 —— 组合根要拿**当前** // 这把工具的驱动去渲染取正文(`Manager.RenderText`),所以这里给出驱动句柄,而不是让 // 工具面再多一个动作(模型不需要 `extract` 这个动作名,那件事由 web-fetch 自动做)。 // // 返回具体类型而不是 Driver:取正文是驱动层的能力、不在工具的三个动作里,也不该加进 // Driver 接口(那会逼每个假驱动实现一个它们不需要的方法)。单测夹具的假驱动不是 // `*browsercap.Manager` ⇒ 返回 nil,调用方据此如实报「没有可用来源」。 func (t *Tool) Manager() *browsercap.Manager { if t == nil { return nil } mgr, _ := t.mgr.(*browsercap.Manager) return mgr } // 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 attaches it to " + "THIS turn automatically — a sighted main model receives the image itself, a text-only " + "model receives a vision/OCR description — so you do not have to re-read it yourself; " + "'info' returns the browser fingerprint (headless, navigator.webdriver, screen, timezone, language) " + "for verifying that a headful session really is headful. " + "For a zoomed region of a large page use the image-crop tool on the returned path. " + "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:截图落盘(0600)后**把它挂进本回合的附件箱** // (B3 闭环:循环的下一次模型调用会把它作为 image 附件送出,见 attach.go),并返回 // 落盘路径与统计。 // // 为什么要「挂进箱子」而不是像 V5 那样只回路径:只回路径 = 把闭环交给模型自觉 // (它得记得再调一次 vision),那不是闭环。现在两条路都自动:主模型能收图 ⇒ 它看到 // 这张图;不能 ⇒ vision/OCR 工具的文字结果进上下文(design/28 §7 判据 2)。 func (t *Tool) screenshot(ctx context.Context) (any, error) { shot, err := t.mgr.Screenshot(ctx) if err != nil { return nil, err } status := t.outbox.PutForContext(ctx, shot) 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, "turnAttachment": status, "hint": shotHint(status), }, nil } // shotHint 是按附件状态给出的**如实**下一步提示(进模型上下文,因此措辞必须与事实一致: // queued 时说「已经作为图片附件送出」,其余档说「图没进上下文,按路径读」)。 func shotHint(status string) string { switch status { case TurnAttachmentQueued: return "This screenshot is queued as an attachment for the current turn and will be " + "in your context on the next step (as the image itself, or as a vision/OCR description " + "if your model cannot receive images). Use image-crop on the path above to read a " + "region at full detail." default: return "This screenshot is NOT attached to the conversation (" + status + "). " + "Pass the path to the vision, OCR or image-crop tool to read the page content." } } // 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 "" }