diff --git a/AGENTS.md b/AGENTS.md index 1cf75a0..9525427 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ This file is the operational contract for coding agents working in this reposito - `cargo run -- chat` — connect to gateway as CLI client (default `ws://127.0.0.1:19876/ws`) - `cargo run -- run "prompt"` — send one prompt through Gateway, print the terminal Turn, and exit; stdin, JSON, verbose progress, and timeout modes are available - `cargo run -- reload` — validate and gracefully reload a running Gateway's configuration +- `cargo run -- health [--json]` — check core, configuration-dependent, and optional runtime dependencies without starting Gateway - `docker compose up -d` — start the container with Gateway bound/published on `0.0.0.0:19876`; override `PICOBOT_GATEWAY_HOST`, `PICOBOT_PUBLISH_HOST`, or `PICOBOT_GATEWAY_PORT` as needed - WebUI — start Gateway, then open `http://127.0.0.1:19876/`; no separate frontend build is required - `cd webui && npm ci && npm run check && npm run build` — validate the Svelte WebUI independently (Node.js 20+); its local `dist/` is ignored @@ -69,7 +70,8 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del | `agent` | LLM call loop, tool execution, context compression, semantic Turn events | `AgentLoop`, `TurnEvent` | | `providers` | Native LLM streams normalized into text/reasoning/tool/usage chunks | `LLMProvider`, `ProviderChunk`, `create_provider()` | | `delivery` | Snapshot projection, latest-wins throttling, terminal retry, per-turn sink lifecycle | `DeliveryCoordinator`, `TurnDeliveryService`, `PresentationPolicy` | -| `tools` | Agent tools (bash, file ops, http, web, get_skill) | `ToolRegistry`, `Tool` trait | +| `tools` | Agent tools and external adapters (bash, files, HTTP, browser, health) | `ToolRegistry`, `Tool`, `ToolExecutionContext` | +| `health` | Shared read-only dependency diagnostics for CLI/tool/slash entry points | `HealthService`, `HealthReport` | | `skills` | Skills loading, management, and prompt building | `SkillsLoader`, `Skill` | | `storage` | SQLite persistence for sessions and messages | `Storage`, `SessionMeta`, `MessageMeta` | | `scheduler` | Cron-based job scheduling, next-run computation | `Scheduler`, `Schedule`, `next_run_for_schedule()` | @@ -102,6 +104,8 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del - **Providers** are pure HTTP clients; no bus/session/channel awareness - **Provider reasoning state** is private replay data: persist it, replay it only to the matching provider, and never expose it to clients, channels, or logs - **Tools** are executed by `AgentLoop`; they receive raw arguments and normally return text. Tools that produce model-consumable media use the structured `execute_with_media` side channel; model capability checks and provider content-block serialization stay outside tools +- **Stateful tools** receive `ToolExecutionContext`; browser automation maps each PicoBot dialog to an opaque agent-browser session, uses per-session serialization, and returns screenshots through structured media. Do not reintroduce Fantoccini, ChromeDriver, WebDriver, or model-controlled raw browser session IDs +- **Health diagnostics** are read-only and share `HealthService` across `picobot health`, the `health` tool, and `/health`; checks must not install/fix dependencies, call Provider APIs, or expose secrets ### Concurrency and Lifecycle Invariants diff --git a/Cargo.toml b/Cargo.toml index 1e09fa6..18201bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,7 +50,6 @@ http = "1" encoding_rs = "0.8" zstd = "0.13" tar = "0.4" -fantoccini = { version = "0.22", default-features = false, features = ["rustls-tls"] } portable-pty = "0.9" [dev-dependencies] diff --git a/Dockerfile b/Dockerfile index c277e8a..6e8a246 100644 --- a/Dockerfile +++ b/Dockerfile @@ -69,13 +69,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && ln -sf /usr/bin/fdfind /usr/local/bin/fd \ && rm -rf /var/lib/apt/lists/* -# Install Chromium and chromedriver for browser automation -# Debian's chromium package is real (not a snap shim like Ubuntu 24.04) +# Install Chromium plus the validated native agent-browser CLI. PicoBot talks +# to agent-browser over its JSON CLI contract; ChromeDriver/WebDriver is not used. +# Debian's chromium package is real (not a snap shim like Ubuntu 24.04). RUN apt-get update && apt-get install -y --no-install-recommends \ chromium \ - chromium-driver \ && ln -sf /usr/bin/chromium /usr/local/bin/chrome \ - && ln -sf /usr/bin/chromedriver /usr/local/bin/chromedriver \ + && npm install -g agent-browser@0.33.0 \ + && npm cache clean --force \ && rm -rf /var/lib/apt/lists/* # Create non-root user @@ -99,6 +100,7 @@ ENV HOME=/app # Environment variables for Chromium in containers ENV CHROME_BIN=/usr/bin/chromium +ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium ENV TMPDIR=/tmp ENTRYPOINT ["/usr/local/bin/picobot"] diff --git a/README.md b/README.md index 9ae3861..ee28a09 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,18 @@ printf '使用浏览器打开 example.com 并返回页面标题\n' | picobot run 连接本机回环地址时不需要人工配对:`run` 自动读取 `~/.picobot/web_admin_token`,Gateway 只有在真实 TCP 对端也是回环地址时才允许该凭据访问 `/ws`。每次调用使用独立的临时 chat scope,不会替换正在运行的 TUI 连接。连接远程 Gateway 时仍使用 `~/.picobot/tui_auth_token` 中已有的配对令牌。 -### 5.2 使用 WebUI +### 5.2 健康检查 + +启动 Gateway 前可检查 PicoBot 核心命令、已启用功能的依赖、stdio MCP 命令和浏览器运行环境: + +```bash +picobot health +picobot health --json +``` + +缺少核心或当前配置要求的依赖时退出码为 `1`;`rg` / `fd` 等有回退实现的加速项只会标记为 `DEGRADED`。运行中的 Gateway 也提供 `/health` 斜杠命令,Agent 可调用同名 `health` 工具,三者共享同一套只读检查逻辑。 + +### 5.3 使用 WebUI Gateway 启动后直接打开: @@ -298,6 +309,7 @@ Session ID 使用三段式: | `/info` | 查看当前 dialog 信息 | | `/dump` | 导出当前 dialog 为 Markdown | | `/mcp` | 查看 MCP 服务器和工具状态 | +| `/health` | 检查 PicoBot 运行依赖 | | `/stop` | 停止当前任务并清空队列 | | `/todo [done\|cancel]` | 查看、完成或取消当前 session 的任务计划 | | `/reload` | 校验并重新加载 Gateway 配置 | @@ -334,7 +346,8 @@ PicoBot 有两类记忆: | `chat_manager` | 查看渠道、会话和历史消息 | | `cron_add/list/remove/enable/disable/update` | 管理定时任务 | | `routine_maintenance` | 安全清理超过保留期的 Timeline,不删除 Knowledge | -| `browser` | 可选 WebDriver 浏览器自动化 | +| `health` | 检查核心、配置相关和可选运行依赖 | +| `browser` | 可选 agent-browser 浏览器自动化;每个 dialog 独立会话 | | MCP tools | 从配置的 MCP Server 动态发现并注册 | ### Skills @@ -377,7 +390,7 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低: | `memory.recall_limit` | `5`(当前运行时固定为 5) | | `memory.timeline_retention_days` | `90` | | `mcp.tool_timeout_secs` | `180` | -| `browser.enabled` | `false` | +| `browser.enabled` | `true` | | `channels.feishu.live_updates` | `false` | | `channels.feishu.live_update_interval_ms` | `500` | | `channels.feishu.require_mention` | `true` | @@ -388,6 +401,52 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低: 更完整的配置字段说明见 [resources/skills/about-picobot/references/config.md](resources/skills/about-picobot/references/config.md)。 +## agent-browser 安装与使用 + +PicoBot 不再使用 Fantoccini、ChromeDriver 或 WebDriver。上层仍暴露一个稳定的 `browser` 工具,底层通过 agent-browser `0.33.0` 的 JSON CLI 驱动原生 Rust daemon 和 Chrome CDP。先安装 CLI 与浏览器: + +```bash +# 推荐;npm 只负责安装预编译 CLI +npm install -g agent-browser@0.33.0 +agent-browser install + +# Linux 需要同时补齐系统库时 +agent-browser install --with-deps + +# 或直接通过 Rust 工具链安装 +cargo install agent-browser --version 0.33.0 --locked +agent-browser install + +# macOS 也可使用 Homebrew +brew install agent-browser +agent-browser install +``` + +已有 Chrome/Chromium 时可在配置中设置 `browser_executable_path`,或通过 `AGENT_BROWSER_EXECUTABLE_PATH` 指定。Docker 镜像已固定安装 agent-browser `0.33.0` 与 Debian Chromium,不包含 ChromeDriver。启用示例: + +```json +{ + "browser": { + "enabled": true, + "command": "agent-browser", + "headless": true, + "browser_executable_path": null, + "max_sessions": 4, + "idle_timeout_secs": 900, + "command_timeout_secs": 120, + "max_output_chars": 50000, + "content_boundaries": true, + "allowed_domains": [], + "allow_private_hosts": false, + "artifact_dir": "~/.picobot/media/browser" + } +} +``` + +浏览器工具默认启用;缺少 agent-browser 或 Chrome 不阻止 Gateway 启动,但实际调用会失败并给出安装提示,`picobot health` 也会提前报告。修改后建议先运行 health,再启动或重载 Gateway。旧的 `webdriver_url`、`chrome_path` 配置已删除,出现这两个字段时配置校验会明确失败。实际使用仍由 Agent 调用 `browser`:`open` → `snapshot` 获取 `@e1` 等引用 → `click` / `fill` / `type` → 页面变化后重新 `snapshot`。截图保存到受控产物目录并作为结构化图片返回,不再生成 Base64 工具文本。 + +详细开发分层、进程协议、并发/安全边界和故障语义见 [agent-browser 集成设计](docs/AGENT_BROWSER_INTEGRATION.md)。 + ## WebSocket API Gateway 暴露: @@ -474,7 +533,6 @@ docs/ 面向维护者和 Agent 的架构与开发文档 | `reqwest` | LLM 和 HTTP 客户端 | | `ratatui`, `crossterm`, `termimad` | 终端 UI | | `rmcp` | MCP 客户端 | -| `fantoccini` | 可选浏览器自动化 | | `cron`, `chrono-tz` | 定时任务 | | `jieba-rs` | 中文记忆检索分词 | | `zstd`, `tar` | 内置 Skill 打包和释放 | diff --git a/docs/AGENT_BROWSER_INTEGRATION.md b/docs/AGENT_BROWSER_INTEGRATION.md new file mode 100644 index 0000000..b924d28 --- /dev/null +++ b/docs/AGENT_BROWSER_INTEGRATION.md @@ -0,0 +1,185 @@ +# agent-browser 集成设计 + +本文描述 PicoBot 1.3.1 的浏览器工具实现。目标是在保持模型侧单一 `browser` 工具协议的同时,用 agent-browser 完全替代 Fantoccini、ChromeDriver 和 WebDriver,并让浏览器状态、并发、产物和健康检查服从 PicoBot 的 Session 生命周期。 + +## 1. 选择与边界 + +PicoBot 使用 agent-browser CLI 的 `--json` 协议,不直接链接其内部 crate,也不把 agent-browser MCP Server 原样暴露给模型。 + +原因: + +- agent-browser 是原生 Rust CLI + daemon,daemon 通过 Chrome CDP 驱动浏览器;CLI 进程很短,浏览器状态跨命令保存在 daemon 中。 +- CLI 是项目的稳定公开边界,PicoBot 不需要依赖 agent-browser 的内部 Rust 模块布局。 +- PicoBot 包装层可统一绑定 dialog、限制并发和输出、校验 URL、控制截图目录,并把图片接入现有 `ToolResultWithMedia`。 +- 直接暴露 MCP 会让 session ID、文件路径、输出规模和安全策略落到模型参数中,也难以自动绑定当前 PicoBot dialog。 + +这不是把浏览器逻辑重新实现一遍。元素定位、accessibility snapshot、页面交互、Chrome 启动、CDP 通信和 daemon 生命周期均由 agent-browser 负责;PicoBot 只负责编排和边界控制。 + +## 2. 分层 + +```text +AgentLoop + │ ToolExecutionContext(session_id, turn_id) + ▼ +BrowserTool 模型侧单一 browser schema + ▼ +BrowserManager dialog → opaque session;并发/空闲/产物 + ├─ security URL、DNS、私网与 allowlist 前置校验 + ├─ action browser action → CLI argv + └─ AgentBrowserRunner timeout、env、--json、错误与输出解析 + ▼ +agent-browser CLI → Rust daemon → Chrome/Chromium CDP +``` + +源文件: + +- `src/tools/browser/mod.rs`:工具 schema 与入口。 +- `src/tools/browser/action.rs`:严格参数解析和 argv 映射。 +- `src/tools/browser/manager.rs`:会话表、per-session mutex、空闲回收、截图媒体。 +- `src/tools/browser/runner.rs`:无 Shell 的子进程调用、硬超时、JSON/错误解析。 +- `src/tools/browser/security.rs`:导航策略。 +- `src/tools/traits.rs`:向有状态工具提供 `ToolExecutionContext`;其他工具沿用默认实现。 + +## 3. 会话与并发 + +`SessionManager` 为每次 AgentLoop 执行传入完整 PicoBot session ID。BrowserManager 第一次看到该 ID 时生成随机、不透明的 `picobot-` agent-browser session,模型不能选择或猜测底层 session。 + +- 同一个 dialog:所有浏览器 action 由该 session 的 mutex 串行,cookie、storage、历史和当前页面连续。 +- 不同 dialog:使用不同 agent-browser session,可并发执行。 +- 子 Agent:若显式获准使用 `browser`,沿用发起任务的 PicoBot session,因此与主 Agent 共享同一浏览器并受同一 mutex 保护。 +- Scheduler:使用 `cron:` 隔离,不与交互 dialog 混用。 +- `close`:先从 PicoBot 映射表移除,再调用 agent-browser close;重复关闭幂等。 +- 空闲回收:创建新会话前移除超过 `idle_timeout_secs` 的映射并尽力关闭底层 session。 +- 容量:达到 `max_sessions` 且没有可回收会话时明确失败,不静默复用别人的浏览器。 + +Gateway 配置重载会构造新的 ToolRegistry/BrowserManager;旧运行代按现有 drain 规则退出。agent-browser daemon 的空闲退出时间通过 `AGENT_BROWSER_IDLE_TIMEOUT_MS` 同步设置,避免遗留浏览器无限驻留。 + +## 4. Action 映射 + +| PicoBot action | agent-browser 命令 | +|---|---| +| `open` | `open ` | +| `snapshot` | `snapshot --interactive --compact [--depth N]` | +| `click` / `fill` / `type` | 同名命令;无 selector 的 type 使用 `keyboard type` | +| `get_text` / `get_title` / `get_url` | `get text/title/url` | +| `focus` / `wait` / `press` / `hover` / `scroll` | 对应原生命令 | +| `click_at` | `mouse move` + `mouse down left` + `mouse up left` | +| `screenshot` | `screenshot [--full] [--annotate]` | +| `close` | `close` | + +每次调用都使用 argv 数组直接启动进程,不经过 Shell。`fill` / `type` 的内容不会写入 PicoBot 日志;日志只记录 action 名和是否绑定 session。 + +Runner 固定传入 `--session`、`--json` 和明确的 headed 状态,并设置: + +- `AGENT_BROWSER_EXECUTABLE_PATH`(配置后) +- `AGENT_BROWSER_CONTENT_BOUNDARIES` +- `AGENT_BROWSER_MAX_OUTPUT` +- `AGENT_BROWSER_ALLOWED_DOMAINS`(非空时) +- `AGENT_BROWSER_IDLE_TIMEOUT_MS` + +非零退出码、JSON 中 `success=false`、无效 JSON和超时都转换为工具失败。stdout/stderr 在返回模型前有长度上限;页面类结果保留 agent-browser `_boundary` 元数据。 + +## 5. 截图与媒体 + +截图绝不返回 Base64。调用方可省略 `path` 自动生成文件名,也可提供单个 `.png` 文件名;绝对路径、目录分隔、`.` 和 `..` 均拒绝。实际文件始终位于 `browser.artifact_dir`。 + +命令成功后 PicoBot 再验证文件存在、是普通文件且非空,然后返回: + +```text +ToolResultWithMedia { + result: ToolResult { output: "Screenshot saved: ..." }, + media_refs: [MediaRef { media_type: "image", path: "..." }] +} +``` + +因此多模态 Provider 可在下一轮直接看到图片,历史中仍只保存短路径清单,不产生 Base64 上下文膨胀。 + +## 6. 安全模型 + +默认策略: + +- 只允许 `http://` 和 `https://`,拒绝 URL userinfo。 +- `allow_private_hosts=false` 时拒绝 localhost、`.local`、回环、私网、link-local、未指定和组播地址;域名会先解析 DNS,任一结果为私网即拒绝。 +- `allowed_domains` 非空时 PicoBot 先校验首个 URL,agent-browser 再对导航、重定向、子资源、WebSocket、EventSource、sendBeacon 和受支持 Chromium 的 WebRTC 实施域名边界。 +- 默认开启 content boundaries,并把页面文本限制为 50,000 字符。 +- 包装层没有 `eval`、上传、下载、cookie/storage 写入或任意 agent-browser 命令透传,模型只能使用 allowlisted action。 +- 截图有单独的产物目录,不能用来覆盖任意文件。 + +`allowed_domains=[]` 表示不启用 agent-browser 域名过滤,适合通用浏览;这不是 OS 网络沙箱。需要强隔离时,应同时设置明确域名表和容器/主机 egress 策略。允许私网浏览是显式配置,适合本地应用测试,但会扩大 SSRF 风险。 + +## 7. 安装 + +验证版本为 `0.33.0`: + +```bash +npm install -g agent-browser@0.33.0 +agent-browser install +``` + +Linux 自动补系统依赖: + +```bash +agent-browser install --with-deps +``` + +不使用 npm 时: + +```bash +cargo install agent-browser --version 0.33.0 --locked +agent-browser install +``` + +macOS 也可执行: + +```bash +brew install agent-browser +agent-browser install +``` + +`agent-browser install` 下载 Chrome for Testing。已有浏览器时设置: + +```json +"browser_executable_path": "/usr/bin/chromium" +``` + +或设置环境变量 `AGENT_BROWSER_EXECUTABLE_PATH`。agent-browser 的 daemon 与 CDP 路径不需要 Node.js;npm 安装方式只需要 npm 用来放置预编译 CLI。Dockerfile 安装 Debian Chromium、`agent-browser@0.33.0` 并设置 executable path。 + +## 8. 使用 + +1. 浏览器工具默认启用;从旧配置删除 `webdriver_url`、`chrome_path`,加入新的 browser 字段。缺少依赖不会阻止 Gateway 启动,只会让 health 和实际浏览器调用失败。 +2. 运行 `picobot health`;应看到 agent-browser CLI 版本和 offline quick doctor 通过。 +3. 启动或重载 Gateway。 +4. 对 Agent 说“使用浏览器打开 …”。模型的推荐动作序列是: + +```text +browser(open, url) +browser(snapshot, interactive_only=true, compact=true) +browser(click/fill/type, selector=@eN) +browser(snapshot) # 页面改变后刷新 refs +browser(screenshot, annotate=true) # 需要视觉上下文时 +browser(close) +``` + +agent-browser 的 `@e` 引用属于当前页面快照。导航、弹窗或 DOM 大幅变化后必须重新 snapshot,不能长期缓存旧引用。 + +## 9. Health 三入口 + +`src/health.rs` 的 `HealthService` 是唯一检查实现: + +- `picobot health [--json]`:本机运维入口;核心/配置必需项失败时退出 `1`。 +- `/health`:当前 Gateway 配置的聊天入口。 +- `health` Tool:Agent 可调用的只读入口,支持 `json=true`。 + +检查项包括 workspace、Bash、内容/文件搜索后端、可选 systemctl、配置中的 stdio MCP 命令,以及浏览器启用时的 agent-browser 版本、显式浏览器路径和 `doctor --offline --quick --json`。检查不安装软件、不执行 `doctor --fix`、不访问 Provider API,也不输出配置密钥。 + +## 10. 迁移和故障处理 + +- 配置使用 `deny_unknown_fields`;遗留 WebDriver 字段会在加载时失败,而不是被静默忽略。 +- `failed to start 'agent-browser'`:CLI 不在 PATH,或 `browser.command` 错误;运行 health。 +- doctor 失败:运行 `agent-browser doctor` 查看完整诊断,再安装浏览器/系统库。 +- session limit:关闭不再使用的 dialog 浏览器,或调整 `max_sessions`;不要让多个 dialog 共享同一底层 ID。 +- domain blocked:补充站点和必要 CDN 域名;不要用空白 allowlist 绕过生产隔离策略。 +- command timeout:确认页面/浏览器未卡死,再按部署风险调整 `command_timeout_secs`。 +- 截图不存在:视为工具失败,不构造失效 MediaRef。 + +Fantoccini crate、旧 `src/tools/browser.rs` WebDriver 实现、ChromeDriver Docker 包和相关配置已全部删除。Cargo 不链接 agent-browser;它是由 health 管理的外部运行依赖。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index eb84a80..b67eb39 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -18,13 +18,14 @@ PicoBot 是一个单进程、异步、可扩展的个人 AI 助手运行时。 ## 2. 运行模式与进程边界 -PicoBot 只有一个二进制,提供三种运行模式: +PicoBot 只有一个二进制,提供四种运行模式: | 模式 | 入口 | 职责 | |------|------|------| | Gateway | `cargo run -- gateway` | 组装服务、监听 HTTP/WebSocket、提供嵌入式 WebUI,运行渠道、会话、调度器和后台任务 | | CLI client | `cargo run -- chat` | 运行 Ratatui UI,通过 WebSocket 使用 Gateway,不持有业务状态 | | One-shot client | `cargo run -- run "prompt"` | 使用独立临时 chat scope 通过 WebSocket 提交一条消息,等待 Turn 终态,输出结果后退出 | +| Health diagnostic | `cargo run -- health [--json]` | 只读检查核心、配置相关和可选外部依赖,不启动 Gateway 或连接 Provider | Linux 上可通过 `picobot service install/start/stop/status/restart/uninstall` 管理 systemd 用户服务。unit 固定为 `picobot.service`,其主进程仍是普通 Gateway 模式,不引入额外 daemon/fork 层;异常退出由 systemd 按 `Restart=on-failure` 拉起。 @@ -74,6 +75,7 @@ flowchart LR | `providers` | 把统一请求映射为原生模型流,并归一化正文、reasoning、工具和 usage | Session、Bus 或 Channel 感知 | | `delivery` | 活动 Turn 快照投影、latest-wins 节流、终态重试和 TurnSink 生命周期 | Provider 协议、会话历史、平台 API 细节 | | `tools` / `mcp` | 工具定义、注册和执行适配 | 隐式修改会话路由 | +| `health` | 聚合只读依赖检查,供 CLI、Tool 与 slash command 复用 | 安装、修复或连接 Provider | | `storage` | SQLite schema、迁移、原子 CRUD | 运行时调度策略 | | `memory` | Knowledge/Timeline 的存取与召回 | 直接驱动消息发送 | | `scheduler` | 领取到期任务、执行普通/巡检 Agent、应用投递策略、原子记录结果 | 复用聊天会话历史、直接感知 Channel | @@ -193,7 +195,7 @@ Session ID 格式为: 当前 WebUI/TUI Turn 通过 `send_message(files=...)` 向自身 session 投递文件时,文件先进入 task-local Turn delivery 暂存区,成功结束后附加到最终 assistant 消息,与工具链一起原子提交;因此持久化和刷新后的顺序都是工具调用/结果在前、携带附件的最终回复在后,也不会生成带 `[message from ...]` 的自投递气泡。其他同 Turn 自投递仍是受控例外:只有 task-local Turn ID 仍匹配该 session 的 active Turn,写入才允许不递增 `state_version`。跨 Turn、跨 session 以及无法证明所有权的写入仍必须递增版本。Provider 回放历史附件时,只有 user 输入和当前工具结果可生成模型原生媒体块;assistant/system 附件只回放文本清单,避免把图片放到供应商不接受的角色。 -SessionManager 负责组装会话上下文:系统提示、Skills、召回的 Knowledge、压缩后的 Timeline、可选的 active plan 摘要和当前消息历史。`session::turn_input` 在 Session 锁外并行读取 Knowledge、active plan 并压缩历史,然后通过同一个 assembly 路径生成首次请求和 context-overflow 重试输入;重试不得复制系统提示或 runtime context 拼接逻辑。普通闲聊 session 没有 plan 摘要;计划状态由 `WorkManager` 从 SQLite 读取,因此不以自然语言摘要作为权威来源。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态。 +SessionManager 负责组装会话上下文:系统提示、Skills、召回的 Knowledge、压缩后的 Timeline、可选的 active plan 摘要和当前消息历史。`session::turn_input` 在 Session 锁外并行读取 Knowledge、active plan 并压缩历史,然后通过同一个 assembly 路径生成首次请求和 context-overflow 重试输入;重试不得复制系统提示或 runtime context 拼接逻辑。普通闲聊 session 没有 plan 摘要;计划状态由 `WorkManager` 从 SQLite 读取,因此不以自然语言摘要作为权威来源。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态。执行工具时额外传递只包含 session/turn 身份的 `ToolExecutionContext`;无状态工具使用默认实现忽略它,有状态外部适配器必须用它隔离资源,不能自行反向查询 SessionManager。 当前 Turn 的工具进度只从 `AgentLoop` 的结构化 `TurnEvent` 进入 `TurnController`,不能另建字符串 notification 通道重复投递。后台子 Agent 的 `TaskNotification` 表达跨 Turn 的任务完成,仍由独立的受监督消费者投递。自动标题属于非关键派生工作:Turn 持久化完成后由 `TaskSupervisor` 调度,Session worker 不等待模型生成;同一 Session 同时最多有一个标题任务,提交时仍校验标题保持默认值,避免覆盖用户改名。 @@ -251,6 +253,10 @@ WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输,WebSocket 只 工具默认通过 `ToolResult` 返回文本;需要把图片等产物交给模型时,通过 `Tool::execute_with_media` 返回文本和结构化 `MediaRef`。工具只负责经过自身路径策略校验后声明媒体,不感知当前模型或 Provider。`AgentLoop` 仅将最新连续工具结果批次的媒体交给 `MediaHandlerRegistry`,旧工具媒体只回放文本和路径,避免历史 Base64 膨胀。OpenAI-compatible Provider 保持 `tool` 结果为文本,并在完整工具批次后构造仅存在于请求内的临时多模态 `user` 消息;Anthropic Provider 将媒体放入对应 `tool_result.content`。媒体加载、格式或能力检查失败必须降级成文本,不得使历史记录不可读取。 +`browser` 是有状态工具适配器:`BrowserTool` 保持模型侧 action schema,`BrowserManager` 把 PicoBot dialog 映射到随机 agent-browser session,并用每 session mutex 保证同一页面串行、不同 dialog 并发;`AgentBrowserRunner` 以 argv 和 `--json` 调用外部原生 CLI,设置硬超时、输出/content boundaries/domain allowlist,底层 daemon 通过 Chrome CDP 工作。PicoBot 不链接 agent-browser 内部 crate、不直接暴露其 MCP、不使用 Fantoccini/ChromeDriver/WebDriver。截图只能写入配置的 artifact directory,并经 `ToolResultWithMedia` 返回。完整边界见 [AGENT_BROWSER_INTEGRATION.md](AGENT_BROWSER_INTEGRATION.md)。 + +`HealthService` 是依赖检查的唯一实现。CLI `picobot health`、只读 `health` 工具和 `/health` 斜杠命令必须复用它;检查可探测命令、版本、配置路径和 agent-browser offline quick doctor,但不能安装/修复软件、连接模型 API 或泄漏配置秘密。 + `AuthManager` 默认保护所有管理 API 和 `/ws`。静态资源、`/health`、`/api/auth/status` 与 `/api/auth/pair` 保持公开,使未配对浏览器只能加载配对界面。`picobot pair` 使用权限为 `0600` 的本机管理密钥调用仅接受真实回环连接的 `/api/auth/code`;反向代理即使从回环连接也无法在没有该密钥时签发代码。本机 `picobot run` 可用同一个管理密钥直接认证 `/ws`,但中间件必须同时验证请求路径严格等于 `/ws` 且 `ConnectInfo` 中的真实 TCP 对端为回环地址;这一身份不能访问管理 API。远程 `run` 与 TUI 一样使用已配对的 Bearer token。配对码为 8 位、5 分钟有效、单次消费,并按来源实施失败锁定。浏览器收到 HttpOnly、SameSite=Strict Cookie,CLI 使用 Bearer token;服务端仅持久化 SHA-256 哈希。`--revoke-all` 的持久化成功后才清空内存令牌,活动 WebSocket 每 5 秒复核身份并回收已撤销连接。 同源 `/api/*` 管理接口只提供显式白名单能力: @@ -304,6 +310,7 @@ WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输,WebSocket 只 3. 明确工具需要“workspace 默认目录”还是“不可逃逸的硬边界”;后者必须显式校验 canonical path,不能只依赖 cwd。 4. 网络工具必须保留 SSRF/私网地址校验。 5. 长操作应有超时;后台执行应交给 SubAgentManager/TaskSupervisor。 +6. 需要跨调用保存外部状态时,实现 `execute_with_context` 并按 session 隔离;不得让模型控制底层全局 session ID。 ### 新增 Provider diff --git a/resources/skills/about-picobot/assets/config.example.json b/resources/skills/about-picobot/assets/config.example.json index 51c0947..cf71937 100644 --- a/resources/skills/about-picobot/assets/config.example.json +++ b/resources/skills/about-picobot/assets/config.example.json @@ -85,10 +85,18 @@ "tool_timeout_secs": 180 }, "browser": { - "enabled": false, - "webdriver_url": "http://127.0.0.1:9515", + "enabled": true, + "command": "agent-browser", "headless": true, - "chrome_path": null + "browser_executable_path": null, + "max_sessions": 4, + "idle_timeout_secs": 900, + "command_timeout_secs": 120, + "max_output_chars": 50000, + "content_boundaries": true, + "allowed_domains": [], + "allow_private_hosts": false, + "artifact_dir": "~/.picobot/media/browser" }, "workspace_dir": "~/.picobot/workspace" } diff --git a/resources/skills/about-picobot/references/architecture.md b/resources/skills/about-picobot/references/architecture.md index 0d20200..582c639 100644 --- a/resources/skills/about-picobot/references/architecture.md +++ b/resources/skills/about-picobot/references/architecture.md @@ -32,6 +32,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message | `observability` | Observer 模式,agent/工具遥测事件 | | `protocol` | WebSocket 协议消息定义 | | `config` | 配置加载、环境变量替换、路径解析 | +| `health` | CLI、工具和斜杠命令共用的只读运行依赖检查 | | `memory` | 长期记忆存储与检索 | | `mcp` | MCP(Model Context Protocol)工具集成 | | `task_supervisor` | Gateway 后台任务注册、取消、限时等待和强制回收 | @@ -47,7 +48,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message - Providers 是纯 HTTP 流客户端,无 bus/session/channel 感知;签名 reasoning 状态只回放给匹配 Provider,不下发客户端或 Channel - DeliveryCoordinator 只投影完整快照,不修改会话历史;慢消费者跳过中间 revision,终态显式、有界投递 - 每个活动 Turn 独占一个 TurnSink;平台 message ID 和 reaction 清理状态只存在于 sink 内 -- Tools 接收原始参数,返回字符串结果 +- Tools 接收原始参数,通常返回字符串结果;有状态适配器额外接收 session/turn `ToolExecutionContext` - MCP 工具在 Gateway 初始化时连接服务器、发现工具,并包装成普通 Tool 注册到 ToolRegistry - 子 Agent 由 `delegate` 工具创建,复用 provider 配置和按需过滤后的工具集;后台任务结果通过 MessageBus 发回原会话 - 复杂任务可使用 `todo` 创建 session 级计划;多个子项可通过 `delegate.plan_item_id` 并行委托,子 Agent 不能修改计划 @@ -61,7 +62,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message - ChannelManager 持有 MessageBus 和所有 channel - OutboundDispatcher 通过 ChannelManager 路由出站消息 - 配置目录 `.env` 与 workspace `.env` 仅在单线程启动阶段分层加载,并使用 `unsafe { env::set_var(...) }` 写入进程环境;优先级为既有进程环境 > workspace > 配置目录 -- `browser` 工具只有在 `browser.enabled=true` 时注册,依赖 Chrome/Chromium 与 WebDriver +- `browser` 工具默认启用,只有 `browser.enabled=false` 时不注册;缺少 CLI/Chrome 不阻止 Gateway 启动,但 health 和实际调用会给出安装错误。每个 PicoBot dialog 映射到独立 agent-browser session,底层原生 daemon 使用 Chrome CDP,不依赖 Fantoccini/ChromeDriver/WebDriver - 同一 session 的普通消息串行处理,不同 session 可并发;session 队列容量为 32,满时明确拒绝 - 出站消息按 `(channel, chat_id)` 分 lane 保序;lane 容量为 64,慢目标不阻塞其他目标 - 活动 Turn 与普通出站消息共享 `(channel, chat_id)` 写锁;禁止把 token delta 放入 MessageBus @@ -296,4 +297,5 @@ Gateway 关停顺序: | `/dump` | 保存当前对话为 markdown | | `/?`, `/help` | 显示帮助 | | `/mcp` | 显示 MCP 状态 | +| `/health` | 检查 PicoBot 运行依赖 | | `/stop` | 停止当前任务并清空消息队列 | diff --git a/resources/skills/about-picobot/references/commands.md b/resources/skills/about-picobot/references/commands.md index c010a5a..d2ac2a6 100644 --- a/resources/skills/about-picobot/references/commands.md +++ b/resources/skills/about-picobot/references/commands.md @@ -7,6 +7,10 @@ cargo build # 启动网关 (默认 127.0.0.1:19876) cargo run -- gateway +# 检查核心、配置相关和可选运行依赖;结构化输出加 --json +picobot health +picobot health --json + # 覆盖监听地址和端口 cargo run -- gateway --host 0.0.0.0 --port 19876 @@ -42,6 +46,11 @@ cargo build cargo run -- chat --pair-code cargo run -- chat +# 浏览器工具依赖(PicoBot 验证版本) +npm install -g agent-browser@0.33.0 +agent-browser install +# Linux 缺少浏览器系统库时改用:agent-browser install --with-deps + # 安装并启动 Linux systemd 用户服务 picobot service install picobot service start diff --git a/resources/skills/about-picobot/references/config.md b/resources/skills/about-picobot/references/config.md index f01f29b..471bbf1 100644 --- a/resources/skills/about-picobot/references/config.md +++ b/resources/skills/about-picobot/references/config.md @@ -129,11 +129,32 @@ MCP 服务器单条配置: ## browser 字段 -浏览器工具默认关闭,开启后注册 `browser` 工具。依赖 Chrome/Chromium 与 chromedriver/WebDriver。 +浏览器工具默认开启并注册 `browser` 工具。缺少外部依赖不会阻止 Gateway 启动,但实际调用会返回安装错误,`picobot health` 会提前判定。上层由 PicoBot 管理 dialog 会话与媒体,底层调用 agent-browser JSON CLI;不再依赖 Fantoccini、ChromeDriver 或 WebDriver。 | 字段 | 类型 | 默认 | 说明 | |------|------|------|------| -| `enabled` | bool | false | 是否启用浏览器工具 | -| `webdriver_url` | string | http://127.0.0.1:9515 | WebDriver 服务地址 | +| `enabled` | bool | true | 是否启用浏览器工具;关闭后不注册 `browser` | +| `command` | string | agent-browser | CLI 名称或绝对路径 | | `headless` | bool | true | 是否无头运行 | -| `chrome_path` | string | - | 自定义 Chrome/Chromium 路径 | +| `browser_executable_path` | string | - | 自定义 Chrome/Chromium 可执行文件路径 | +| `max_sessions` | int | 4 | 同时保留的 dialog 浏览器会话上限 | +| `idle_timeout_secs` | int | 900 | PicoBot 会话清理及 agent-browser daemon 空闲退出时间 | +| `command_timeout_secs` | int | 120 | 单次 CLI 调用硬超时 | +| `max_output_chars` | int | 50000 | 页面来源文本输出上限 | +| `content_boundaries` | bool | true | 启用 agent-browser 不可信页面边界元数据 | +| `allowed_domains` | []string | [] | 可选域名白名单;空数组表示不启用域名限制 | +| `allow_private_hosts` | bool | false | 是否允许回环、私网和本地域名 | +| `artifact_dir` | string | ~/.picobot/media/browser | 截图产物目录 | + +旧字段 `webdriver_url`、`chrome_path` 不再接受。推荐安装 `agent-browser@0.33.0` 后运行 `agent-browser install`;Linux 可运行 `agent-browser install --with-deps`。使用前用 `picobot health` 检查 CLI 与 Chrome 环境。 + +### 浏览器依赖故障处置 + +| health / 工具错误 | 处置 | +|---|---| +| `agent-browser` 未找到 | 运行 `npm install -g agent-browser@0.33.0`,或 `cargo install agent-browser --version 0.33.0 --locked` | +| CLI 已安装但找不到 Chrome/Chromium | 运行 `agent-browser install`;已有浏览器则设置 `browser_executable_path` 或 `AGENT_BROWSER_EXECUTABLE_PATH` | +| Linux 缺少共享库/系统包 | 运行 `agent-browser install --with-deps`,然后再运行 `agent-browser doctor` | +| 安装状态不明确 | 先运行 `picobot health` 获取 PicoBot 视角的结果,再运行 `agent-browser doctor` 查看完整上游诊断 | + +不得在 Agent 工具调用中自动安装或执行 `doctor --fix`;安装会修改系统且可能需要管理员权限,应把命令报告给用户,由用户确认后执行。临时不需要浏览器时可设置 `browser.enabled=false`,此时 health 不要求 agent-browser/Chrome。 diff --git a/resources/skills/about-picobot/references/tools.md b/resources/skills/about-picobot/references/tools.md index f6ea7b9..8b44c18 100644 --- a/resources/skills/about-picobot/references/tools.md +++ b/resources/skills/about-picobot/references/tools.md @@ -157,7 +157,7 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅 ## browser — 浏览器自动化 -仅在 `browser.enabled=true` 时注册。底层使用 WebDriver/Chrome。 +默认注册;设置 `browser.enabled=false` 后不注册。PicoBot 上层包装统一 action 和结构化媒体,底层逐次调用 agent-browser `--json`;每个 PicoBot dialog 映射到一个不透明的 agent-browser session,同 dialog 串行、不同 dialog 可并发。CLI daemon 自动常驻并通过 Chrome CDP 工作,不使用 Fantoccini、ChromeDriver 或 WebDriver。 | action | 说明 | |--------|------| @@ -166,10 +166,18 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅 | `click`, `click_at` | 点击元素或坐标 | | `fill`, `type`, `press` | 输入文本或按键 | | `get_text`, `get_title`, `get_url` | 读取页面信息 | -| `screenshot` | 截图,可写入文件或返回 base64 | +| `screenshot` | 保存到 `browser.artifact_dir` 并返回结构化图片媒体;支持 `full_page`、`annotate` | | `focus`, `hover`, `scroll`, `wait` | 常见交互和等待 | | `close` | 关闭浏览器会话 | +典型流程:`open` → `snapshot` 获取 `@e` 引用 → 交互 → 页面变化后重新 `snapshot`。`path` 只接受 `.png` 文件名,不能逃逸产物目录。`open` 默认拒绝非 HTTP(S)、userinfo、回环、私网、本地域名及 DNS 解析到私网的地址;配置 `allowed_domains` 后,agent-browser 同时限制导航、子资源、WebSocket、EventSource 与 WebRTC。页面输出是不可信内容,默认开启 content boundary 元数据和 50,000 字符上限。 + +依赖缺失时必须把错误和处置命令返回给用户,不能声称已浏览,也不能在工具内部静默安装:CLI 不存在时安装 `agent-browser@0.33.0`;Chrome 不存在时运行 `agent-browser install`;Linux 共享库不完整时运行 `agent-browser install --with-deps`。用 `picobot health` 复查,再用 `agent-browser doctor` 获取详细上游诊断。用户明确不需要浏览器时才建议 `browser.enabled=false`。 + +## health — 依赖检查 + +无参数时返回可读报告;`json=true` 返回结构化报告。核心必需项、当前配置启用后必需的依赖、可选功能分别标记。该工具只读,与 CLI `picobot health [--json]` 和 `/health` 斜杠命令复用同一个 `HealthService`。 + --- ## MCP 工具 diff --git a/resources/templates/config.example.json b/resources/templates/config.example.json index 5103d6c..42aee2d 100644 --- a/resources/templates/config.example.json +++ b/resources/templates/config.example.json @@ -93,10 +93,18 @@ "tool_timeout_secs": 180 }, "browser": { - "enabled": false, - "webdriver_url": "http://127.0.0.1:9515", + "enabled": true, + "command": "agent-browser", "headless": true, - "chrome_path": null + "browser_executable_path": null, + "max_sessions": 4, + "idle_timeout_secs": 900, + "command_timeout_secs": 120, + "max_output_chars": 50000, + "content_boundaries": true, + "allowed_domains": [], + "allow_private_hosts": false, + "artifact_dir": "~/.picobot/media/browser" }, "workspace_dir": "~/.picobot/workspace" } diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index cc4f2bf..ceaaf8a 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -10,7 +10,7 @@ use crate::providers::{ ChatCompletionRequest, ChatCompletionResponse, LLMProvider, Message, ProviderChunk, ProviderResponseAccumulator, ToolCall, create_provider, }; -use crate::tools::ToolRegistry; +use crate::tools::{ToolExecutionContext, ToolRegistry}; use std::collections::VecDeque; use std::hash::{Hash, Hasher}; use std::path::PathBuf; @@ -628,7 +628,16 @@ impl AgentLoop { &self, messages: Vec, ) -> Result { - self.process_inner(messages, None).await + self.process_inner(messages, None, ToolExecutionContext::default()) + .await + } + + pub async fn process_with_context( + &self, + messages: Vec, + tool_context: ToolExecutionContext, + ) -> Result { + self.process_inner(messages, None, tool_context).await } pub async fn process_streaming( @@ -636,13 +645,27 @@ impl AgentLoop { messages: Vec, turn: AgentTurnContext, ) -> Result { - self.process_inner(messages, Some(turn)).await + let tool_context = ToolExecutionContext::default().with_turn_id(turn.turn_id.clone()); + self.process_inner(messages, Some(turn), tool_context).await + } + + pub async fn process_streaming_with_context( + &self, + messages: Vec, + turn: AgentTurnContext, + mut tool_context: ToolExecutionContext, + ) -> Result { + if tool_context.turn_id.is_none() { + tool_context.turn_id = Some(turn.turn_id.clone()); + } + self.process_inner(messages, Some(turn), tool_context).await } async fn process_inner( &self, mut messages: Vec, turn: Option, + tool_context: ToolExecutionContext, ) -> Result { let turn_start = Instant::now(); @@ -780,7 +803,12 @@ impl AgentLoop { // Execute tools and add results to messages let tool_results = self - .execute_tools(&response.tool_calls, iteration, turn.as_ref()) + .execute_tools( + &response.tool_calls, + iteration, + turn.as_ref(), + &tool_context, + ) .await?; for (tool_call, result) in response.tool_calls.iter().zip(tool_results.iter()) { @@ -964,14 +992,15 @@ impl AgentLoop { tool_calls: &[ToolCall], iteration: u32, turn: Option<&AgentTurnContext>, + context: &ToolExecutionContext, ) -> Result, AgentError> { if self.should_execute_in_parallel(tool_calls) { tracing::debug!("Executing {} tools in parallel", tool_calls.len()); - self.execute_tools_parallel(tool_calls, iteration, turn) + self.execute_tools_parallel(tool_calls, iteration, turn, context) .await } else { tracing::debug!("Executing {} tools sequentially", tool_calls.len()); - self.execute_tools_sequential(tool_calls, iteration, turn) + self.execute_tools_sequential(tool_calls, iteration, turn, context) .await } } @@ -982,10 +1011,11 @@ impl AgentLoop { tool_calls: &[ToolCall], iteration: u32, turn: Option<&AgentTurnContext>, + context: &ToolExecutionContext, ) -> Result, AgentError> { let futures: Vec<_> = tool_calls .iter() - .map(|tool_call| self.execute_one_tool(tool_call, iteration, turn)) + .map(|tool_call| self.execute_one_tool(tool_call, iteration, turn, context)) .collect(); futures_util::future::join_all(futures) @@ -1000,11 +1030,15 @@ impl AgentLoop { tool_calls: &[ToolCall], iteration: u32, turn: Option<&AgentTurnContext>, + context: &ToolExecutionContext, ) -> Result, AgentError> { let mut outcomes = Vec::with_capacity(tool_calls.len()); for tool_call in tool_calls { - outcomes.push(self.execute_one_tool(tool_call, iteration, turn).await?); + outcomes.push( + self.execute_one_tool(tool_call, iteration, turn, context) + .await?, + ); } Ok(outcomes) @@ -1016,6 +1050,7 @@ impl AgentLoop { tool_call: &ToolCall, iteration: u32, turn: Option<&AgentTurnContext>, + context: &ToolExecutionContext, ) -> Result { let start = Instant::now(); let tool_name = tool_call.name.clone(); @@ -1037,7 +1072,7 @@ impl AgentLoop { }); } - let result = self.execute_tool_internal(tool_call).await; + let result = self.execute_tool_internal(tool_call, context).await; let duration = start.elapsed(); if let Some(turn) = turn { @@ -1068,7 +1103,11 @@ impl AgentLoop { } /// Internal tool execution without event tracking. - async fn execute_tool_internal(&self, tool_call: &ToolCall) -> ToolExecutionOutcome { + async fn execute_tool_internal( + &self, + tool_call: &ToolCall, + context: &ToolExecutionContext, + ) -> ToolExecutionOutcome { let tool = match self.tools.get(&tool_call.name) { Some(t) => t, None => { @@ -1080,7 +1119,10 @@ impl AgentLoop { } }; - match tool.execute_with_media(tool_call.arguments.clone()).await { + match tool + .execute_with_context(context, tool_call.arguments.clone()) + .await + { Ok(result_with_media) => { let result = result_with_media.result; if result.success { diff --git a/src/agent/sub_agent.rs b/src/agent/sub_agent.rs index 2dc6a88..ededb50 100644 --- a/src/agent/sub_agent.rs +++ b/src/agent/sub_agent.rs @@ -14,7 +14,7 @@ use crate::bus::ChatMessage; use crate::config::LLMProviderConfig; use crate::providers::{LLMProvider, create_provider}; use crate::skills::SkillsLoader; -use crate::tools::ToolRegistry; +use crate::tools::{ToolExecutionContext, ToolRegistry}; tokio::task_local! { pub(crate) static DELEGATE_CONTEXT: DelegateContext; @@ -261,10 +261,22 @@ impl SubAgentManager { ]; let start = Instant::now(); + let browser_session_id = config + .session_id + .clone() + .or_else(|| { + get_delegate_context() + .ok() + .map(|context| context.session_id) + }) + .unwrap_or_else(|| format!("sub-agent:{task_id}")); let result = tokio::time::timeout( std::time::Duration::from_secs(timeout_secs), - agent.process(history), + agent.process_with_context( + history, + ToolExecutionContext::for_session(browser_session_id), + ), ) .await; @@ -490,7 +502,10 @@ impl SubAgentManager { tokio::select! { r = tokio::time::timeout( std::time::Duration::from_secs(timeout_secs), - agent.process(history), + agent.process_with_context( + history, + ToolExecutionContext::for_session(&sess_id), + ), ) => { match r { Ok(Ok(agent_result)) => { diff --git a/src/config/mod.rs b/src/config/mod.rs index a6fca23..d52ac9d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -450,32 +450,80 @@ fn default_mcp_tool_timeout_secs() -> u64 { } #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct BrowserConfig { - #[serde(default)] + #[serde(default = "default_true")] pub enabled: bool, - #[serde(default = "default_webdriver_url")] - pub webdriver_url: String, + #[serde(default = "default_agent_browser_command")] + pub command: String, #[serde(default = "default_true")] pub headless: bool, #[serde(default)] - pub chrome_path: Option, + pub browser_executable_path: Option, + #[serde(default = "default_browser_max_sessions")] + pub max_sessions: usize, + #[serde(default = "default_browser_idle_timeout_secs")] + pub idle_timeout_secs: u64, + #[serde(default = "default_browser_command_timeout_secs")] + pub command_timeout_secs: u64, + #[serde(default = "default_browser_max_output_chars")] + pub max_output_chars: usize, + #[serde(default = "default_true")] + pub content_boundaries: bool, + #[serde(default)] + pub allowed_domains: Vec, + #[serde(default)] + pub allow_private_hosts: bool, + #[serde(default = "default_browser_artifact_dir")] + pub artifact_dir: String, } -fn default_webdriver_url() -> String { - "http://127.0.0.1:9515".to_string() +fn default_agent_browser_command() -> String { + "agent-browser".to_string() } fn default_true() -> bool { true } +fn default_browser_max_sessions() -> usize { + 4 +} + +fn default_browser_idle_timeout_secs() -> u64 { + 15 * 60 +} + +fn default_browser_command_timeout_secs() -> u64 { + 120 +} + +fn default_browser_max_output_chars() -> usize { + 50_000 +} + +fn default_browser_artifact_dir() -> String { + get_user_config_dir() + .join("media/browser") + .to_string_lossy() + .to_string() +} + impl Default for BrowserConfig { fn default() -> Self { Self { - enabled: false, - webdriver_url: default_webdriver_url(), + enabled: true, + command: default_agent_browser_command(), headless: true, - chrome_path: None, + browser_executable_path: None, + max_sessions: default_browser_max_sessions(), + idle_timeout_secs: default_browser_idle_timeout_secs(), + command_timeout_secs: default_browser_command_timeout_secs(), + max_output_chars: default_browser_max_output_chars(), + content_boundaries: true, + allowed_domains: Vec::new(), + allow_private_hosts: false, + artifact_dir: default_browser_artifact_dir(), } } } @@ -955,6 +1003,9 @@ mod tests { config.gateway.file_transfer.max_file_bytes, 25 * 1024 * 1024 ); + assert!(config.browser.enabled); + let browser: BrowserConfig = serde_json::from_str("{}").unwrap(); + assert!(browser.enabled); } #[test] diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 00a4432..ce92081 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -167,6 +167,7 @@ impl GatewayState { } else { None }; + let health = Arc::new(crate::health::HealthService::new(config.clone())); // Create SessionManager with bus injection let session_manager = SessionManager::new( @@ -181,6 +182,7 @@ impl GatewayState { ) .with_admission(admission.clone()), browser_config, + health, config.gateway.max_concurrent_background_tasks, )?; let session_manager = Arc::new(session_manager); diff --git a/src/health.rs b/src/health.rs new file mode 100644 index 0000000..870e692 --- /dev/null +++ b/src/health.rs @@ -0,0 +1,545 @@ +use std::collections::HashSet; +use std::path::Path; +use std::process::Stdio; +use std::time::Duration; + +use serde::Serialize; +use tokio::process::Command; + +use crate::config::{Config, McpTransport, expand_path}; + +pub const SUPPORTED_AGENT_BROWSER_VERSION: &str = "0.33.0"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum HealthStatus { + Pass, + Warning, + Fail, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum HealthOverall { + Healthy, + Degraded, + Unhealthy, +} + +#[derive(Debug, Clone, Serialize)] +pub struct HealthCheck { + pub name: String, + pub category: String, + pub required: bool, + pub status: HealthStatus, + pub detail: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub remediation: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct HealthReport { + pub version: &'static str, + pub overall: HealthOverall, + pub checks: Vec, +} + +impl HealthReport { + pub fn configuration_error(error: impl Into) -> Self { + Self::from_checks(vec![HealthCheck { + name: "configuration".to_string(), + category: "core".to_string(), + required: true, + status: HealthStatus::Fail, + detail: error.into(), + remediation: Some( + "Fix ~/.picobot/config.json (or ./config.json) and run picobot health again." + .to_string(), + ), + }]) + } + + fn from_checks(checks: Vec) -> Self { + let overall = if checks + .iter() + .any(|check| check.required && check.status == HealthStatus::Fail) + { + HealthOverall::Unhealthy + } else if checks + .iter() + .any(|check| check.status != HealthStatus::Pass) + { + HealthOverall::Degraded + } else { + HealthOverall::Healthy + }; + Self { + version: env!("CARGO_PKG_VERSION"), + overall, + checks, + } + } + + pub fn is_usable(&self) -> bool { + self.overall != HealthOverall::Unhealthy + } + + pub fn render_text(&self) -> String { + let overall = match self.overall { + HealthOverall::Healthy => "HEALTHY", + HealthOverall::Degraded => "DEGRADED", + HealthOverall::Unhealthy => "UNHEALTHY", + }; + let mut lines = vec![format!("PicoBot {} health: {overall}", self.version)]; + for check in &self.checks { + let icon = match check.status { + HealthStatus::Pass => "✓", + HealthStatus::Warning => "!", + HealthStatus::Fail => "✗", + }; + let requirement = if check.required { + "required" + } else { + "optional" + }; + lines.push(format!( + "{icon} [{} / {requirement}] {} — {}", + check.category, check.name, check.detail + )); + if let Some(remediation) = &check.remediation { + lines.push(format!(" Fix: {remediation}")); + } + } + lines.join("\n") + } +} + +#[derive(Clone)] +pub struct HealthService { + config: Config, +} + +impl HealthService { + pub fn new(config: Config) -> Self { + Self { config } + } + + pub async fn check(&self) -> HealthReport { + let mut checks = vec![ + check_workspace(&self.config), + check_required_binary( + "bash", + "core", + "Install Bash and make it available on PATH.", + ), + check_search_backend("content search", &["rg", "grep"], "rg"), + check_search_backend("file search", &["fd", "fdfind", "find"], "fd"), + check_optional_binary("systemd service management", "systemctl", "service"), + ]; + checks.extend(self.check_mcp_commands()); + checks.extend(self.check_browser().await); + HealthReport::from_checks(checks) + } + + fn check_mcp_commands(&self) -> Vec { + let mut seen = HashSet::new(); + let mut checks = Vec::new(); + for server in &self.config.mcp.servers { + if !matches!(server.transport, McpTransport::Stdio) { + continue; + } + let Some(command) = server.command.as_deref() else { + checks.push(HealthCheck { + name: format!("MCP server {}", server.name), + category: "configured".to_string(), + required: true, + status: HealthStatus::Fail, + detail: "stdio server has no command".to_string(), + remediation: Some("Set mcp.servers[].command.".to_string()), + }); + continue; + }; + if !seen.insert(command.to_string()) { + continue; + } + let installed = command_exists(command); + checks.push(HealthCheck { + name: format!("MCP command {command}"), + category: "configured".to_string(), + required: true, + status: if installed { + HealthStatus::Pass + } else { + HealthStatus::Fail + }, + detail: if installed { + "installed".to_string() + } else { + "not found on PATH".to_string() + }, + remediation: (!installed).then(|| { + format!("Install '{command}' or set an absolute mcp.servers[].command path.") + }), + }); + } + if checks.is_empty() { + checks.push(HealthCheck { + name: "MCP stdio commands".to_string(), + category: "configured".to_string(), + required: false, + status: HealthStatus::Pass, + detail: "no stdio MCP servers configured".to_string(), + remediation: None, + }); + } + checks + } + + async fn check_browser(&self) -> Vec { + let browser = &self.config.browser; + if !browser.enabled { + return vec![HealthCheck { + name: "agent-browser".to_string(), + category: "configured".to_string(), + required: false, + status: HealthStatus::Pass, + detail: "browser tool disabled; dependency not required".to_string(), + remediation: None, + }]; + } + + let mut checks = Vec::new(); + if !command_exists(&browser.command) { + checks.push(HealthCheck { + name: "agent-browser CLI".to_string(), + category: "configured".to_string(), + required: true, + status: HealthStatus::Fail, + detail: format!("'{}' was not found", browser.command), + remediation: Some(format!( + "Run `npm install -g agent-browser@{SUPPORTED_AGENT_BROWSER_VERSION}` (or `cargo install agent-browser --version {SUPPORTED_AGENT_BROWSER_VERSION} --locked`), then `agent-browser install`." + )), + }); + return checks; + } + + let version = command_output( + &browser.command, + &["--version"], + None, + Duration::from_secs(5), + ) + .await; + match version { + Ok(version_output) => { + let version_number = extract_version(&version_output); + let exact = version_number.as_deref() == Some(SUPPORTED_AGENT_BROWSER_VERSION); + checks.push(HealthCheck { + name: "agent-browser CLI".to_string(), + category: "configured".to_string(), + required: true, + status: if exact { + HealthStatus::Pass + } else { + HealthStatus::Warning + }, + detail: format!( + "installed version {}; PicoBot is validated with {}", + version_number.unwrap_or_else(|| version_output.trim().to_string()), + SUPPORTED_AGENT_BROWSER_VERSION + ), + remediation: (!exact).then(|| { + format!( + "Install agent-browser@{SUPPORTED_AGENT_BROWSER_VERSION} for the validated CLI contract." + ) + }), + }); + } + Err(error) => checks.push(HealthCheck { + name: "agent-browser CLI".to_string(), + category: "configured".to_string(), + required: true, + status: HealthStatus::Fail, + detail: error, + remediation: Some("Reinstall agent-browser and verify it can execute.".to_string()), + }), + } + + if let Some(path) = browser.browser_executable_path.as_deref() { + let path = expand_path(path); + let path = if path.is_absolute() { + path + } else { + expand_path(&self.config.workspace_dir).join(path) + }; + let installed = path.is_file(); + checks.push(HealthCheck { + name: "configured browser executable".to_string(), + category: "configured".to_string(), + required: true, + status: if installed { + HealthStatus::Pass + } else { + HealthStatus::Fail + }, + detail: if installed { + format!("found at {}", path.display()) + } else { + format!("not found at {}", path.display()) + }, + remediation: (!installed).then(|| { + "Fix browser.browser_executable_path or run `agent-browser install`." + .to_string() + }), + }); + } + + let doctor_executable = browser + .browser_executable_path + .as_deref() + .map(expand_path) + .map(|path| { + if path.is_absolute() { + path + } else { + expand_path(&self.config.workspace_dir).join(path) + } + }) + .map(|path| path.to_string_lossy().into_owned()); + let doctor_env = doctor_executable + .as_deref() + .map(|path| ("AGENT_BROWSER_EXECUTABLE_PATH", path)); + let doctor = command_output( + &browser.command, + &["doctor", "--offline", "--quick", "--json"], + doctor_env, + Duration::from_secs(15), + ) + .await; + checks.push(match doctor { + Ok(output) => HealthCheck { + name: "agent-browser runtime".to_string(), + category: "configured".to_string(), + required: true, + status: HealthStatus::Pass, + detail: summarize_output(&output), + remediation: None, + }, + Err(error) => HealthCheck { + name: "agent-browser runtime".to_string(), + category: "configured".to_string(), + required: true, + status: HealthStatus::Fail, + detail: error, + remediation: Some( + "Run `agent-browser doctor`, then `agent-browser install --with-deps` on Linux or `agent-browser install` on other platforms." + .to_string(), + ), + }, + }); + checks + } +} + +fn check_workspace(config: &Config) -> HealthCheck { + let workspace = expand_path(&config.workspace_dir); + let exists = workspace.is_dir(); + HealthCheck { + name: "workspace".to_string(), + category: "core".to_string(), + required: true, + status: if exists { + HealthStatus::Pass + } else { + HealthStatus::Fail + }, + detail: if exists { + format!("{} is available", workspace.display()) + } else { + format!("{} does not exist", workspace.display()) + }, + remediation: (!exists) + .then(|| "Create the configured workspace directory or fix workspace_dir.".to_string()), + } +} + +fn check_required_binary(name: &str, category: &str, remediation: &str) -> HealthCheck { + let installed = command_exists(name); + HealthCheck { + name: name.to_string(), + category: category.to_string(), + required: true, + status: if installed { + HealthStatus::Pass + } else { + HealthStatus::Fail + }, + detail: if installed { + "installed".to_string() + } else { + "not found on PATH".to_string() + }, + remediation: (!installed).then(|| remediation.to_string()), + } +} + +fn check_search_backend(name: &str, candidates: &[&str], preferred: &str) -> HealthCheck { + let found = candidates.iter().copied().find(|name| command_exists(name)); + let (status, detail, remediation) = match found { + Some(found) if found == preferred => ( + HealthStatus::Pass, + format!("using preferred backend {found}"), + None, + ), + Some(found) => ( + HealthStatus::Warning, + format!("using fallback backend {found}"), + Some(format!("Install {preferred} for faster searches.")), + ), + None => ( + HealthStatus::Fail, + "no supported backend found".to_string(), + Some(format!("Install one of: {}.", candidates.join(", "))), + ), + }; + HealthCheck { + name: name.to_string(), + category: "core".to_string(), + required: true, + status, + detail, + remediation, + } +} + +fn check_optional_binary(name: &str, binary: &str, category: &str) -> HealthCheck { + let installed = command_exists(binary); + HealthCheck { + name: name.to_string(), + category: category.to_string(), + required: false, + status: HealthStatus::Pass, + detail: if installed { + format!("{binary} installed") + } else { + format!("{binary} not installed; feature remains unavailable") + }, + remediation: None, + } +} + +fn command_exists(command: &str) -> bool { + if command.contains(std::path::MAIN_SEPARATOR) { + Path::new(command).is_file() + } else { + which::which(command).is_ok() + } +} + +async fn command_output( + command: &str, + args: &[&str], + env: Option<(&str, &str)>, + timeout: Duration, +) -> Result { + let mut process = Command::new(command); + process + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + if let Some((key, value)) = env { + process.env(key, value); + } + let output = tokio::time::timeout(timeout, process.output()) + .await + .map_err(|_| format!("command timed out after {} seconds", timeout.as_secs()))? + .map_err(|error| format!("failed to start: {error}"))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + if !output.status.success() { + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + return Err(format!( + "exited with {}: {}", + output.status, + truncate(detail, 1_000) + )); + } + let combined = if stdout.trim().is_empty() { + stderr.trim() + } else { + stdout.trim() + }; + Ok(truncate(combined, 4_000)) +} + +fn extract_version(output: &str) -> Option { + output + .split_whitespace() + .map(|token| { + token + .trim_start_matches('v') + .trim_matches(|c: char| c == ',' || c == ';') + }) + .find(|token| { + let mut parts = token.split('.'); + parts.clone().count() >= 3 && parts.all(|part| part.chars().all(|c| c.is_ascii_digit())) + }) + .map(str::to_string) +} + +fn summarize_output(output: &str) -> String { + if let Ok(json) = serde_json::from_str::(output) + && let Some(summary) = json + .get("summary") + .and_then(serde_json::Value::as_str) + .or_else(|| json.get("message").and_then(serde_json::Value::as_str)) + { + return truncate(summary, 500); + } + let first_line = output + .lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or("ok"); + truncate(first_line, 500) +} + +fn truncate(value: &str, max: usize) -> String { + if value.len() <= max { + value.to_string() + } else { + format!("{}…", &value[..value.floor_char_boundary(max)]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn required_failure_makes_report_unhealthy() { + let report = HealthReport::from_checks(vec![HealthCheck { + name: "x".into(), + category: "core".into(), + required: true, + status: HealthStatus::Fail, + detail: "missing".into(), + remediation: None, + }]); + assert_eq!(report.overall, HealthOverall::Unhealthy); + assert!(!report.is_usable()); + } + + #[test] + fn extracts_agent_browser_version() { + assert_eq!( + extract_version("agent-browser 0.33.0"), + Some("0.33.0".to_string()) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 9412e40..8997cea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub mod client; pub mod config; pub mod delivery; pub mod gateway; +pub mod health; pub mod logging; pub mod mcp; pub mod memory; diff --git a/src/main.rs b/src/main.rs index 79d478f..175882a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,6 +62,12 @@ enum Command { #[arg(long)] gateway_url: Option, }, + /// Check PicoBot runtime and configured external dependencies + Health { + /// Print the structured report as JSON + #[arg(long)] + json: bool, + }, /// Generate a one-time browser pairing code from the local gateway Pair { /// Gateway WebSocket or HTTP URL @@ -136,6 +142,20 @@ async fn main() -> Result<(), Box> { .unwrap_or_else(|| "ws://127.0.0.1:19876/ws".to_string()); println!("{}", picobot::client::reload_gateway(&url).await?); } + Command::Health { json } => { + let report = match picobot::config::Config::load_default() { + Ok(config) => picobot::health::HealthService::new(config).check().await, + Err(error) => picobot::health::HealthReport::configuration_error(error.to_string()), + }; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("{}", report.render_text()); + } + if !report.is_usable() { + std::process::exit(1); + } + } Command::Pair { gateway_url, revoke_all, diff --git a/src/session/session.rs b/src/session/session.rs index 7bbbd27..af454cf 100644 --- a/src/session/session.rs +++ b/src/session/session.rs @@ -547,7 +547,7 @@ use crate::session::session_id::UnifiedSessionId; use crate::skills::SkillsLoader; use crate::tools::OutboundMessenger; use crate::tools::SendMessageTool; -use crate::tools::{ToolRegistry, create_default_tools}; +use crate::tools::{ToolExecutionContext, ToolRegistry, create_default_tools}; /// Session = 一个 dialog /// 每个 Session 对应一个 UnifiedSessionId,有独立的 messages history @@ -1430,6 +1430,7 @@ pub struct SessionManager { task_supervisor: crate::task_supervisor::TaskSupervisor, turn_delivery: TurnDeliveryService, reload: crate::gateway::reload::ReloadHandle, + health: Arc, } /// Gateway-owned runtime services shared by all Session workers. @@ -1549,6 +1550,11 @@ pub static SLASH_COMMANDS: &[SlashCommand] = &[ description: "显示 MCP 服务状态和工具列表", aliases: &["/mcp"], }, + SlashCommand { + name: "health", + description: "检查 PicoBot 运行依赖", + aliases: &["/health"], + }, SlashCommand { name: "stop", description: "停止当前正在执行的任务并清空消息队列", @@ -1594,6 +1600,7 @@ impl SessionManager { storage: Arc, services: SessionManagerServices, browser_config: Option, + health: Arc, max_concurrent_background_tasks: usize, ) -> Result { let SessionManagerServices { @@ -1610,13 +1617,18 @@ impl SessionManager { let skills_loader = Arc::new(skills_loader); let work_manager = Arc::new(crate::work::WorkManager::new(storage.clone())); - let tools = Arc::new(create_default_tools( - skills_loader.clone(), - memory_manager.clone(), - work_manager.clone(), - None, // SubAgentManager created below - browser_config.as_ref(), - )); + let tools = Arc::new( + create_default_tools( + skills_loader.clone(), + memory_manager.clone(), + work_manager.clone(), + None, // SubAgentManager created below + browser_config.as_ref(), + health.clone(), + provider_config.workspace_dir.clone(), + ) + .map_err(|error| AgentError::Other(format!("failed to create tools: {error}")))?, + ); // Create SubAgentManager and register DelegateTool let (notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -1694,6 +1706,7 @@ impl SessionManager { task_supervisor, turn_delivery, reload, + health, }) } @@ -2036,6 +2049,10 @@ impl SessionManager { .collect(); Ok((None, format!("MCP 服务:\n\n{}", lines.join("\n\n")))) } + "health" => { + let report = self.health.check().await; + Ok((None, report.render_text())) + } "stop" => { let sid = current_session_id .ok_or_else(|| AgentError::Other("no active session".to_string()))?; @@ -3064,13 +3081,19 @@ fn spawn_agent_worker( let scoped_turn_deliveries = pending_turn_deliveries.clone(); let process_future = async move { let response_session_id = unified_str2.clone(); + let tool_context = ToolExecutionContext::for_session(&response_session_id) + .with_turn_id(agent_turn.turn_id.clone()); let process_result = crate::agent::sub_agent::DELEGATE_CONTEXT.scope( crate::agent::DelegateContext { session_id: unified_str2, channel: chan2.clone(), chat_id: cid2.clone(), }, - agent.process_streaming(history_out.clone(), agent_turn.clone()), + agent.process_streaming_with_context( + history_out.clone(), + agent_turn.clone(), + tool_context.clone(), + ), ).await; let mut result = match process_result { Ok(r) => r, @@ -3155,7 +3178,11 @@ fn spawn_agent_worker( let retry_history = runtime_context.assemble(retry_result.history); match agent - .process_streaming(retry_history, agent_turn.clone()) + .process_streaming_with_context( + retry_history, + agent_turn.clone(), + tool_context, + ) .await { Ok(r) => r, @@ -3406,7 +3433,14 @@ impl SessionManager { let agent = self.create_cron_agent()?; let source_session = format!("cron:{}", job_name); let result = CURRENT_SOURCE_SESSION - .scope(Some(source_session), async { agent.process(history).await }) + .scope(Some(source_session.clone()), async { + agent + .process_with_context( + history, + ToolExecutionContext::for_session(source_session), + ) + .await + }) .await .inspect_err(|e| { tracing::error!(error = %e, job_id = %job_id, "Cron agent processing error"); @@ -3442,7 +3476,14 @@ impl SessionManager { let history = vec![ChatMessage::system(system), ChatMessage::user(prompt)]; let source_session = format!("cron:{job_id}"); let result = CURRENT_SOURCE_SESSION - .scope(Some(source_session), async { agent.process(history).await }) + .scope(Some(source_session.clone()), async { + agent + .process_with_context( + history, + ToolExecutionContext::for_session(source_session), + ) + .await + }) .await?; Ok(result.final_response.content) } @@ -3526,6 +3567,10 @@ mod slash_command_tests { resolve_slash_command("reload").map(|command| command.name), Some("reload") ); + assert_eq!( + resolve_slash_command("health").map(|command| command.name), + Some("health") + ); assert!(resolve_slash_command("unknown").is_none()); } } diff --git a/src/tools/browser.rs b/src/tools/browser.rs deleted file mode 100644 index 08e9846..0000000 --- a/src/tools/browser.rs +++ /dev/null @@ -1,1267 +0,0 @@ -use std::net::TcpStream; -use std::process::Stdio; -use std::time::Duration; - -use anyhow::Context; -use async_trait::async_trait; -use base64::Engine; -use fantoccini::actions::{InputSource, MOUSE_BUTTON_LEFT, MouseActions, PointerAction}; -use fantoccini::key::Key; -use fantoccini::{Client, ClientBuilder, Locator}; -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value, json}; -use tracing; - -use crate::config::BrowserConfig; -use crate::tools::traits::{Tool, ToolResult}; - -const CHROME_CANDIDATES: &[&str] = &[ - "google-chrome", - "chromium-browser", - "chromium", - "google-chrome-stable", - "chrome", -]; - -const CHROMEDRIVER_CANDIDATES: &[&str] = &["chromedriver"]; - -pub struct BrowserTool { - webdriver_url: String, - headless: bool, - chrome_path: Option, - state: tokio::sync::Mutex, - driver: std::sync::Mutex>, -} - -struct BrowserState { - client: Option, -} - -impl Drop for BrowserTool { - fn drop(&mut self) { - if let Ok(mut driver) = self.driver.lock() - && let Some(ref mut child) = driver.take() - { - tracing::debug!("Stopping chromedriver process"); - let _ = child.start_kill(); - } - } -} - -impl BrowserTool { - pub fn new(config: &BrowserConfig) -> Self { - Self { - webdriver_url: config.webdriver_url.clone(), - headless: config.headless, - chrome_path: config.chrome_path.clone(), - state: tokio::sync::Mutex::new(BrowserState { client: None }), - driver: std::sync::Mutex::new(None), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum BrowserAction { - Open { - url: String, - }, - Snapshot { - #[serde(default)] - interactive_only: bool, - #[serde(default)] - compact: bool, - #[serde(default)] - depth: Option, - }, - Click { - selector: String, - }, - Fill { - selector: String, - value: String, - }, - Type { - selector: Option, - text: String, - }, - GetText { - selector: String, - }, - GetTitle, - GetUrl, - Screenshot { - #[serde(default)] - path: Option, - #[serde(default)] - return_base64: bool, - }, - Focus { - selector: String, - }, - Wait { - #[serde(default)] - selector: Option, - #[serde(default)] - ms: Option, - #[serde(default)] - text: Option, - }, - Press { - key: String, - }, - Hover { - selector: String, - }, - ClickAt { - x: u32, - y: u32, - }, - Scroll { - direction: String, - #[serde(default)] - pixels: Option, - }, - Close, -} - -fn parse_browser_action(action_str: &str, args: &Value) -> anyhow::Result { - match action_str { - "open" => { - let url = args - .get("url") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'url' for open action"))?; - Ok(BrowserAction::Open { - url: url.to_string(), - }) - } - "snapshot" => Ok(BrowserAction::Snapshot { - interactive_only: args - .get("interactive_only") - .and_then(Value::as_bool) - .unwrap_or(true), - compact: args.get("compact").and_then(Value::as_bool).unwrap_or(true), - depth: args.get("depth").and_then(|v| v.as_i64()), - }), - "click" => { - let selector = args - .get("selector") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for click"))?; - Ok(BrowserAction::Click { - selector: selector.to_string(), - }) - } - "fill" => { - let selector = args - .get("selector") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for fill"))?; - let value = args - .get("value") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'value' for fill"))?; - Ok(BrowserAction::Fill { - selector: selector.to_string(), - value: value.to_string(), - }) - } - "type" => { - let selector = args - .get("selector") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let text = args - .get("text") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'text' for type"))?; - Ok(BrowserAction::Type { - selector, - text: text.to_string(), - }) - } - "get_text" => { - let selector = args - .get("selector") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for get_text"))?; - Ok(BrowserAction::GetText { - selector: selector.to_string(), - }) - } - "get_title" => Ok(BrowserAction::GetTitle), - "get_url" => Ok(BrowserAction::GetUrl), - "screenshot" => Ok(BrowserAction::Screenshot { - path: args.get("path").and_then(|v| v.as_str()).map(String::from), - return_base64: args - .get("return_base64") - .and_then(Value::as_bool) - .unwrap_or(false), - }), - "focus" => { - let selector = args - .get("selector") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for focus"))?; - Ok(BrowserAction::Focus { - selector: selector.to_string(), - }) - } - "wait" => Ok(BrowserAction::Wait { - selector: args - .get("selector") - .and_then(|v| v.as_str()) - .map(String::from), - ms: args.get("ms").and_then(|v| v.as_u64()), - text: args.get("text").and_then(|v| v.as_str()).map(String::from), - }), - "press" => { - let key = args - .get("key") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'key' for press"))?; - Ok(BrowserAction::Press { - key: key.to_string(), - }) - } - "hover" => { - let selector = args - .get("selector") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'selector' for hover"))?; - Ok(BrowserAction::Hover { - selector: selector.to_string(), - }) - } - "scroll" => { - let direction = args - .get("direction") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'direction' for scroll"))?; - Ok(BrowserAction::Scroll { - direction: direction.to_string(), - pixels: args - .get("pixels") - .and_then(|v| v.as_u64()) - .map(|p| u32::try_from(p).unwrap_or(u32::MAX)), - }) - } - "close" => Ok(BrowserAction::Close), - "click_at" => { - let x = args - .get("x") - .and_then(|v| v.as_u64()) - .ok_or_else(|| anyhow::anyhow!("Missing 'x' for click_at"))? - as u32; - let y = args - .get("y") - .and_then(|v| v.as_u64()) - .ok_or_else(|| anyhow::anyhow!("Missing 'y' for click_at"))? - as u32; - Ok(BrowserAction::ClickAt { x, y }) - } - other => anyhow::bail!("Unsupported browser action: {}", other), - } -} - -#[async_trait] -impl Tool for BrowserTool { - fn name(&self) -> &str { - "browser" - } - - fn description(&self) -> &str { - "Automate browser interactions via WebDriver. \ - Actions: open, snapshot, click, fill, type, get_text, get_title, \ - get_url, screenshot, wait, press, hover, scroll, close, focus, click_at. \ - Each session holds a single page; calling open again navigates \ - the current page (does not open a new tab). \ - Selectors: CSS, @e1 refs (from snapshot), text=... for text content, \ - label=... for