feat: add cancellable sleep tool

This commit is contained in:
xiaoxixi 2026-08-06 08:38:43 +08:00
parent da5ee05311
commit e45980a282
16 changed files with 541 additions and 5 deletions

View File

@ -105,6 +105,7 @@ 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
- **Sleep tool** is a cancellable foreground wait bounded to 24 hours; longer or durable delays belong to Scheduler/background work, and cancelling a Turn must normalize active tool blocks to `Cancelled`
- **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

View File

@ -1,6 +1,6 @@
[package]
name = "picobot"
version = "1.4.0"
version = "1.4.1"
edition = "2024"
[dependencies]
@ -55,6 +55,7 @@ portable-pty = "0.9"
[dev-dependencies]
dotenv = "0.15"
tower = "0.5"
tokio = { version = "1.53", features = ["test-util"] }
[build-dependencies]
zstd = "0.13"

View File

@ -334,6 +334,7 @@ PicoBot 有两类记忆:
| 工具 | 说明 |
|------|------|
| `calculator` | 数学表达式和统计计算 |
| `sleep` | 暂停当前 Agent 工具调用 086400 秒;可由用户停止,不用于持久调度 |
| `file_read` / `file_write` / `file_edit` | 文件读写和编辑;`file_read` 读取受支持图片时可将图片直接提供给多模态模型 |
| `file_search` / `content_search` | 文件名和内容搜索 |
| `bash` | 在 workspace 中执行 Shell 命令 |

View File

@ -312,6 +312,8 @@ WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输WebSocket 只
5. 长操作应有超时;后台执行应交给 SubAgentManager/TaskSupervisor。
6. 需要跨调用保存外部状态时,实现 `execute_with_context` 并按 session 隔离;不得让模型控制底层全局 session ID。
内置 `sleep` 只暂停当前前台工具 Future允许 086400 秒且不持久化;`/stop`、Scheduler/SubAgent 超时和 Supervisor shutdown 通过丢弃外层 Future 取消计时。Turn 进入 `Cancelled` 时必须把仍为 `Running` 的工具块同步归约为 `Cancelled`,避免终态快照继续显示工具执行中。超过 24 小时或需要跨重启的等待必须使用 Scheduler/后台任务。
### 新增 Provider
1. 实现 `LLMProvider`,保持其为纯 HTTP/API 适配器。

View File

