feat(browser): preserve persistent sessions
This commit is contained in:
parent
c8cdce1ced
commit
ff3d0ef773
@ -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 `<config_dir>/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
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "picobot"
|
||||
version = "1.17.0"
|
||||
version = "1.18.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -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-<uuid>`,并在该工作的后续每个 `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-<uuid>`,并在该工作的后续每个 `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 会给出可选能力警告。
|
||||
|
||||
|
||||
@ -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-<uuid>` agent-browser session。同一 dialog 串行、不同 dialog 可并发;空闲会话按 `idle_timeout_secs` 回收,数量受 `max_sessions` 限制。
|
||||
- 省略 `persistent_id` 时使用普通临时浏览器。`SessionManager` 传入完整 PicoBot session ID,BrowserManager 第一次看到该 ID 时生成随机、不透明的 `picobot-<uuid>` 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/<id>` 专用目录和可选语义标签;标签可通过 `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` 元数据。
|
||||
|
||||
|
||||
@ -264,7 +264,7 @@ WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输,WebSocket 只
|
||||
|
||||
MCP 发现的工具在 `ToolRegistry` 中使用 `mcp_<server-name>_<tool-name>` 命名空间,避免与内置工具混淆;`tool_settings` 仍按 MCP 原始 `<tool-name>` 键入。MCP 协议不提供 PicoBot 可依赖的副作用或并发契约,因此每个 `mcp.servers[].tool_settings.<tool-name>` 可在受信任本地配置中声明 `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 或泄漏配置秘密。
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 队列;两者都无法接收时明确拒绝
|
||||
|
||||
@ -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-<uuid>`,对应数据位于 `profile_dir/<id>`,然后在该工作的后续每个 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-<uuid>`,对应数据位于 `profile_dir/<id>`,然后在该工作的后续每个 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 包含登录凭据,应把目录视为敏感数据,不得提交到版本控制、跨用户共享或放在不受信任的网络文件系统中。
|
||||
|
||||
|
||||
@ -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 | 说明 |
|
||||
|--------|------|
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -338,9 +338,7 @@ pub fn parse_definition_info(path: &Path) -> Result<AgentDefinitionInfo, AgentDe
|
||||
/// `info` (id from the file stem, the role body, and whichever frontmatter
|
||||
/// fields still parse) together with the parse error, so broken definitions
|
||||
/// remain listed and editable instead of being silently hidden.
|
||||
pub fn parse_definition_lenient(
|
||||
path: &Path,
|
||||
) -> (Option<AgentDefinitionInfo>, Option<String>) {
|
||||
pub fn parse_definition_lenient(path: &Path) -> (Option<AgentDefinitionInfo>, Option<String>) {
|
||||
match parse_definition_info(path) {
|
||||
Ok(info) => (Some(info), None),
|
||||
Err(error) => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -898,7 +898,9 @@ pub async fn list_agents(State(state): State<Arc<GatewayState>>) -> Result<Json<
|
||||
continue;
|
||||
};
|
||||
let fm = &info.frontmatter;
|
||||
let load_error = load_errors.get(fm.id.as_str()).map(|error| error.reason.clone());
|
||||
let load_error = load_errors
|
||||
.get(fm.id.as_str())
|
||||
.map(|error| error.reason.clone());
|
||||
let disabled = load_error.is_some() || parse_error.is_some();
|
||||
agents.push(json!({
|
||||
"id": fm.id,
|
||||
@ -1021,8 +1023,7 @@ pub async fn get_agent_options(
|
||||
// a definition's `tools` list (it turns on the scoped skill wrapper),
|
||||
// so it must be offered in the editor.
|
||||
.filter(|(name, tool)| {
|
||||
(!tool.runtime_injected() || name == "get_skill")
|
||||
&& !crate::mcp::is_mcp_tool_name(name)
|
||||
(!tool.runtime_injected() || name == "get_skill") && !crate::mcp::is_mcp_tool_name(name)
|
||||
})
|
||||
.map(|(name, tool)| json!({ "name": name, "description": tool.description() }))
|
||||
.collect();
|
||||
|
||||
@ -693,10 +693,7 @@ fn build_router(state: Arc<GatewayState>) -> 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(
|
||||
|
||||
@ -279,7 +279,9 @@ impl SkillsLoader {
|
||||
|
||||
/// Get the modification time of a single file (missing file -> None).
|
||||
fn get_file_mtime(path: &Path) -> Option<SystemTime> {
|
||||
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}"))?;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -300,9 +300,13 @@ pub struct AcceptAgentRequest {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AcceptedAgentRuns {
|
||||
Accepted { runs: Vec<AgentRunRecord> },
|
||||
Accepted {
|
||||
runs: Vec<AgentRunRecord>,
|
||||
},
|
||||
/// Idempotent retry: the run already existed for this key.
|
||||
Existing { runs: Vec<AgentRunRecord> },
|
||||
Existing {
|
||||
runs: Vec<AgentRunRecord>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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");
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -17,7 +17,7 @@ pub(super) struct AgentBrowserRunner {
|
||||
max_output_chars: usize,
|
||||
content_boundaries: bool,
|
||||
allowed_domains: Vec<String>,
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
4
webui/package-lock.json
generated
4
webui/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picobot-webui",
|
||||
"private": true,
|
||||
"version": "1.17.0",
|
||||
"version": "1.18.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user