diff --git a/AGENTS.md b/AGENTS.md index 9297252..001c23c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,7 +110,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del - **No foreground wait tool**: Agents wait for asynchronous work by ending the Turn and letting queued completions/signals open a continuation Turn, or by polling status tools; there is no model-callable `sleep`/wait tool. Cancelling a Turn must still normalize active tool blocks to `Cancelled` - **Skill enable/disable**: skills default to enabled; user-disabled skill names are persisted in `/skills_state.json` (skill files are never modified), and disabled skills are excluded from prompts, listings, and `get_skill` at load time - **MCP enable/disable**: `mcp.servers[].enabled` defaults to true; disabled servers are skipped at activation (no connection attempt) and by health checks -- **Stateful tools** receive `ToolExecutionContext`; browser calls without `persistent_id` map each PicoBot dialog to an opaque transient agent-browser session. For long-running work an Agent may autonomously create a persistent identity and must pass its validated ID on every related action; the same ID shares one agent-browser session and serialization gate across dialogs, while different IDs have independent sessions/gates. `browser_profiles` may create IDs, persist bounded semantic labels, list, or delete only validated IDs beneath the configured profile root. Do not introduce a global persistence mode switch, a default persistent ID, automatic per-dialog persistent Profiles, Fantoccini, ChromeDriver, WebDriver, model-controlled raw agent-browser session IDs, or arbitrary Profile paths +- **Stateful tools** receive `ToolExecutionContext`; browser calls without `persistent_id` map each PicoBot dialog to an opaque transient agent-browser session, whose idle daemon timeout is controlled by `browser.idle_timeout_secs` (one hour by default). For long-running work an Agent may autonomously create a persistent identity and must pass its validated ID on every related action; persistent browser daemons disable idle auto-close, the same ID shares one agent-browser session and serialization gate across dialogs, and different IDs have independent sessions/gates. `browser_profiles` may create IDs, persist bounded semantic labels, list, or delete only validated IDs beneath the configured profile root. Do not introduce a global persistence mode switch, a default persistent ID, automatic per-dialog persistent Profiles, Fantoccini, ChromeDriver, WebDriver, model-controlled raw agent-browser session IDs, or arbitrary Profile paths - **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 67a10f6..aadd52f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "picobot" -version = "1.17.0" +version = "1.18.0" edition = "2024" [dependencies] diff --git a/README.md b/README.md index 7dcc97e..ae7f84b 100644 --- a/README.md +++ b/README.md @@ -465,7 +465,7 @@ agent-browser install "headless": true, "browser_executable_path": null, "max_sessions": 4, - "idle_timeout_secs": 900, + "idle_timeout_secs": 3600, "command_timeout_secs": 120, "max_output_chars": 50000, "content_boundaries": true, @@ -479,7 +479,7 @@ agent-browser install } ``` -浏览器没有全局“持久模式”开关,而是按每次调用分流:不传 `persistent_id` 时使用当前 dialog 的普通临时浏览器;涉及长期工作、需要保留登录或站点状态时,Agent 可以自主调用 `browser_profiles(create,label=...)` 生成 `picobot-profile-`,并在该工作的后续每个 `browser` action 中持续传入同一个 ID。也可以用 `set_label` 随时修改语义化标签。PicoBot 不设置默认 ID,也不会按 dialog 自动选择持久 Profile。同一 ID 跨 dialog 共享 agent-browser session 和串行锁,不同 ID 使用各自的 session、锁与 Chrome Profile,因而可以并发操作。Cookie、localStorage、IndexedDB、Service Worker、缓存和标签随各自目录持久化。 +浏览器没有全局“持久模式”开关,而是按每次调用分流:不传 `persistent_id` 时使用当前 dialog 的普通临时浏览器,连续一小时没有操作后默认自动关闭;涉及长期工作、需要保持浏览器进程或保留登录和站点状态时,Agent 可以自主调用 `browser_profiles(create,label=...)` 生成 `picobot-profile-`,并在该工作的后续每个 `browser` action 中持续传入同一个 ID。持久浏览器禁用 daemon 空闲自动关闭,只会在显式 `browser(close)` 或 Profile 删除时关闭。也可以用 `set_label` 随时修改语义化标签。PicoBot 不设置默认 ID,也不会按 dialog 自动选择持久 Profile。同一 ID 跨 dialog 共享 agent-browser session 和串行锁,不同 ID 使用各自的 session、锁与 Chrome Profile,因而可以并发操作。Cookie、localStorage、IndexedDB、Service Worker、缓存和标签随各自目录持久化。 `browser_profiles` 支持 `create`、`set_label`、`list`、`delete`;`list` 返回 ID、标签、目录和 active 状态,浏览器仍始终用不可变 ID 选择,重命名标签不会破坏现有调用。Profile 根目录位于 `~/.picobot` 内,现有 Docker `picobot_data` 卷会一并持久化。agent-browser 无法同时保证 Profile 复用与 `allowed_domains` 域名隔离;配置非空白名单后,普通临时浏览器仍可用,持久身份的创建和使用会被拒绝,health 会给出可选能力警告。 diff --git a/docs/AGENT_BROWSER_INTEGRATION.md b/docs/AGENT_BROWSER_INTEGRATION.md index b960f34..fa942c3 100644 --- a/docs/AGENT_BROWSER_INTEGRATION.md +++ b/docs/AGENT_BROWSER_INTEGRATION.md @@ -44,19 +44,19 @@ agent-browser CLI → Rust daemon → Chrome/Chromium CDP BrowserManager 按每次调用是否携带 `persistent_id` 分流,两种浏览器可在同一个 Gateway 中同时使用: -- 省略 `persistent_id` 时使用普通临时浏览器。`SessionManager` 传入完整 PicoBot session ID,BrowserManager 第一次看到该 ID 时生成随机、不透明的 `picobot-` agent-browser session。同一 dialog 串行、不同 dialog 可并发;空闲会话按 `idle_timeout_secs` 回收,数量受 `max_sessions` 限制。 +- 省略 `persistent_id` 时使用普通临时浏览器。`SessionManager` 传入完整 PicoBot session ID,BrowserManager 第一次看到该 ID 时生成随机、不透明的 `picobot-` agent-browser session。同一 dialog 串行、不同 dialog 可并发;临时 daemon 连续 `idle_timeout_secs`(默认 3600 秒)没有操作后自动退出,Manager 在容量检查时惰性回收空闲条目,数量受 `max_sessions` 限制。 - 需要长期保留登录或站点状态时,Agent 可以自主调用 `browser_profiles(action=create,label=...)` 创建持久身份,并在后续相关的每个 `browser` action 中显式传入返回的 `persistent_id`。 - 用户可以拥有多个 `picobot-profile-<32 hex>` ID。`browser_profiles(action=create,label=...)` 创建 `persistence.profile_dir/` 专用目录和可选语义标签;标签可通过 `set_label` 重命名,ID 保持不变。 - PicoBot 不保存默认 ID,也不按 dialog 自动选择或绑定持久 Profile。没有 ID 的调用始终回到该 dialog 的临时浏览器,不会隐式选中任何持久身份。 - 每个 ID 分别映射 agent-browser `--session`、Chrome `--profile` 路径和 mutex。同一 ID 可从不同 dialog、子 Agent 或 Scheduler 使用并保持串行;不同 ID 的浏览器状态和锁相互独立,可以并发。 -- ID 与 Profile 目录跨 Gateway 重启、配置重载和 agent-browser daemon 空闲退出保持不变;Cookie、localStorage、IndexedDB、Service Worker 和缓存由各 Chrome Profile 自身保存。 +- 持久 daemon 禁用空闲自动退出,适合需要长时间等待的浏览器作业。ID 与 Profile 目录跨 Gateway 重启、配置重载和显式浏览器关闭保持不变;Cookie、localStorage、IndexedDB、Service Worker 和缓存由各 Chrome Profile 自身保存。 - `close` 关闭显式选择的浏览器进程但保留 ID、标签和 Profile;下一次使用该 ID 时从相同目录重新打开。 - `browser_profiles(action=list)` 返回每个合法 ID 的标签、完整目录和当前 Manager 是否 active。 - `browser_profiles(action=set_label,id=...,label=...)` 写入语义标签并同步当前活动实例;标签去除首尾空白,限制为 1–80 个非控制字符。 - `browser_profiles(action=delete,id=...)` 只接受 `create/list` 返回的完整格式 ID。删除时持有管理锁、等待该 ID 的活动 action、尝试关闭其浏览器,再递归删除对应目录。 -Profile 根目录和每个生成目录在 Unix 上收敛为 `0700`,目录内 `.picobot-label` 标签文件为 `0600`。列表忽略格式非法的目录和符号链接,选择、改标签和删除拒绝路径穿越、符号链接及非目录目标。Gateway 配置重载会构造新的 ToolRegistry/BrowserManager;旧运行代按现有 drain 规则退出。agent-browser daemon 的空闲退出时间通过 `AGENT_BROWSER_IDLE_TIMEOUT_MS` 同步设置。 +Profile 根目录和每个生成目录在 Unix 上收敛为 `0700`,目录内 `.picobot-label` 标签文件为 `0600`。列表忽略格式非法的目录和符号链接,选择、改标签和删除拒绝路径穿越、符号链接及非目录目标。Gateway 配置重载会构造新的 ToolRegistry/BrowserManager;旧运行代按现有 drain 规则退出。临时 agent-browser daemon 的空闲退出时间通过 `AGENT_BROWSER_IDLE_TIMEOUT_MS` 设置为配置值,持久 daemon 则固定设置为 `0`(禁用)。 ## 4. Action 映射 @@ -79,7 +79,7 @@ Runner 固定传入 `--session`、`--json` 和明确的 headed 状态;携带 - `AGENT_BROWSER_CONTENT_BOUNDARIES` - `AGENT_BROWSER_MAX_OUTPUT` - `AGENT_BROWSER_ALLOWED_DOMAINS`(非空时) -- `AGENT_BROWSER_IDLE_TIMEOUT_MS` +- `AGENT_BROWSER_IDLE_TIMEOUT_MS`(临时会话使用配置值;持久会话固定为 `0`) 非零退出码、JSON 中 `success=false`、无效 JSON和超时都转换为工具失败。stdout/stderr 在返回模型前有长度上限;页面类结果保留 agent-browser `_boundary` 元数据。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4b98039..193d941 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -264,7 +264,7 @@ WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输,WebSocket 只 MCP 发现的工具在 `ToolRegistry` 中使用 `mcp__` 命名空间,避免与内置工具混淆;`tool_settings` 仍按 MCP 原始 `` 键入。MCP 协议不提供 PicoBot 可依赖的副作用或并发契约,因此每个 `mcp.servers[].tool_settings.` 可在受信任本地配置中声明 `read_only` 与 `exclusive`。未声明的 MCP 工具保守地按“可能有副作用、顺序执行”处理。`concurrency_safe` 不保存为独立状态,而是严格由 `read_only && !exclusive` 推导;工具批次只有全部工具满足该条件才允许并发执行。WebUI 的 MCP 工具展开项提供这两个声明的复选框,并将推导结果显示为“可并发”;属性编辑先保留在页面草稿中,只有选择“保存并应用”才原子写入配置并触发一次热重载,离开 MCP 标签或刷新页面会丢弃草稿。 -`browser` 是有状态工具适配器:`BrowserTool` 保持模型侧 action schema,`BrowserManager` 按每次调用是否带 `persistent_id` 分流。省略 ID 时把 PicoBot dialog 映射到随机临时 agent-browser session,并用每 session mutex 保证同一页面串行、不同 dialog 并发;长期工作需要保留登录或站点状态时,Agent 可自主创建持久身份并在后续相关 action 中持续传入同一个 ID。Manager 按持久 ID 保存 agent-browser session 和 mutex,同一 ID 跨 dialog 共享且串行,不同 ID 相互独立并可并发,Gateway 重启或 daemon 退出后继续使用原 Profile;没有全局持久化开关、默认 ID 或按 dialog 隐式选择。`browser_profiles` 在受控根目录下创建、设置语义化标签、列出或删除格式合法的 ID;标签只负责识别,选择仍使用不可变 ID,删除活动 ID 时先等待其 action 并关闭浏览器。`AgentBrowserRunner` 以 argv 和 `--json` 调用外部原生 CLI,设置硬超时、输出/content boundaries/domain allowlist,并在持久调用中传入受控 `--profile` 路径,底层 daemon 通过 Chrome CDP 工作。PicoBot 不链接 agent-browser 内部 crate、不直接暴露其 MCP、不使用 Fantoccini/ChromeDriver/WebDriver。持久 Profile 与 `allowed_domains` 因上游安全边界互斥;设置域名限制时临时浏览器仍可用,持久调用会被拒绝。截图只能写入配置的 artifact directory,并作为 `ModelAndUser` 产物返回,默认附到最终用户回复;仅当调用显式设置 `present_to_user=false` 时才作为模型内部观察。完整边界见 [AGENT_BROWSER_INTEGRATION.md](AGENT_BROWSER_INTEGRATION.md)。 +`browser` 是有状态工具适配器:`BrowserTool` 保持模型侧 action schema,`BrowserManager` 按每次调用是否带 `persistent_id` 分流。省略 ID 时把 PicoBot dialog 映射到随机临时 agent-browser session,并用每 session mutex 保证同一页面串行、不同 dialog 并发;临时 daemon 按 `browser.idle_timeout_secs`(默认一小时)自动退出,Manager 在容量检查时惰性回收对应空闲条目。长期工作需要保持浏览器进程或保留登录和站点状态时,Agent 可自主创建持久身份并在后续相关 action 中持续传入同一个 ID。Manager 按持久 ID 保存 agent-browser session 和 mutex,持久 daemon 的空闲超时固定为禁用;同一 ID 跨 dialog 共享且串行,不同 ID 相互独立并可并发,Gateway 重启或显式关闭浏览器后仍可继续使用原 Profile;没有全局持久化开关、默认 ID 或按 dialog 隐式选择。`browser_profiles` 在受控根目录下创建、设置语义化标签、列出或删除格式合法的 ID;标签只负责识别,选择仍使用不可变 ID,删除活动 ID 时先等待其 action 并关闭浏览器。`AgentBrowserRunner` 以 argv 和 `--json` 调用外部原生 CLI,设置硬超时、输出/content boundaries/domain allowlist,并在持久调用中传入受控 `--profile` 路径,底层 daemon 通过 Chrome CDP 工作。PicoBot 不链接 agent-browser 内部 crate、不直接暴露其 MCP、不使用 Fantoccini/ChromeDriver/WebDriver。持久 Profile 与 `allowed_domains` 因上游安全边界互斥;设置域名限制时临时浏览器仍可用,持久调用会被拒绝。截图只能写入配置的 artifact directory,并作为 `ModelAndUser` 产物返回,默认附到最终用户回复;仅当调用显式设置 `present_to_user=false` 时才作为模型内部观察。完整边界见 [AGENT_BROWSER_INTEGRATION.md](AGENT_BROWSER_INTEGRATION.md)。 `HealthService` 是依赖检查的唯一实现。CLI `picobot health`、只读 `health` 工具和 `/health` 斜杠命令必须复用它;检查可探测命令、版本、配置路径和 agent-browser offline quick doctor,但不能安装/修复软件、连接模型 API 或泄漏配置秘密。 diff --git a/resources/skills/about-picobot/assets/config.example.json b/resources/skills/about-picobot/assets/config.example.json index 76f53aa..9010928 100644 --- a/resources/skills/about-picobot/assets/config.example.json +++ b/resources/skills/about-picobot/assets/config.example.json @@ -90,7 +90,7 @@ "headless": true, "browser_executable_path": null, "max_sessions": 4, - "idle_timeout_secs": 900, + "idle_timeout_secs": 3600, "command_timeout_secs": 120, "max_output_chars": 50000, "content_boundaries": true, diff --git a/resources/skills/about-picobot/references/architecture.md b/resources/skills/about-picobot/references/architecture.md index 1028a88..17a6ec7 100644 --- a/resources/skills/about-picobot/references/architecture.md +++ b/resources/skills/about-picobot/references/architecture.md @@ -63,7 +63,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=false` 时不注册;缺少 CLI/Chrome 不阻止 Gateway 启动,但 health 和实际调用会给出安装错误。每次调用按参数分流:不传 `persistent_id` 时按 dialog 使用普通临时浏览器;长期工作时 Agent 可自主创建持久身份,并在后续相关 action 中持续传入同一个 ID。同一 ID 跨 dialog 共享 session/锁,不同 ID 相互独立并可并发。`browser_profiles` 只在受控根目录中创建、设置语义标签、列出或删除合法 ID;没有全局持久化开关、默认 ID,也不自动按 dialog 建立或选择持久 Profile,不依赖 Fantoccini/ChromeDriver/WebDriver +- `browser` 工具默认启用,只有 `browser.enabled=false` 时不注册;缺少 CLI/Chrome 不阻止 Gateway 启动,但 health 和实际调用会给出安装错误。每次调用按参数分流:不传 `persistent_id` 时按 dialog 使用普通临时浏览器,默认空闲一小时后自动关闭;长期工作时 Agent 可自主创建持久身份,并在后续相关 action 中持续传入同一个 ID,持久 daemon 禁用空闲自动关闭。同一 ID 跨 dialog 共享 session/锁,不同 ID 相互独立并可并发。`browser_profiles` 只在受控根目录中创建、设置语义标签、列出或删除合法 ID;没有全局持久化开关、默认 ID,也不自动按 dialog 建立或选择持久 Profile,不依赖 Fantoccini/ChromeDriver/WebDriver - 所有工具调用统一包装为 `ToolOutput` 并经过公共处理器;产物按模型/用户受众分流。浏览器截图默认同时供模型查看并附到最终回复,`file_read` 图片默认仅供模型理解 - 同一 session 只运行一个 Turn;活动 Turn 期间普通输入默认 steering,`/queue` 明确等待下一 Turn,不同 session 可并发 - steering mailbox 容量为 32 条/64 KiB,满或关闭时可靠回退到容量 32 的 session 队列;两者都无法接收时明确拒绝 diff --git a/resources/skills/about-picobot/references/config.md b/resources/skills/about-picobot/references/config.md index ccb560f..aee25d6 100644 --- a/resources/skills/about-picobot/references/config.md +++ b/resources/skills/about-picobot/references/config.md @@ -173,7 +173,7 @@ MCP 服务器单条配置: | `headless` | bool | true | 是否无头运行 | | `browser_executable_path` | string | - | 自定义 Chrome/Chromium 可执行文件路径 | | `max_sessions` | int | 4 | 同时保留的普通 dialog 临时浏览器会话上限;持久身份不计入 | -| `idle_timeout_secs` | int | 900 | PicoBot 会话清理及 agent-browser daemon 空闲退出时间 | +| `idle_timeout_secs` | int | 3600 | 大于零;普通 dialog 临时浏览器的空闲退出及 Manager 回收时间;持久浏览器不使用该超时 | | `command_timeout_secs` | int | 120 | 单次 CLI 调用硬超时 | | `max_output_chars` | int | 50000 | 页面来源文本输出上限 | | `content_boundaries` | bool | true | 启用 agent-browser 不可信页面边界元数据 | @@ -182,7 +182,7 @@ MCP 服务器单条配置: | `artifact_dir` | string | ~/.picobot/media/browser | 截图产物目录 | | `persistence.profile_dir` | string | ~/.picobot/browser/profiles | 持久 ID、语义标签和 Profile 目录的受控根目录 | -持久化不是配置模式。`browser` 调用省略 `persistent_id` 时使用当前 dialog 的普通临时浏览器;长期工作需要保留登录或站点状态时,Agent 可自主调用 `browser_profiles(create,label=...)` 生成 `picobot-profile-`,对应数据位于 `profile_dir/`,然后在该工作的后续每个 action 中持续传入同一个 ID。没有默认 ID,也不会按 dialog 自动选择持久身份。同一 ID 跨 dialog 共享底层 session 与串行锁,不同 ID 各自独立并可并发。`browser_profiles` 还支持 `set_label`、`list` 和使用精确 ID 的 `delete`;标签可重命名,但选择浏览器始终使用不可变 ID。 +持久化不是配置模式。`browser` 调用省略 `persistent_id` 时使用当前 dialog 的普通临时浏览器,并在达到 `idle_timeout_secs` 后自动退出;长期工作需要保持浏览器进程或保留登录和站点状态时,Agent 可自主调用 `browser_profiles(create,label=...)` 生成 `picobot-profile-`,对应数据位于 `profile_dir/`,然后在该工作的后续每个 action 中持续传入同一个 ID。持久浏览器禁用 daemon 空闲自动退出,只通过显式 `browser(close)` 或 Profile 删除关闭。没有默认 ID,也不会按 dialog 自动选择持久身份。同一 ID 跨 dialog 共享底层 session 与串行锁,不同 ID 各自独立并可并发。`browser_profiles` 还支持 `set_label`、`list` 和使用精确 ID 的 `delete`;标签可重命名,但选择浏览器始终使用不可变 ID。 agent-browser 0.33.0 不允许持久 Profile 与 `allowed_domains` 同时使用。配置域名限制后普通临时浏览器仍可用,创建或使用持久身份会被拒绝,health 会提示该可选能力受限。Profile 包含登录凭据,应把目录视为敏感数据,不得提交到版本控制、跨用户共享或放在不受信任的网络文件系统中。 diff --git a/resources/skills/about-picobot/references/tools.md b/resources/skills/about-picobot/references/tools.md index 4dac23b..b9680a4 100644 --- a/resources/skills/about-picobot/references/tools.md +++ b/resources/skills/about-picobot/references/tools.md @@ -166,7 +166,7 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅 ## browser — 浏览器自动化 -默认注册;设置 `browser.enabled=false` 后不注册。PicoBot 上层包装统一 action 和结构化媒体,底层逐次调用 agent-browser `--json`。调用时不传 `persistent_id`,每个 dialog 使用各自的普通临时 session;长期工作需要保留登录或站点状态时,Agent 可自主用 `browser_profiles(create,label=...)` 创建身份,并在后续相关 action 中持续传入同一个 ID。PicoBot 没有全局持久化开关或默认持久 ID,也不按 dialog 自动选择持久身份;同一 ID 跨 dialog 共享 session 和串行锁,不同 ID 使用独立 session/锁并可并发。CLI daemon 通过 Chrome CDP 工作,不使用 Fantoccini、ChromeDriver 或 WebDriver。 +默认注册;设置 `browser.enabled=false` 后不注册。PicoBot 上层包装统一 action 和结构化媒体,底层逐次调用 agent-browser `--json`。调用时不传 `persistent_id`,每个 dialog 使用各自的普通临时 session,默认空闲一小时后自动关闭;长期工作需要保持浏览器进程或保留登录和站点状态时,Agent 可自主用 `browser_profiles(create,label=...)` 创建身份,并在后续相关 action 中持续传入同一个 ID。持久 session 不会因空闲自动关闭。PicoBot 没有全局持久化开关或默认持久 ID,也不按 dialog 自动选择持久身份;同一 ID 跨 dialog 共享 session 和串行锁,不同 ID 使用独立 session/锁并可并发。CLI daemon 通过 Chrome CDP 工作,不使用 Fantoccini、ChromeDriver 或 WebDriver。 | action | 说明 | |--------|------| diff --git a/resources/templates/config.example.json b/resources/templates/config.example.json index b7ee984..2690329 100644 --- a/resources/templates/config.example.json +++ b/resources/templates/config.example.json @@ -114,7 +114,7 @@ "headless": true, "browser_executable_path": null, "max_sessions": 4, - "idle_timeout_secs": 900, + "idle_timeout_secs": 3600, "command_timeout_secs": 120, "max_output_chars": 50000, "content_boundaries": true, diff --git a/src/agent/catalog.rs b/src/agent/catalog.rs index 2dd152e..7fc841f 100644 --- a/src/agent/catalog.rs +++ b/src/agent/catalog.rs @@ -485,9 +485,7 @@ fn validate_definition_skills( }); } } - if !definition.skills.is_empty() - && !definition.tools.iter().any(|tool| tool == "get_skill") - { + if !definition.skills.is_empty() && !definition.tools.iter().any(|tool| tool == "get_skill") { return Err(AgentCatalogError::InvalidTool { agent: definition.id.clone(), tool: "get_skill".to_string(), @@ -604,7 +602,12 @@ mod tests { fn catalog_loads_provider_tools_and_delegation_graph() { let root = tempfile::tempdir().unwrap(); std::fs::create_dir(root.path().join("agents")).unwrap(); - write_agent(root.path(), "researcher", &["calculator"], Some(&["reviewer"])); + write_agent( + root.path(), + "researcher", + &["calculator"], + Some(&["reviewer"]), + ); write_agent(root.path(), "reviewer", &["calculator"], None); let tools = ToolRegistry::new(); tools.register(CalculatorTool::new()); @@ -665,7 +668,13 @@ mod tests { .unwrap(); // ROOT may delegate to every named Agent. - for id in ["general-purpose", "researcher", "reviewer", "coder", "writer"] { + for id in [ + "general-purpose", + "researcher", + "reviewer", + "coder", + "writer", + ] { assert!(catalog.root_can_delegate(id), "ROOT -> {id}"); } @@ -899,10 +908,12 @@ mod tests { assert!(catalog.get("reviewer").is_none()); assert!(catalog.get("researcher").is_none(), "must cascade"); assert!(catalog.get("coder").is_some(), "`*` must not cascade"); - assert!(catalog - .load_errors() - .iter() - .any(|error| error.id == "researcher" && error.reason.contains("reviewer"))); + assert!( + catalog + .load_errors() + .iter() + .any(|error| error.id == "researcher" && error.reason.contains("reviewer")) + ); } #[test] diff --git a/src/agent/definition.rs b/src/agent/definition.rs index bd7c0bd..eec70df 100644 --- a/src/agent/definition.rs +++ b/src/agent/definition.rs @@ -338,9 +338,7 @@ pub fn parse_definition_info(path: &Path) -> Result (Option, Option) { +pub fn parse_definition_lenient(path: &Path) -> (Option, Option) { match parse_definition_info(path) { Ok(info) => (Some(info), None), Err(error) => { diff --git a/src/config/mod.rs b/src/config/mod.rs index 604fb40..4e6bf07 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -655,7 +655,7 @@ fn default_browser_max_sessions() -> usize { } fn default_browser_idle_timeout_secs() -> u64 { - 15 * 60 + 60 * 60 } fn default_browser_command_timeout_secs() -> u64 { @@ -1186,6 +1186,7 @@ mod tests { assert!(config.browser.enabled); let browser: BrowserConfig = serde_json::from_str("{}").unwrap(); assert!(browser.enabled); + assert_eq!(browser.idle_timeout_secs, 60 * 60); assert!( browser .persistence diff --git a/src/gateway/http.rs b/src/gateway/http.rs index 0f70f12..eaa5a32 100644 --- a/src/gateway/http.rs +++ b/src/gateway/http.rs @@ -898,7 +898,9 @@ pub async fn list_agents(State(state): State>) -> Result) -> Router { .route("/api/status", routing::get(http::get_status)) .route("/api/tools", routing::get(http::get_tools)) .route("/api/skills", routing::get(http::get_skills)) - .route( - "/api/skills/{name}", - routing::put(http::put_skill_enabled), - ) + .route("/api/skills/{name}", routing::put(http::put_skill_enabled)) .route("/api/jobs", routing::get(http::get_jobs)) .route("/api/jobs/{id}/runs", routing::get(http::get_job_runs)) .route( diff --git a/src/skills/mod.rs b/src/skills/mod.rs index ea7f544..b53f82e 100644 --- a/src/skills/mod.rs +++ b/src/skills/mod.rs @@ -279,7 +279,9 @@ impl SkillsLoader { /// Get the modification time of a single file (missing file -> None). fn get_file_mtime(path: &Path) -> Option { - std::fs::metadata(path).and_then(|metadata| metadata.modified()).ok() + std::fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .ok() } /// Read the disabled-skills state file into `state.disabled_skills`. @@ -316,10 +318,7 @@ impl SkillsLoader { disabled.sort(); let content = serde_json::json!({ "disabled": disabled }).to_string(); - let parent = self - .state_path - .parent() - .unwrap_or_else(|| Path::new(".")); + let parent = self.state_path.parent().unwrap_or_else(|| Path::new(".")); std::fs::create_dir_all(parent).map_err(|e| format!("create state dir: {e}"))?; let temp = parent.join(".skills_state.json.tmp"); std::fs::write(&temp, &content).map_err(|e| format!("write state: {e}"))?; diff --git a/src/storage/agent_inbox.rs b/src/storage/agent_inbox.rs index 475acf3..af2a716 100644 --- a/src/storage/agent_inbox.rs +++ b/src/storage/agent_inbox.rs @@ -892,7 +892,7 @@ impl super::Storage { id: uuid::Uuid::new_v4().to_string(), root_session_id: session_id.clone(), run_id: Some(run_id.clone()), - event_type: AgentEventType::Completion, + event_type: AgentEventType::Completion, event_key: format!("interrupted:{run_id}"), delivery: AgentEventDelivery::Queue, requires_continuation: true, diff --git a/src/storage/agent_run.rs b/src/storage/agent_run.rs index 2624cf2..92218c5 100644 --- a/src/storage/agent_run.rs +++ b/src/storage/agent_run.rs @@ -300,9 +300,13 @@ pub struct AcceptAgentRequest { #[derive(Debug)] pub enum AcceptedAgentRuns { - Accepted { runs: Vec }, + Accepted { + runs: Vec, + }, /// Idempotent retry: the run already existed for this key. - Existing { runs: Vec }, + Existing { + runs: Vec, + }, } /// Terminal outcome produced by a runner. The Coordinator persists it; the @@ -351,7 +355,6 @@ impl AgentTerminalOutcome { Self::Interrupted { .. } => AgentRunStatus::Interrupted, } } - } #[derive(Debug, Clone)] @@ -675,7 +678,6 @@ impl super::Storage { rows.iter().map(run_record_from_row).collect() } - /// Conditional `queued -> running` transition owned by this execution. pub async fn mark_agent_run_running( &self, @@ -1370,10 +1372,7 @@ mod tests { )); } storage - .accept_agent_runs(AcceptAgentRequest { - runs, - now: 100, - }) + .accept_agent_runs(AcceptAgentRequest { runs, now: 100 }) .await .unwrap(); @@ -1510,7 +1509,10 @@ mod tests { .await .unwrap(); - let messages = storage.list_agent_run_messages("run-1", 10_000).await.unwrap(); + let messages = storage + .list_agent_run_messages("run-1", 10_000) + .await + .unwrap(); assert_eq!(messages.len(), 2); assert_eq!(messages[0].seq, 0); assert_eq!(messages[0].role, "assistant"); diff --git a/src/tools/browser/manager.rs b/src/tools/browser/manager.rs index 2852b38..4676980 100644 --- a/src/tools/browser/manager.rs +++ b/src/tools/browser/manager.rs @@ -66,6 +66,9 @@ impl BrowserManager { if config.max_sessions == 0 { bail!("browser.max_sessions must be greater than zero"); } + if config.idle_timeout_secs == 0 { + bail!("browser.idle_timeout_secs must be greater than zero"); + } if config.command.trim().is_empty() { bail!("browser.command cannot be empty"); } @@ -90,7 +93,7 @@ impl BrowserManager { persistent_sessions: Mutex::new(HashMap::new()), profile_root, max_sessions: config.max_sessions, - idle_timeout: Duration::from_secs(config.idle_timeout_secs.max(1)), + idle_timeout: Duration::from_secs(config.idle_timeout_secs), artifact_dir, allow_private_hosts: config.allow_private_hosts, allowed_domains: config.allowed_domains.clone(), @@ -667,6 +670,20 @@ mod tests { } } + #[test] + fn zero_transient_idle_timeout_is_rejected() { + let temp = tempfile::tempdir().unwrap(); + let config = BrowserConfig { + idle_timeout_secs: 0, + ..persistent_config(temp.path()) + }; + let error = BrowserManager::new(&config, temp.path().to_path_buf()) + .err() + .unwrap() + .to_string(); + assert!(error.contains("browser.idle_timeout_secs must be greater than zero")); + } + #[tokio::test] async fn missing_id_uses_transient_dialog_sessions() { let temp = tempfile::tempdir().unwrap(); diff --git a/src/tools/browser/runner.rs b/src/tools/browser/runner.rs index eb66b17..8223f1a 100644 --- a/src/tools/browser/runner.rs +++ b/src/tools/browser/runner.rs @@ -17,7 +17,7 @@ pub(super) struct AgentBrowserRunner { max_output_chars: usize, content_boundaries: bool, allowed_domains: Vec, - idle_timeout_ms: u64, + transient_idle_timeout_ms: u64, } impl AgentBrowserRunner { @@ -40,7 +40,7 @@ impl AgentBrowserRunner { max_output_chars: config.max_output_chars.max(1), content_boundaries: config.content_boundaries, allowed_domains: config.allowed_domains.clone(), - idle_timeout_ms: config.idle_timeout_secs.saturating_mul(1_000), + transient_idle_timeout_ms: config.idle_timeout_secs.saturating_mul(1_000), } } @@ -77,7 +77,11 @@ impl AgentBrowserRunner { ) .env( "AGENT_BROWSER_IDLE_TIMEOUT_MS", - self.idle_timeout_ms.to_string(), + if profile_dir.is_some() { + "0".to_string() + } else { + self.transient_idle_timeout_ms.to_string() + }, ); if let Some(path) = &self.executable_path { command.env("AGENT_BROWSER_EXECUTABLE_PATH", path); @@ -231,11 +235,13 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let script = temp.path().join("fake-agent-browser"); let args_file = temp.path().join("args.txt"); + let idle_timeout_file = temp.path().join("idle-timeout.txt"); std::fs::write( &script, format!( - "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\nprintf '%s\\n' '{{\"success\":true,\"data\":{{\"message\":\"ok\"}}}}'\n", - args_file.display() + "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\nprintf '%s\\n' \"$AGENT_BROWSER_IDLE_TIMEOUT_MS\" > '{}'\nprintf '%s\\n' '{{\"success\":true,\"data\":{{\"message\":\"ok\"}}}}'\n", + args_file.display(), + idle_timeout_file.display() ), ) .unwrap(); @@ -265,5 +271,45 @@ mod tests { assert!(args.windows(2).any(|pair| { pair[0] == "--profile" && pair[1] == profile.to_string_lossy().as_ref() })); + assert_eq!(std::fs::read_to_string(idle_timeout_file).unwrap(), "0\n"); + } + + #[cfg(unix)] + #[tokio::test] + async fn transient_session_uses_configured_idle_timeout() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let script = temp.path().join("fake-agent-browser"); + let idle_timeout_file = temp.path().join("idle-timeout.txt"); + std::fs::write( + &script, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$AGENT_BROWSER_IDLE_TIMEOUT_MS\" > '{}'\nprintf '%s\\n' '{{\"success\":true,\"data\":{{\"message\":\"ok\"}}}}'\n", + idle_timeout_file.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap(); + let config = BrowserConfig { + command: script.to_string_lossy().into_owned(), + idle_timeout_secs: 37, + ..BrowserConfig::default() + }; + let runner = AgentBrowserRunner::new(&config, temp.path().to_path_buf()); + + runner + .run( + "transient-id", + None, + &["open".to_string(), "https://example.com".to_string()], + ) + .await + .unwrap(); + + assert_eq!( + std::fs::read_to_string(idle_timeout_file).unwrap(), + "37000\n" + ); } } diff --git a/webui/package-lock.json b/webui/package-lock.json index 0473ef8..66c9244 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,12 +1,12 @@ { "name": "picobot-webui", - "version": "1.17.0", + "version": "1.18.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picobot-webui", - "version": "1.17.0", + "version": "1.18.0", "dependencies": { "bits-ui": "^2.0.0", "dompurify": "^3.4.12", diff --git a/webui/package.json b/webui/package.json index cef2db4..1bd3ee7 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,7 +1,7 @@ { "name": "picobot-webui", "private": true, - "version": "1.17.0", + "version": "1.18.0", "type": "module", "engines": { "node": ">=20"