@ -0,0 +1,201 @@
# Sleep Tool Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a model-callable `sleep` tool that asynchronously waits for 086400 whole seconds and terminates cleanly when its Turn is cancelled.
**Architecture:** A focused, stateless `SleepTool` validates its single argument and waits on one bounded Tokio timer. The existing default registry exposes it to agents, dropping the surrounding execution future cancels the timer, and terminal Turn reduction marks active tool blocks cancelled.
**Tech Stack:** Rust 2024, Tokio timers and paused-time tests, existing `Tool`/`ToolResult` interfaces, Serde JSON.
---
## Chunk 1: Tool And Registration
### Task 1: Implement And Register `SleepTool`
**Files:**
- Create: `src/tools/sleep.rs`
- Modify: `src/tools/mod.rs:1-52`
- Modify: `src/tools/mod.rs:74-90`
- Test: `src/tools/sleep.rs`
- [ ] **Step 1: Declare the module and write failing metadata tests**
Add `pub mod sleep;` and `pub use sleep::SleepTool;` to `src/tools/mod.rs`. Create `src/tools/sleep.rs` with a test module that imports `super::*`, `crate::tools::Tool`, `serde_json::json`, and `std::time::Duration`. Add a synchronous test asserting the name is `sleep`, the schema requires `seconds`, and its property type is `integer` with minimum `0`.
- [ ] **Step 2: Write failing validation tests**
Add a zero-second async test asserting exact successful output `Slept for 0 second(s).`. Add a table-driven async test for `{}`, negative, fractional, string, large float, and `86401`; assert each result is unsuccessful with empty output and a populated error. Add a parser boundary test:
```rust
#[test]
fn accepts_24_hour_boundary() {
assert_eq!(parse_seconds(&json!({"seconds": 86_400})), Ok(86_400));
}
```
- [ ] **Step 3: Write failing timer and cancellation tests**
Add the following paused-clock elapsed test. Yield after every `advance` so expired timers are polled deterministically:
```rust
#[tokio::test(start_paused = true)]
async fn waits_for_requested_seconds() {
let handle = tokio::spawn(async { SleepTool::new().execute(json!({"seconds": 2})).await });
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(1)).await;
tokio::task::yield_now().await;
assert!(!handle.is_finished());
tokio::time::advance(Duration::from_secs(1)).await;
tokio::task::yield_now().await;
assert!(handle.await.unwrap().unwrap().success);
}
```
Add a 24-hour boundary test that advances to one second before the deadline, asserts the handle is unfinished, advances the final second, and asserts success.
Add the cancellation test, which yields before aborting to ensure the timer has been registered:
```rust
#[tokio::test(start_paused = true)]
async fn cancellation_drops_an_active_sleep() {
let handle = tokio::spawn(async {
SleepTool::new()
.execute(json!({"seconds": MAX_SLEEP_SECONDS}))
.await
});
tokio::task::yield_now().await;
assert!(!handle.is_finished());
handle.abort();
assert!(handle.await.unwrap_err().is_cancelled());
}
```
- [ ] **Step 4: Run the focused test target and confirm RED**
Run: `cargo test --lib tools::sleep::tests`
Expected: compilation fails because `SleepTool`, `parse_seconds`, and `MAX_SLEEP_SECONDS` are not defined.
- [ ] **Step 5: Implement the minimal tool**
Implement `src/tools/sleep.rs` with this shape:
```rust
use super::traits::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::json;
use std::time::Duration;
const MAX_SLEEP_SECONDS: u64 = 86_400;
pub struct SleepTool;
impl SleepTool {
pub fn new() -> Self {
Self
}
}
impl Default for SleepTool {
fn default() -> Self {
Self::new()
}
}
fn parse_seconds(args: &serde_json::Value) -> Result<u64, String> {
let seconds = args.get("seconds")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| "seconds must be a non-negative integer".to_string())?;
if seconds > MAX_SLEEP_SECONDS {
return Err("seconds must not exceed 86400 (24 hours)".to_string());
}
Ok(seconds)
}
#[async_trait]
impl Tool for SleepTool {
fn name(&self) -> &str {
"sleep"
}
fn description(&self) -> &str {
"Pause the current agent execution for a specified number of whole seconds."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"seconds": {
"type": "integer",
"minimum": 0,
"maximum": MAX_SLEEP_SECONDS,
"description": "Number of whole seconds to wait, up to 24 hours."
}
},
"required": ["seconds"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let seconds = match parse_seconds(&args) {
Ok(seconds) => seconds,
Err(error) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(error),
});
}
};
tokio::time::sleep(Duration::from_secs(seconds)).await;
Ok(ToolResult {
success: true,
output: format!("Slept for {seconds} second(s)."),
error: None,
})
}
}
```
Keep the default `read_only`, `concurrency_safe`, and `exclusive` methods unchanged so a batch containing `sleep` executes sequentially.
- [ ] **Step 6: Register the tool**
In `create_default_tools`, add:
```rust
registry.register(SleepTool::new());
```
Place it with the other stateless core tools, immediately after `CalculatorTool`.
- [ ] **Step 7: Run focused tests and confirm GREEN**
Run: `cargo test --lib tools::sleep::tests`
Expected: all sleep tests pass, including paused-time and cancellation cases.
- [ ] **Step 8: Verify the complete Rust change**
Run: `cargo test --lib`
Expected: all library tests pass.
Run: `cargo clippy --all-targets --all-features -- -D warnings`
Expected: exits successfully with no warnings.
Run: `cargo build`
Expected: debug build succeeds, including embedded WebUI build handling.
- [ ] **Step 9: Inspect the final diff**
Run: `git diff --check && git status --short && git diff -- src/tools/sleep.rs src/tools/mod.rs docs/superpowers/specs/2026-07-28-sleep-tool-design.md docs/superpowers/plans/2026-07-28-sleep-tool.md`
Expected: no whitespace errors; only the intended sleep implementation, Turn cancellation handling, public documentation, tests, and patch-version files are changed. Do not commit unless the user explicitly requests it.

View File

