[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:
pakrym-oai
2026-06-15 21:39:21 -07:00
committed by GitHub
parent 022f1221e8
commit 08901fc8e1
37 changed files with 1099 additions and 19 deletions
+6
View File
@@ -617,6 +617,9 @@
"skill_mcp_dependency_install": {
"type": "boolean"
},
"sleep_tool": {
"type": "boolean"
},
"sqlite": {
"type": "boolean"
},
@@ -4772,6 +4775,9 @@
"skill_mcp_dependency_install": {
"type": "boolean"
},
"sleep_tool": {
"type": "boolean"
},
"sqlite": {
"type": "boolean"
},
+2
View File
@@ -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;
+131
View File
@@ -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 {}
+5
View File
@@ -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(
+188 -11
View File
@@ -4,12 +4,15 @@ use std::sync::Arc;
use codex_core::CodexThread;
use codex_features::Feature;
use codex_protocol::AgentPath;
use codex_protocol::items::SleepItem;
use codex_protocol::items::TurnItem;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::user_input::UserInput;
use core_test_support::context_snapshot;
use core_test_support::context_snapshot::ContextSnapshotOptions;
@@ -65,6 +68,34 @@ fn message_input_texts(body: &Value, role: &str) -> Vec<String> {
.collect()
}
fn function_call_output_text<'a>(body: &'a Value, call_id: &str) -> Option<&'a str> {
body.get("input")
.and_then(Value::as_array)?
.iter()
.find(|item| {
item.get("type").and_then(Value::as_str) == Some("function_call_output")
&& item.get("call_id").and_then(Value::as_str) == Some(call_id)
})?
.get("output")?
.as_str()
}
fn assert_interrupted_sleep_output(output: Option<&str>) {
let Some(output) = output else {
panic!("sleep output missing");
};
let Some(wall_time) = output
.strip_prefix("Wall time: ")
.and_then(|output| output.strip_suffix(" seconds\nSleep interrupted by new input."))
else {
panic!("sleep output should include wall time");
};
assert!(
wall_time.parse::<f64>().is_ok(),
"sleep wall time should be a number"
);
}
fn chunk(event: Value) -> StreamingSseChunk {
StreamingSseChunk {
gate: None,
@@ -207,6 +238,54 @@ async fn wait_for_turn_complete(codex: &CodexThread) {
wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
}
async fn wait_for_sleep_item_started(codex: &CodexThread, call_id: &str, duration_ms: u64) {
let event = wait_for_event(codex, |event| {
matches!(
event,
EventMsg::ItemStarted(started)
if matches!(&started.item, TurnItem::Sleep(item) if item.id == call_id)
)
})
.await;
let EventMsg::ItemStarted(started) = event else {
unreachable!("wait predicate only accepts item/started events");
};
let TurnItem::Sleep(item) = started.item else {
unreachable!("wait predicate only accepts sleep items");
};
assert_eq!(
item,
SleepItem {
id: call_id.to_string(),
duration_ms,
}
);
}
async fn wait_for_sleep_item_completed(codex: &CodexThread, call_id: &str, duration_ms: u64) {
let event = wait_for_event(codex, |event| {
matches!(
event,
EventMsg::ItemCompleted(completed)
if matches!(&completed.item, TurnItem::Sleep(item) if item.id == call_id)
)
})
.await;
let EventMsg::ItemCompleted(completed) = event else {
unreachable!("wait predicate only accepts item/completed events");
};
let TurnItem::Sleep(item) = completed.item else {
unreachable!("wait predicate only accepts sleep items");
};
assert_eq!(
item,
SleepItem {
id: call_id.to_string(),
duration_ms,
}
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn steer_interrupts_wait_agent_and_is_sent_in_follow_up_request() {
const WAIT_CALL_ID: &str = "wait-call";
@@ -257,17 +336,7 @@ async fn steer_interrupts_wait_agent_and_is_sent_in_follow_up_request() {
relevant_user_input,
vec![INITIAL_PROMPT.to_string(), STEER_PROMPT.to_string()]
);
let wait_output = second["input"]
.as_array()
.expect("second request input")
.iter()
.find(|item| {
item.get("type").and_then(Value::as_str) == Some("function_call_output")
&& item.get("call_id").and_then(Value::as_str) == Some(WAIT_CALL_ID)
})
.and_then(|item| item.get("output"))
.and_then(Value::as_str)
.expect("wait_agent output");
let wait_output = function_call_output_text(&second, WAIT_CALL_ID).expect("wait_agent output");
assert_eq!(
serde_json::from_str::<Value>(wait_output).expect("parse wait_agent output"),
json!({
@@ -279,6 +348,114 @@ async fn steer_interrupts_wait_agent_and_is_sent_in_follow_up_request() {
server.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn any_new_input_interrupts_sleep() {
const FIRST_SLEEP_CALL_ID: &str = "sleep-call-1";
const SECOND_SLEEP_CALL_ID: &str = "sleep-call-2";
const SLEEP_DURATION_MS: u64 = 3_600_000;
const INITIAL_PROMPT: &str = "sleep for a while";
const STEER_PROMPT: &str = "stop sleeping and continue";
let sleep_arguments = json!({ "duration_ms": SLEEP_DURATION_MS }).to_string();
let first_chunks = vec![
chunk(ev_response_created("resp-1")),
chunk(ev_function_call(
FIRST_SLEEP_CALL_ID,
"sleep",
&sleep_arguments,
)),
chunk(ev_completed("resp-1")),
];
let second_chunks = vec![
chunk(ev_response_created("resp-2")),
chunk(ev_function_call(
SECOND_SLEEP_CALL_ID,
"sleep",
&sleep_arguments,
)),
chunk(ev_completed("resp-2")),
];
let (server, _completions) = start_streaming_sse_server(vec![
first_chunks,
second_chunks,
response_completed_chunks("resp-3"),
])
.await;
let codex = test_codex()
.with_model("gpt-5.4")
.with_config(|config| {
config
.features
.enable(Feature::SleepTool)
.expect("test config should allow feature update");
})
.build_with_streaming_server(&server)
.await
.expect("build Codex test session")
.codex;
submit_user_input(&codex, INITIAL_PROMPT).await;
wait_for_sleep_item_started(&codex, FIRST_SLEEP_CALL_ID, SLEEP_DURATION_MS).await;
steer_user_input(&codex, STEER_PROMPT).await;
wait_for_sleep_item_completed(&codex, FIRST_SLEEP_CALL_ID, SLEEP_DURATION_MS).await;
wait_for_sleep_item_started(&codex, SECOND_SLEEP_CALL_ID, SLEEP_DURATION_MS).await;
submit_queue_only_agent_mail(&codex, "new mailbox input").await;
wait_for_sleep_item_completed(&codex, SECOND_SLEEP_CALL_ID, SLEEP_DURATION_MS).await;
wait_for_turn_complete(&codex).await;
let requests = server.requests().await;
assert_eq!(requests.len(), 3);
let second: Value = from_slice(&requests[1]).expect("parse second request");
let relevant_user_input = message_input_texts(&second, "user")
.into_iter()
.filter(|text| text == INITIAL_PROMPT || text == STEER_PROMPT)
.collect::<Vec<_>>();
assert_eq!(
relevant_user_input,
vec![INITIAL_PROMPT.to_string(), STEER_PROMPT.to_string()]
);
assert_interrupted_sleep_output(function_call_output_text(&second, FIRST_SLEEP_CALL_ID));
let third: Value = from_slice(&requests[2]).expect("parse third request");
assert_interrupted_sleep_output(function_call_output_text(&third, SECOND_SLEEP_CALL_ID));
codex.submit(Op::Shutdown).await.expect("shutdown session");
wait_for_event(&codex, |event| matches!(event, EventMsg::ShutdownComplete)).await;
let rollout_path = codex.rollout_path().expect("rollout path");
let rollout = tokio::fs::read_to_string(rollout_path)
.await
.expect("read rollout");
let persisted_sleep_items = rollout
.lines()
.filter_map(|line| serde_json::from_str::<RolloutLine>(line).ok())
.filter_map(|line| match line.item {
RolloutItem::EventMsg(EventMsg::ItemCompleted(event)) => match event.item {
TurnItem::Sleep(item) => Some(item),
_ => None,
},
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
persisted_sleep_items,
vec![
SleepItem {
id: FIRST_SLEEP_CALL_ID.to_string(),
duration_ms: SLEEP_DURATION_MS,
},
SleepItem {
id: SECOND_SLEEP_CALL_ID.to_string(),
duration_ms: SLEEP_DURATION_MS,
},
]
);
server.shutdown().await;
}
fn assert_two_responses_input_snapshot(snapshot_name: &str, requests: &[Vec<u8>]) {
assert_eq!(requests.len(), 2);
let options = ContextSnapshotOptions::default().strip_capability_instructions();