PicoBot/src/tools/sleep.rs
xiaoxixi 5501c539fc feat: remove agent run groups, add WebUI agent definition management
- drop agent_run_groups table and group_id/scope_kind/scope_id columns (schema v8)
- remove group_id from AgentExecutionContext and recovery group counters
- flatten TasksPage background tab into a per-run list
- add WebUI Agents page with definition CRUD and inline provider/model
- bump version to 1.11.0
2026-08-13 14:03:01 +08:00

521 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use super::traits::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::json;
use std::time::Duration;
use crate::agent::steering::{TurnWakeupState, WakeupSource};
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 input_interrupt_policy(&self) -> crate::tools::InputInterruptPolicy {
crate::tools::InputInterruptPolicy::WakeOnly
}
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> {
self.execute_with_context(&crate::tools::ToolExecutionContext::default(), args)
.await
.map(|output| output.result)
}
async fn execute_with_context(
&self,
context: &crate::tools::ToolExecutionContext,
args: serde_json::Value,
) -> anyhow::Result<crate::tools::ToolOutput> {
let seconds = match parse_seconds(&args) {
Ok(seconds) => seconds,
Err(error) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(error),
}
.into());
}
};
let started = std::time::Instant::now();
let mut wakeup_rx = context
.turn_wakeup
.as_ref()
.map(|handle| handle.receiver.clone());
// Root interactive Turn: if inputs are already pending, do not wait
// at all. The watch revision is monotonic, so an input arriving
// between this check and the select below still fires `changed()`.
if let Some(rx) = wakeup_rx.as_mut() {
let state = rx.borrow_and_update();
if state.pending_total() > 0 {
return Ok(ToolResult {
success: true,
output: wake_message(&state, started.elapsed(), 0),
error: None,
}
.into());
}
}
let outcome = match wakeup_rx.as_mut() {
Some(rx) => {
tokio::select! {
biased;
_ = context.cancellation.cancelled() => {
anyhow::bail!("sleep cancelled");
}
_ = tokio::time::sleep(Duration::from_secs(seconds)) => {
WakeOutcome::Elapsed
}
changed = rx.changed() => {
let _ = changed;
let state = rx.borrow_and_update();
WakeOutcome::InputArrived(state.clone())
}
}
}
// Child runs and continuation Turns have no session input lane:
// their sleep answers only the timer, run cancellation, timeout
// and shutdown.
None => {
tokio::select! {
biased;
_ = context.cancellation.cancelled() => {
anyhow::bail!("sleep cancelled");
}
_ = tokio::time::sleep(Duration::from_secs(seconds)) => {
WakeOutcome::Elapsed
}
}
}
};
let output = match outcome {
WakeOutcome::Elapsed => format!("Slept for {seconds} second(s)."),
WakeOutcome::InputArrived(state) => wake_message(&state, started.elapsed(), seconds),
};
Ok(ToolResult {
success: true,
output,
error: None,
}
.into())
}
}
enum WakeOutcome {
Elapsed,
InputArrived(TurnWakeupState),
}
/// Build the model-visible wake message. Steer wakes describe the source,
/// run identity and a safe preview; queue wakes only state the type/count and
/// explicitly promise the content stays out of the current Turn.
fn wake_message(state: &TurnWakeupState, waited: std::time::Duration, planned: u64) -> String {
let waited_secs = waited.as_secs();
let mut message = format!("Sleep 提前结束:已等待 {waited_secs}");
if planned > 0 {
message.push_str(&format!("(原计划 {planned} 秒)"));
}
message.push('。');
match &state.latest_source {
Some(WakeupSource::UserSteer) => {
message.push_str(" 收到一条新的用户输入,将在当前 Turn 的下一个安全边界注入。");
}
Some(WakeupSource::UserQueue) => {
message.push_str(&format!(
" 收到 {} 条排队输入。内容不会进入当前 Turn将在当前工作结束后的下一 Turn处理。",
state.pending_user_queue.max(1)
));
}
Some(WakeupSource::AgentSignal { run_id, agent_id }) => {
message.push_str(&format!(
" 收到一条 steer AgentSignalrun_id={run_id}, agent={agent_id}"
));
if let Some(preview) = state.latest_safe_preview.as_deref() {
message.push_str(&format!("{preview}"));
}
message.push_str("。该信号将在当前 Turn 的下一个安全边界注入。");
}
Some(WakeupSource::AgentCompletion { run_id, agent_id }) => {
message.push_str(&format!(
" 收到一条 steer AgentCompletionrun_id={run_id}, agent={agent_id}),将在当前 Turn 的下一个安全边界注入。"
));
}
Some(WakeupSource::AgentQueue) | None => {
message.push_str(&format!(
" 收到 {} 条排队输入。内容不会进入当前 Turn将在当前工作结束后的下一 Turn处理。",
state.pending_agent_queue.max(1)
));
}
}
message
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::TurnEvent;
use crate::agent::steering::TurnWakeupPublisher;
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());
assert_eq!(
tool.input_interrupt_policy(),
crate::tools::InputInterruptPolicy::WakeOnly
);
}
#[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"
));
}
#[tokio::test(start_paused = true)]
async fn cancellation_token_ends_sleep_before_timer() {
let context = crate::tools::ToolExecutionContext::default();
let token = context.cancellation.clone();
let handle = tokio::spawn(async move {
SleepTool::new()
.execute_with_context(&context, json!({"seconds": MAX_SLEEP_SECONDS}))
.await
});
tokio::task::yield_now().await;
assert!(!handle.is_finished());
token.cancel();
tokio::task::yield_now().await;
let error = handle.await.unwrap().unwrap_err();
assert!(error.to_string().contains("cancelled"));
}
#[tokio::test(start_paused = true)]
async fn pre_cancelled_context_never_enters_sleep() {
let context = crate::tools::ToolExecutionContext::default();
context.cancellation.cancel();
let error = SleepTool::new()
.execute_with_context(&context, json!({"seconds": 60}))
.await
.unwrap_err();
assert!(error.to_string().contains("cancelled"));
}
#[tokio::test(start_paused = true)]
async fn pending_input_before_listen_returns_immediately() {
let publisher = TurnWakeupPublisher::new();
let handle = publisher.subscribe();
publisher.publish(TurnWakeupState {
pending_user_steer: 1,
latest_source: Some(WakeupSource::UserSteer),
..Default::default()
});
let context = crate::tools::ToolExecutionContext::default().with_turn_wakeup(handle);
let result = SleepTool::new()
.execute_with_context(&context, json!({"seconds": 3600}))
.await
.unwrap();
assert!(result.result.success);
assert!(result.result.output.contains("提前结束"));
assert!(result.result.output.contains("用户输入"));
}
#[tokio::test(start_paused = true)]
async fn steer_publish_wakes_sleep_with_source_and_preview() {
let publisher = TurnWakeupPublisher::new();
let handle = publisher.subscribe();
let context = crate::tools::ToolExecutionContext::default().with_turn_wakeup(handle);
let tool = SleepTool::new();
let wait = tokio::spawn(async move {
tool.execute_with_context(&context, json!({"seconds": 3600}))
.await
.unwrap()
.result
.output
});
tokio::task::yield_now().await;
assert!(!wait.is_finished());
publisher.publish(TurnWakeupState {
pending_agent_steer: 1,
latest_source: Some(WakeupSource::AgentSignal {
run_id: "run-123".to_string(),
agent_id: "monitor".to_string(),
}),
latest_safe_preview: Some("服务错误率超过 5%".to_string()),
..Default::default()
});
tokio::task::yield_now().await;
let output = wait.await.unwrap();
assert!(output.contains("提前结束"));
assert!(output.contains("run-123"));
assert!(output.contains("服务错误率超过 5%"));
assert!(output.contains("安全边界注入"));
}
#[tokio::test(start_paused = true)]
async fn queue_publish_wakes_sleep_without_content() {
let publisher = TurnWakeupPublisher::new();
let handle = publisher.subscribe();
let context = crate::tools::ToolExecutionContext::default().with_turn_wakeup(handle);
let tool = SleepTool::new();
let wait = tokio::spawn(async move {
tool.execute_with_context(&context, json!({"seconds": 3600}))
.await
.unwrap()
.result
.output
});
tokio::task::yield_now().await;
publisher.publish(TurnWakeupState {
pending_agent_queue: 1,
latest_source: Some(WakeupSource::AgentQueue),
..Default::default()
});
tokio::task::yield_now().await;
let output = wait.await.unwrap();
assert!(output.contains("排队输入"));
assert!(output.contains("不会进入当前 Turn"));
assert!(!output.contains("run-"));
}
#[tokio::test(start_paused = true)]
async fn child_sleep_without_handle_is_not_woken_by_publishes() {
let publisher = TurnWakeupPublisher::new();
let _handle = publisher.subscribe();
let context = crate::tools::ToolExecutionContext::default();
let tool = SleepTool::new();
let wait = tokio::spawn(async move {
tool.execute_with_context(&context, json!({"seconds": 30}))
.await
.unwrap()
.result
.output
});
tokio::task::yield_now().await;
publisher.publish(TurnWakeupState {
pending_agent_steer: 1,
latest_source: Some(WakeupSource::AgentSignal {
run_id: "run-9".to_string(),
agent_id: "a".to_string(),
}),
..Default::default()
});
tokio::task::yield_now().await;
assert!(!wait.is_finished());
tokio::time::advance(Duration::from_secs(30)).await;
tokio::task::yield_now().await;
assert!(wait.await.unwrap().contains("Slept for 30"));
}
#[tokio::test(start_paused = true)]
async fn pre_listen_publish_does_not_lose_the_wake() {
// Publish BEFORE the sleep subscribes its own receiver: watch keeps
// the latest value, so the borrow_and_update pre-check sees it.
let publisher = TurnWakeupPublisher::new();
let handle = publisher.subscribe();
publisher.publish(TurnWakeupState {
pending_agent_queue: 2,
latest_source: Some(WakeupSource::AgentQueue),
..Default::default()
});
let context = crate::tools::ToolExecutionContext::default().with_turn_wakeup(handle);
let result = SleepTool::new()
.execute_with_context(&context, json!({"seconds": 3600}))
.await
.unwrap();
assert!(result.result.success);
assert!(result.result.output.contains("排队输入"));
}
}