@ -0,0 +1,41 @@
# Sleep Tool Design
## Goal
Add a model-callable `sleep` tool that pauses the current agent tool call for a requested number of whole seconds. This first version only waits in process and does not schedule durable or background work.
## Interface
- Tool name: `sleep`
- Arguments: an object with one required `seconds` field
- `seconds` must be an integer from `0` through `86400` inclusive
- The maximum foreground wait is 24 hours
- `0` is valid and completes immediately
- Unknown object fields are ignored consistently with existing native tools
Invalid, missing, negative, fractional, or values above `86400` return an ordinary unsuccessful `ToolResult`. A successful call returns `Slept for N second(s).`, with the requested duration substituted for `N`.
## Implementation
Create a stateless `SleepTool` in `src/tools/sleep.rs`. Its `Tool::execute` implementation validates `seconds`, waits on one bounded Tokio timer, and returns success after the full duration. The asynchronous timer does not block the Gateway runtime.
Export the type from `src/tools/mod.rs` and register it in `create_default_tools`, making it available to the root agent and to constrained tool registries unless those registries explicitly filter it by name.
The tool does not persist state, create a background task, or send messages. It retains the `Tool` trait's default non-concurrency-safe classification, so a model response containing `sleep` and other calls executes that batch sequentially. `/stop`, supervisor shutdown, scheduler timeout, and sub-agent timeout cancel work by dropping the surrounding agent execution future; dropping that future also drops the current Tokio sleep timer. When a Turn is cancelled, every still-running tool block is normalized to `ToolStatus::Cancelled` before publishing the terminal snapshot.
The 24-hour cap bounds foreground resource retention. Longer or restart-durable waits must use Scheduler or background work rather than holding an interactive session worker.
## Testing
Unit tests cover the tool metadata and schema, immediate success for zero seconds, elapsed-time behavior using Tokio's paused clock, rejection of missing, negative, fractional, string, and over-24-hour values, acceptance of the 24-hour boundary, cancellation of an active sleeping task, and Turn cancellation normalization.
Run the targeted tests, `cargo test --lib`, `cargo clippy --all-targets --all-features -- -D warnings`, and `cargo build`.
## Out Of Scope
- Slash commands or direct user invocation
- Durable sleeps that survive process restart
- Delayed or scheduled message delivery
- A configurable duration limit
This change increments only the product patch version.

View File

@ -198,6 +198,10 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅
用于交互式程序和需要保持状态的长运行命令。`action` 支持 `spawn``write``read``kill``list``write/read/kill` 需要 `session_id`。Gateway 进程退出时 PTY manager 会清理子进程。
## sleep — 前台等待
参数 `seconds` 接受 086400 的整数。工具只暂停当前 Agent 工具调用,不持久化、不发送消息,也不保证跨进程重启继续;用户 `/stop`、Scheduler/SubAgent 超时和 Gateway shutdown 都会取消等待。超过 24 小时或需要可靠延迟执行时应使用 Scheduler。
## http_request / web_fetch — HTTP 和 Web 工具
`http_request` 支持 GET/POST/PUT/DELETE/PATCH、headers 和字符串 body`web_fetch` 提取 HTML/JSON 的可读文本。两者校验 URL 与 DNS 解析结果阻止回环、私网、link-local 和本地域名,并禁用自动重定向,以降低 SSRF 风险。

View File

@ -2611,6 +2611,7 @@ fn render_feishu_turn(snapshot: &TurnSnapshot) -> String {
let status = match status {
ToolStatus::Running => "执行中",
ToolStatus::Completed => "已完成",
ToolStatus::Cancelled => "已停止",
ToolStatus::Failed => "失败",
};
let mut section = format!("> 🔧 **{name}** · {status}");

View File

@ -316,6 +316,7 @@ fn tool_status_name(status: ToolStatus) -> &'static str {
match status {
ToolStatus::Running => "running",
ToolStatus::Completed => "completed",
ToolStatus::Cancelled => "cancelled",
ToolStatus::Failed => "failed",
}
}

View File

