mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] Add interruptible sleep tool (#28429)
## Why Models sometimes need to pause briefly while waiting for external work, but using a shell command for that delay ties the wait to a process and does not naturally resume when new turn input arrives. ## What changed - add a built-in `sleep` tool behind the under-development `sleep_tool` feature - accept a bounded `duration_ms` argument, matching the millisecond convention used by unified exec - end the sleep early when either steered user input or mailbox input arrives - include elapsed wall-clock time in completed and interrupted outputs - emit a dedicated core `SleepItem` through `item/started` and `item/completed` - expose the sleep item as app-server v2 `ThreadItem::Sleep` and retain it in reconstructed thread history - regenerate the configuration schema for the new feature flag - regenerate app-server JSON and TypeScript schema fixtures ## Test plan - `just test -p codex-core sleep_tool_follows_feature_gate` - `just test -p codex-core any_new_input_interrupts_sleep` - `just test -p codex-app-server-protocol` - `just test -p codex-app-server sleep_emits_started_and_completed_items`
This commit is contained in:
committed by
GitHub
Unverified
parent
022f1221e8
commit
08901fc8e1
@@ -26,6 +26,7 @@ mod request_user_input;
|
||||
pub(crate) mod request_user_input_spec;
|
||||
mod shell;
|
||||
pub(crate) mod shell_spec;
|
||||
mod sleep;
|
||||
mod test_sync;
|
||||
pub(crate) mod test_sync_spec;
|
||||
mod tool_search;
|
||||
@@ -68,6 +69,7 @@ pub use request_plugin_install::RequestPluginInstallHandler;
|
||||
pub use request_user_input::RequestUserInputHandler;
|
||||
pub use shell::ShellCommandHandler;
|
||||
pub(crate) use shell::ShellCommandHandlerOptions;
|
||||
pub use sleep::SleepHandler;
|
||||
pub use test_sync::TestSyncHandler;
|
||||
pub(crate) use tool_search::ToolSearchHandlerCache;
|
||||
pub use unified_exec::ExecCommandHandler;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::context::boxed_tool_output;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::registry::CoreToolRuntime;
|
||||
use crate::tools::registry::ToolExecutor;
|
||||
use codex_protocol::items::SleepItem;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_tools::JsonSchema;
|
||||
use codex_tools::ResponsesApiTool;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSpec;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
const SLEEP_TOOL_NAME: &str = "sleep";
|
||||
const MAX_SLEEP_DURATION_MS: u64 = 3_600_000;
|
||||
|
||||
pub struct SleepHandler;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SleepArgs {
|
||||
duration_ms: u64,
|
||||
}
|
||||
|
||||
fn create_sleep_tool() -> ToolSpec {
|
||||
let properties = BTreeMap::from([(
|
||||
"duration_ms".to_string(),
|
||||
JsonSchema::number(Some(format!(
|
||||
"How long to sleep in milliseconds. Must be between 1 and {MAX_SLEEP_DURATION_MS}."
|
||||
))),
|
||||
)]);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: SLEEP_TOOL_NAME.to_string(),
|
||||
description: "Pause execution for a specified duration. The sleep ends early when new input arrives for the active turn. Returns the elapsed wall-clock time."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::object(
|
||||
properties,
|
||||
Some(vec!["duration_ms".to_string()]),
|
||||
/*additional_properties*/ Some(false.into()),
|
||||
),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
impl ToolExecutor<ToolInvocation> for SleepHandler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain(SLEEP_TOOL_NAME)
|
||||
}
|
||||
|
||||
fn spec(&self) -> ToolSpec {
|
||||
create_sleep_tool()
|
||||
}
|
||||
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(async move {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
call_id,
|
||||
payload,
|
||||
..
|
||||
} = invocation;
|
||||
let ToolPayload::Function { arguments } = payload else {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"{SLEEP_TOOL_NAME} handler received unsupported payload"
|
||||
)));
|
||||
};
|
||||
let args: SleepArgs = parse_arguments(&arguments)?;
|
||||
if !(1..=MAX_SLEEP_DURATION_MS).contains(&args.duration_ms) {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"duration_ms must be between 1 and {MAX_SLEEP_DURATION_MS}"
|
||||
)));
|
||||
}
|
||||
|
||||
let started = Instant::now();
|
||||
let item = TurnItem::Sleep(SleepItem {
|
||||
id: call_id,
|
||||
duration_ms: args.duration_ms,
|
||||
});
|
||||
session.emit_turn_item_started(turn.as_ref(), &item).await;
|
||||
let turn_state = session
|
||||
.input_queue
|
||||
.turn_state_for_sub_id(&session.active_turn, &turn.sub_id)
|
||||
.await;
|
||||
let (mut activity_rx, pending_activity) = session
|
||||
.input_queue
|
||||
.subscribe_activity(turn_state.as_deref())
|
||||
.await;
|
||||
let interrupted = if pending_activity.is_some() {
|
||||
true
|
||||
} else {
|
||||
let sleep = tokio::time::sleep(Duration::from_millis(args.duration_ms));
|
||||
tokio::pin!(sleep);
|
||||
tokio::select! {
|
||||
() = &mut sleep => false,
|
||||
result = activity_rx.changed() => {
|
||||
if result.is_ok() {
|
||||
true
|
||||
} else {
|
||||
sleep.await;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
session.emit_turn_item_completed(turn.as_ref(), item).await;
|
||||
|
||||
let message = if interrupted {
|
||||
"Sleep interrupted by new input."
|
||||
} else {
|
||||
"Sleep completed."
|
||||
};
|
||||
let wall_time_seconds = started.elapsed().as_secs_f64();
|
||||
Ok(boxed_tool_output(FunctionToolOutput::from_text(
|
||||
format!("Wall time: {wall_time_seconds:.4} seconds\n{message}"),
|
||||
/*success*/ Some(true),
|
||||
)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl CoreToolRuntime for SleepHandler {}
|
||||
@@ -22,6 +22,7 @@ use crate::tools::handlers::RequestPluginInstallHandler;
|
||||
use crate::tools::handlers::RequestUserInputHandler;
|
||||
use crate::tools::handlers::ShellCommandHandler;
|
||||
use crate::tools::handlers::ShellCommandHandlerOptions;
|
||||
use crate::tools::handlers::SleepHandler;
|
||||
use crate::tools::handlers::TestSyncHandler;
|
||||
use crate::tools::handlers::ToolSearchHandlerCache;
|
||||
use crate::tools::handlers::ViewImageHandler;
|
||||
@@ -666,6 +667,10 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut
|
||||
planned_tools.add(GetContextRemainingHandler);
|
||||
}
|
||||
|
||||
if features.enabled(Feature::SleepTool) {
|
||||
planned_tools.add(SleepHandler);
|
||||
}
|
||||
|
||||
if tool_suggest_enabled(turn_context)
|
||||
&& let Some(discoverable_tools) =
|
||||
context.discoverable_tools.filter(|tools| !tools.is_empty())
|
||||
|
||||
@@ -672,6 +672,21 @@ async fn host_context_gates_agent_job_tools() {
|
||||
worker_agent_job.assert_visible_contains(&["spawn_agents_on_csv", "report_agent_job_result"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sleep_tool_follows_feature_gate() {
|
||||
let disabled = probe(|turn| {
|
||||
set_feature(turn, Feature::SleepTool, /*enabled*/ false);
|
||||
})
|
||||
.await;
|
||||
disabled.assert_visible_lacks(&["sleep"]);
|
||||
|
||||
let enabled = probe(|turn| {
|
||||
set_feature(turn, Feature::SleepTool, /*enabled*/ true);
|
||||
})
|
||||
.await;
|
||||
enabled.assert_visible_contains(&["sleep"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() {
|
||||
let direct_mcp = probe_with(
|
||||
|
||||
Reference in New Issue
Block a user