@ -89,6 +89,7 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) {
let status = match status {
ToolStatus::Running => "执行中",
ToolStatus::Completed => "已完成",
ToolStatus::Cancelled => "已停止",
ToolStatus::Failed => "失败",
};
lines.push(Line::from(Span::styled(

View File

@ -56,6 +56,7 @@ pub enum TurnPhase {
pub enum ToolStatus {
Running,
Completed,
Cancelled,
Failed,
}
@ -233,6 +234,18 @@ impl TurnControllerInner {
return false;
}
self.text_segment_open = false;
if status == TurnStatus::Cancelled {
for block in &mut self.state.blocks {
if let TurnBlock::Tool {
status: tool_status,
..
} = block
&& *tool_status == ToolStatus::Running
{
*tool_status = ToolStatus::Cancelled;
}
}
}
self.state.status = status;
self.state.phase = TurnPhase::Finalizing;
self.state.usage = usage;
@ -580,6 +593,34 @@ mod tests {
assert_eq!(snapshot.revision, 1);
}
#[test]
fn cancelling_turn_marks_running_tools_cancelled() {
let (controller, emitter, _receiver) = start();
emitter
.emit(TurnEvent::ToolStarted {
iteration: 0,
call: ToolCall {
id: "sleep-call".into(),
name: "sleep".into(),
arguments: serde_json::json!({"seconds": 60}),
},
})
.unwrap();
assert!(controller.cancel(Some("stopped by user".into())));
let snapshot = controller.snapshot();
assert_eq!(snapshot.status, TurnStatus::Cancelled);
assert!(matches!(
&snapshot.blocks[0],
TurnBlock::Tool {
id,
status: ToolStatus::Cancelled,
..
} if id == "sleep-call"
));
}
#[test]
fn failure_is_published_as_structured_terminal_state() {
let (controller, _emitter, _) = start();

View File

@ -21,6 +21,7 @@ pub mod registry;
pub mod reload_config;
pub mod schema;
pub mod send_message;
pub mod sleep;
pub mod todo;
pub mod traits;
pub mod web_fetch;
@ -44,6 +45,7 @@ pub use pty::{PtyManager, PtyTool};
pub use registry::ToolRegistry;
pub use reload_config::ReloadConfigTool;
pub use send_message::SendMessageTool;
pub use sleep::SleepTool;
pub use todo::TodoTool;
pub use traits::{
OutboundDelivery, OutboundMessenger, Tool, ToolExecutionContext, ToolResult,
@ -73,6 +75,7 @@ pub fn create_default_tools(
) -> anyhow::Result<ToolRegistry> {
let registry = ToolRegistry::new();
registry.register(CalculatorTool::new());
registry.register(SleepTool::new());
registry.register(FileReadTool::new());
registry.register(FileWriteTool::new());
registry.register(FileEditTool::new());

238
src/tools/sleep.rs Normal file
View File

@ -0,0 +1,238 @@
use super::traits::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::json;
use std::time::Duration;
const MAX_SLEEP_SECONDS: u64 = 86_400;
pub struct SleepTool;
impl SleepTool {
pub fn new() -> Self {
Self
}
}
impl Default for SleepTool {
fn default() -> Self {
Self::new()
}
}
fn parse_seconds(args: &serde_json::Value) -> Result<u64, String> {
let seconds = args
.get("seconds")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| "seconds must be a non-negative integer".to_string())?;
if seconds > MAX_SLEEP_SECONDS {
return Err(format!(
"seconds must not exceed {MAX_SLEEP_SECONDS} (24 hours)"
));
}
Ok(seconds)
}
#[async_trait]
impl Tool for SleepTool {
fn name(&self) -> &str {
"sleep"
}
fn description(&self) -> &str {
"Pause the current agent execution for a specified number of whole seconds."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"seconds": {
"type": "integer",
"minimum": 0,
"maximum": MAX_SLEEP_SECONDS,
"description": "Number of whole seconds to wait, up to 24 hours."
}
},
"required": ["seconds"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let seconds = match parse_seconds(&args) {
Ok(seconds) => seconds,
Err(error) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(error),
});
}
};
tokio::time::sleep(Duration::from_secs(seconds)).await;
Ok(ToolResult {
success: true,
output: format!("Slept for {seconds} second(s)."),
error: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::TurnEvent;
use crate::providers::ToolCall;
use crate::session::{ToolStatus, TurnBlock, TurnController, TurnStatus};
use crate::tools::Tool;
use serde_json::json;
use std::time::Duration;
#[test]
fn exposes_sleep_metadata_and_schema() {
let tool = SleepTool::new();
let schema = tool.parameters_schema();
assert_eq!(tool.name(), "sleep");
assert!(tool.description().contains("current agent execution"));
assert!(tool.description().contains("whole seconds"));
assert_eq!(schema["type"], "object");
assert_eq!(schema["required"], json!(["seconds"]));
assert_eq!(schema["properties"]["seconds"]["type"], "integer");
assert_eq!(schema["properties"]["seconds"]["minimum"], 0);
assert_eq!(
schema["properties"]["seconds"]["maximum"],
MAX_SLEEP_SECONDS
);
assert!(schema.get("additionalProperties").is_none());
assert!(!tool.read_only());
assert!(!tool.concurrency_safe());
assert!(!tool.exclusive());
}
#[tokio::test]
async fn zero_seconds_returns_exact_success() {
let result = SleepTool::new()
.execute(json!({"seconds": 0}))
.await
.unwrap();
assert!(result.success);
assert_eq!(result.output, "Slept for 0 second(s).");
assert_eq!(result.error, None);
}
#[tokio::test]
async fn rejects_invalid_seconds() {
let invalid_args = [
json!({}),
json!({"seconds": -1}),
json!({"seconds": 0.5}),
json!({"seconds": "1"}),
json!({"seconds": 18_446_744_073_709_552_000.0_f64}),
json!({"seconds": MAX_SLEEP_SECONDS + 1}),
];
for args in invalid_args {
let result = SleepTool::new().execute(args).await.unwrap();
assert!(!result.success);
assert!(result.output.is_empty());
assert!(result.error.is_some());
}
}
#[test]
fn accepts_24_hour_boundary() {
assert_eq!(
parse_seconds(&json!({"seconds": MAX_SLEEP_SECONDS})),
Ok(MAX_SLEEP_SECONDS)
);
}
#[tokio::test(start_paused = true)]
async fn waits_for_requested_seconds() {
let handle = tokio::spawn(async { SleepTool::new().execute(json!({"seconds": 2})).await });
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(1)).await;
tokio::task::yield_now().await;
assert!(!handle.is_finished());
tokio::time::advance(Duration::from_secs(1)).await;
tokio::task::yield_now().await;
assert!(handle.await.unwrap().unwrap().success);
}
#[tokio::test(start_paused = true)]
async fn waits_up_to_24_hour_boundary() {
let handle = tokio::spawn(async {
SleepTool::new()
.execute(json!({"seconds": MAX_SLEEP_SECONDS}))
.await
});
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(MAX_SLEEP_SECONDS - 1)).await;
tokio::task::yield_now().await;
assert!(!handle.is_finished());
tokio::time::advance(Duration::from_secs(1)).await;
tokio::task::yield_now().await;
assert!(handle.await.unwrap().unwrap().success);
}
#[tokio::test(start_paused = true)]
async fn cancellation_drops_an_active_sleep() {
let handle = tokio::spawn(async {
SleepTool::new()
.execute(json!({"seconds": MAX_SLEEP_SECONDS}))
.await
});
tokio::task::yield_now().await;
assert!(!handle.is_finished());
handle.abort();
assert!(handle.await.unwrap_err().is_cancelled());
}
#[tokio::test(start_paused = true)]
async fn user_cancellation_stops_sleep_and_terminalizes_its_tool_block() {
let (controller, emitter, receiver) =
TurnController::start("cli:test:sleep", "assistant-message");
emitter
.emit(TurnEvent::ToolStarted {
iteration: 0,
call: ToolCall {
id: "sleep-call".into(),
name: "sleep".into(),
arguments: json!({"seconds": MAX_SLEEP_SECONDS}),
},
})
.unwrap();
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
let handle = tokio::spawn(async move {
let tool = SleepTool::new();
tokio::select! {
result = tool.execute(json!({"seconds": MAX_SLEEP_SECONDS})) => {
result.unwrap();
false
}
_ = cancel_rx => {
controller.cancel(Some("stopped by user".into()));
true
}
}
});
tokio::task::yield_now().await;
drop(cancel_tx);
assert!(handle.await.unwrap());
let snapshot = receiver.borrow().clone();
assert_eq!(snapshot.status, TurnStatus::Cancelled);
assert!(matches!(
&snapshot.blocks[0],
TurnBlock::Tool {
id,
status: ToolStatus::Cancelled,
..
} if id == "sleep-call"
));
}
}

View File

@ -1,12 +1,12 @@
{
"name": "picobot-webui",
"version": "1.4.0",
"version": "1.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picobot-webui",
"version": "1.4.0",
"version": "1.4.1",
"dependencies": {
"bits-ui": "^2.0.0",
"dompurify": "^3.4.12",

View File

@ -1,7 +1,7 @@
{
"name": "picobot-webui",
"private": true,
"version": "1.4.0",
"version": "1.4.1",
"type": "module",
"engines": {
"node": ">=20"

View File

@ -13,7 +13,7 @@
}
function toolStatus(value) {
return ({ running: "执行中", completed: "已完成", failed: "失败" })[value] || value;
return ({ running: "执行中", completed: "已完成", cancelled: "已停止", failed: "失败" })[value] || value;
}
</script>