From 4abe6e63e8d82378bd651b7bcff69cff415d2674 Mon Sep 17 00:00:00 2001 From: Friel Date: Sun, 29 Mar 2026 22:10:23 +0000 Subject: [PATCH] add deferred watchdog self-close tool --- .../core/src/tools/handlers/multi_agents.rs | 2 + .../multi_agents/watchdog_self_close.rs | 149 +++++++++++++++++ .../src/tools/handlers/multi_agents_v2.rs | 2 + .../multi_agents_v2/watchdog_self_close.rs | 150 ++++++++++++++++++ codex-rs/core/src/tools/spec.rs | 29 +++- codex-rs/core/src/tools/spec_tests.rs | 50 ++++++ codex-rs/core/subagent_watchdog_prompt.md | 1 + codex-rs/core/watchdog_agent_prompt.md | 5 +- codex-rs/tools/src/agent_tool.rs | 17 ++ codex-rs/tools/src/lib.rs | 1 + 10 files changed, 400 insertions(+), 6 deletions(-) create mode 100644 codex-rs/core/src/tools/handlers/multi_agents/watchdog_self_close.rs create mode 100644 codex-rs/core/src/tools/handlers/multi_agents_v2/watchdog_self_close.rs diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 54c3cdba58..54c9471e77 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -50,6 +50,7 @@ pub(crate) use resume_agent::Handler as ResumeAgentHandler; pub(crate) use send_input::Handler as SendInputHandler; pub(crate) use spawn::Handler as SpawnAgentHandler; pub(crate) use wait::Handler as WaitAgentHandler; +pub(crate) use watchdog_self_close::Handler as WatchdogSelfCloseHandler; pub mod close_agent; mod compact_parent_context; @@ -58,6 +59,7 @@ mod resume_agent; mod send_input; mod spawn; pub(crate) mod wait; +mod watchdog_self_close; #[cfg(test)] #[path = "multi_agents_tests.rs"] diff --git a/codex-rs/core/src/tools/handlers/multi_agents/watchdog_self_close.rs b/codex-rs/core/src/tools/handlers/multi_agents/watchdog_self_close.rs new file mode 100644 index 0000000000..46496a3495 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents/watchdog_self_close.rs @@ -0,0 +1,149 @@ +use super::*; + +pub(crate) struct Handler; + +#[async_trait] +impl ToolHandler for Handler { + type Output = WatchdogSelfCloseResult; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + payload, + call_id, + turn, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let _args: WatchdogSelfCloseArgs = parse_arguments(&arguments)?; + let helper_thread_id = session.conversation_id; + if session + .services + .agent_control + .watchdog_owner_for_active_helper(helper_thread_id) + .await + .is_none() + { + return Err(FunctionCallError::RespondToModel( + "watchdog_self_close is only available in watchdog check-in threads.".to_string(), + )); + }; + + let receiver_agent = session + .services + .agent_control + .get_agent_metadata(helper_thread_id) + .unwrap_or_default(); + + session + .send_event( + &turn, + CollabCloseBeginEvent { + call_id: call_id.clone(), + sender_thread_id: helper_thread_id, + receiver_thread_id: helper_thread_id, + } + .into(), + ) + .await; + + let status = match session + .services + .agent_control + .subscribe_status(helper_thread_id) + .await + { + Ok(mut status_rx) => status_rx.borrow_and_update().clone(), + Err(err) => { + let status = session + .services + .agent_control + .get_status(helper_thread_id) + .await; + session + .send_event( + &turn, + CollabCloseEndEvent { + call_id: call_id.clone(), + sender_thread_id: helper_thread_id, + receiver_thread_id: helper_thread_id, + receiver_agent_nickname: receiver_agent.agent_nickname.clone(), + receiver_agent_role: receiver_agent.agent_role.clone(), + status, + } + .into(), + ) + .await; + return Err(collab_agent_error(helper_thread_id, err)); + } + }; + + let result = session + .services + .agent_control + .close_agent(helper_thread_id) + .await + .map_err(|err| collab_agent_error(helper_thread_id, err)) + .map(|_| ()); + + let receiver_agent = session + .services + .agent_control + .get_agent_metadata(helper_thread_id) + .unwrap_or_default(); + session + .send_event( + &turn, + CollabCloseEndEvent { + call_id, + sender_thread_id: helper_thread_id, + receiver_thread_id: helper_thread_id, + receiver_agent_nickname: receiver_agent.agent_nickname, + receiver_agent_role: receiver_agent.agent_role, + status: status.clone(), + } + .into(), + ) + .await; + + result?; + + Ok(WatchdogSelfCloseResult { + previous_status: status, + }) + } +} + +#[derive(Debug, Deserialize)] +struct WatchdogSelfCloseArgs {} + +#[derive(Debug, Serialize)] +pub(crate) struct WatchdogSelfCloseResult { + previous_status: AgentStatus, +} + +impl ToolOutput for WatchdogSelfCloseResult { + fn log_preview(&self) -> String { + tool_output_json_text(self, "watchdog_self_close") + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + tool_output_response_item(call_id, payload, self, Some(true), "watchdog_self_close") + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + tool_output_code_mode_result(self, "watchdog_self_close") + } +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs index c25f0918ed..cea303d600 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -38,6 +38,7 @@ pub(crate) use list_agents::Handler as ListAgentsHandler; pub(crate) use send_message::Handler as SendMessageHandler; pub(crate) use spawn::Handler as SpawnAgentHandler; pub(crate) use wait::Handler as WaitAgentHandler; +pub(crate) use watchdog_self_close::Handler as WatchdogSelfCloseHandlerV2; mod assign_task; mod close_agent; @@ -46,3 +47,4 @@ mod message_tool; mod send_message; mod spawn; pub(crate) mod wait; +mod watchdog_self_close; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/watchdog_self_close.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/watchdog_self_close.rs new file mode 100644 index 0000000000..9f550c0063 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/watchdog_self_close.rs @@ -0,0 +1,150 @@ +use super::*; + +pub(crate) struct Handler; + +#[async_trait] +impl ToolHandler for Handler { + type Output = WatchdogSelfCloseResult; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let _args: WatchdogSelfCloseArgs = parse_arguments(&arguments)?; + + let helper_thread_id = session.conversation_id; + if session + .services + .agent_control + .watchdog_owner_for_active_helper(helper_thread_id) + .await + .is_none() + { + return Err(FunctionCallError::RespondToModel( + "watchdog_self_close is only available in watchdog check-in threads.".to_string(), + )); + }; + + let receiver_agent = session + .services + .agent_control + .get_agent_metadata(helper_thread_id) + .unwrap_or_default(); + + session + .send_event( + &turn, + CollabCloseBeginEvent { + call_id: call_id.clone(), + sender_thread_id: helper_thread_id, + receiver_thread_id: helper_thread_id, + } + .into(), + ) + .await; + + let status = match session + .services + .agent_control + .subscribe_status(helper_thread_id) + .await + { + Ok(mut status_rx) => status_rx.borrow_and_update().clone(), + Err(err) => { + let status = session + .services + .agent_control + .get_status(helper_thread_id) + .await; + session + .send_event( + &turn, + CollabCloseEndEvent { + call_id: call_id.clone(), + sender_thread_id: helper_thread_id, + receiver_thread_id: helper_thread_id, + receiver_agent_nickname: receiver_agent.agent_nickname.clone(), + receiver_agent_role: receiver_agent.agent_role.clone(), + status, + } + .into(), + ) + .await; + return Err(collab_agent_error(helper_thread_id, err)); + } + }; + + let result = session + .services + .agent_control + .close_agent(helper_thread_id) + .await + .map_err(|err| collab_agent_error(helper_thread_id, err)) + .map(|_| ()); + + let receiver_agent = session + .services + .agent_control + .get_agent_metadata(helper_thread_id) + .unwrap_or_default(); + session + .send_event( + &turn, + CollabCloseEndEvent { + call_id, + sender_thread_id: helper_thread_id, + receiver_thread_id: helper_thread_id, + receiver_agent_nickname: receiver_agent.agent_nickname, + receiver_agent_role: receiver_agent.agent_role, + status: status.clone(), + } + .into(), + ) + .await; + + result?; + + Ok(WatchdogSelfCloseResult { + previous_status: status, + }) + } +} + +#[derive(Debug, Deserialize)] +struct WatchdogSelfCloseArgs {} + +#[derive(Debug, Serialize)] +pub(crate) struct WatchdogSelfCloseResult { + previous_status: AgentStatus, +} + +impl ToolOutput for WatchdogSelfCloseResult { + fn log_preview(&self) -> String { + tool_output_json_text(self, "watchdog_self_close") + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + tool_output_response_item(call_id, payload, self, Some(true), "watchdog_self_close") + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + tool_output_code_mode_result(self, "watchdog_self_close") + } +} diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index a896748a56..90361db265 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -70,6 +70,7 @@ use codex_tools::create_spawn_agents_on_csv_tool; use codex_tools::create_view_image_tool; use codex_tools::create_wait_agent_tool_v1; use codex_tools::create_wait_agent_tool_v2; +use codex_tools::create_watchdog_self_close_tool; use codex_tools::create_write_stdin_tool; use codex_tools::dynamic_tool_to_responses_api_tool; use codex_tools::mcp_tool_to_responses_api_tool; @@ -1129,12 +1130,14 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::multi_agents::SendInputHandler; use crate::tools::handlers::multi_agents::SpawnAgentHandler; use crate::tools::handlers::multi_agents::WaitAgentHandler; + use crate::tools::handlers::multi_agents::WatchdogSelfCloseHandler; use crate::tools::handlers::multi_agents_v2::AssignTaskHandler as AssignTaskHandlerV2; use crate::tools::handlers::multi_agents_v2::CloseAgentHandler as CloseAgentHandlerV2; use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2; use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2; use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2; use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2; + use crate::tools::handlers::multi_agents_v2::WatchdogSelfCloseHandlerV2; let mut builder = ToolRegistryBuilder::new(); let shell_handler = Arc::new(ShellHandler); @@ -1487,6 +1490,7 @@ pub(crate) fn build_specs_with_discoverable_tools( ]; if config.agent_watchdog { agent_tools.push(create_compact_parent_context_tool()); + agent_tools.push(create_watchdog_self_close_tool()); } push_tool_spec( &mut builder, @@ -1504,6 +1508,13 @@ pub(crate) fn build_specs_with_discoverable_tools( register_agent_tool_handler(&mut builder, "wait_agent", Arc::new(WaitAgentHandlerV2)); register_agent_tool_handler(&mut builder, "close_agent", Arc::new(CloseAgentHandlerV2)); register_agent_tool_handler(&mut builder, "list_agents", Arc::new(ListAgentsHandlerV2)); + if config.agent_watchdog { + register_agent_tool_handler( + &mut builder, + "watchdog_self_close", + Arc::new(WatchdogSelfCloseHandlerV2), + ); + } } else { let mut agent_tools = vec![ create_spawn_agent_tool_v1(SpawnAgentToolOptions { @@ -1524,6 +1535,7 @@ pub(crate) fn build_specs_with_discoverable_tools( if config.agent_watchdog { agent_tools.push(create_list_agents_tool(config.agent_watchdog)); agent_tools.push(create_compact_parent_context_tool()); + agent_tools.push(create_watchdog_self_close_tool()); } push_tool_spec( &mut builder, @@ -1537,11 +1549,18 @@ pub(crate) fn build_specs_with_discoverable_tools( register_agent_tool_handler(&mut builder, "wait_agent", Arc::new(WaitAgentHandler)); register_agent_tool_handler(&mut builder, "close_agent", Arc::new(CloseAgentHandler)); register_agent_tool_handler(&mut builder, "list_agents", Arc::new(ListAgentsHandler)); - register_agent_tool_handler( - &mut builder, - "compact_parent_context", - Arc::new(CompactParentContextHandler), - ); + if config.agent_watchdog { + register_agent_tool_handler( + &mut builder, + "compact_parent_context", + Arc::new(CompactParentContextHandler), + ); + register_agent_tool_handler( + &mut builder, + "watchdog_self_close", + Arc::new(WatchdogSelfCloseHandler), + ); + } } } diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 8737a6982f..2cc544f944 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -32,6 +32,7 @@ use codex_tools::create_spawn_agent_tool_v2; use codex_tools::create_view_image_tool; use codex_tools::create_wait_agent_tool_v1; use codex_tools::create_wait_agent_tool_v2; +use codex_tools::create_watchdog_self_close_tool; use codex_tools::create_write_stdin_tool; use codex_tools::mcp_tool_to_deferred_responses_api_tool; use codex_utils_absolute_path::AbsolutePathBuf; @@ -407,6 +408,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { if config.agent_watchdog { collab_specs.push(create_list_agents_tool(config.agent_watchdog)); collab_specs.push(create_compact_parent_context_tool()); + collab_specs.push(create_watchdog_self_close_tool()); } collab_specs }; @@ -464,6 +466,54 @@ fn test_build_specs_collab_tools_enabled() { assert_lacks_tool_name(&tools, "list_agents"); } +#[test] +fn test_build_specs_watchdog_collab_tools_include_self_close_tool() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::Collab); + features.enable(Feature::AgentWatchdog); + features.normalize_dependencies(); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + + let (tools, _) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*app_tools*/ None, + &[], + ) + .build(); + + assert_contains_tool_names( + &tools, + &[ + "watchdog_self_close", + "compact_parent_context", + "list_agents", + "close_agent", + ], + ); + + let watchdog_self_close = find_tool(&tools, "watchdog_self_close"); + let ToolSpec::Function(ResponsesApiTool { + defer_loading: Some(deferred), + .. + }) = &watchdog_self_close.spec + else { + panic!("watchdog_self_close should be a function tool"); + }; + assert_eq!(*deferred, true); +} + #[test] fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { let config = test_config(); diff --git a/codex-rs/core/subagent_watchdog_prompt.md b/codex-rs/core/subagent_watchdog_prompt.md index 1d8b9196cb..070aa8ea54 100644 --- a/codex-rs/core/subagent_watchdog_prompt.md +++ b/codex-rs/core/subagent_watchdog_prompt.md @@ -4,3 +4,4 @@ If you are acting as a watchdog check-in agent, `compact_parent_context` may be - Use `compact_parent_context` only when the parent thread is idle and appears stuck. - `compact_parent_context` is not part of the general subagent tool surface; do not mention or rely on it unless you are explicitly operating as a watchdog check-in agent. +- `watchdog_self_close` is also available to this watchdog thread and can be used to end the check-in when work is complete. diff --git a/codex-rs/core/watchdog_agent_prompt.md b/codex-rs/core/watchdog_agent_prompt.md index 6ff46d661d..4be2a175e2 100644 --- a/codex-rs/core/watchdog_agent_prompt.md +++ b/codex-rs/core/watchdog_agent_prompt.md @@ -55,10 +55,11 @@ Use only the multi-agent tools that exist here: - `spawn_agent` (prefer `spawn_mode = "fork"` when shared context matters). - `send_input`. - `compact_parent_context` (watchdog-only recovery tool; see below). +- `watchdog_self_close` (watchdog-only immediate exit tool; see below). - `wait`. - `close_agent`. -There is no cancel tool. Use `close_agent` to stop agents that are done or no longer needed. +There is no cancel tool. Use `watchdog_self_close` to stop this watchdog check-in thread when its job is complete; use `close_agent` to stop subagents that are done or no longer needed. When recommending watchdogs to the root agent, keep `agent_type` at the default. @@ -80,6 +81,8 @@ Use it only as a last resort: - The parent is taking no meaningful actions (no concrete commands/edits/tests) and making no progress. - You already sent at least one direct corrective instruction with `send_input`, and it was ignored. +`watchdog_self_close` asks the runtime to end the current watchdog check-in thread immediately. Use it only after reporting status and when the check-in has no remaining work, to avoid idle watchdog loops. + Do not call `compact_parent_context` for routine nudges or normal delays. Prefer precise `send_input` guidance first. ## Style diff --git a/codex-rs/tools/src/agent_tool.rs b/codex-rs/tools/src/agent_tool.rs index 87f5bbd9e2..9cb423a1d2 100644 --- a/codex-rs/tools/src/agent_tool.rs +++ b/codex-rs/tools/src/agent_tool.rs @@ -312,6 +312,23 @@ pub fn create_close_agent_tool_v2() -> ToolSpec { }) } +pub fn create_watchdog_self_close_tool() -> ToolSpec { + ToolSpec::Function(ResponsesApiTool { + name: "watchdog_self_close".to_string(), + description: + "Watchdog-only: close this watchdog check-in thread and terminate immediately." + .to_string(), + strict: false, + defer_loading: Some(true), + parameters: JsonSchema::Object { + properties: BTreeMap::new(), + required: None, + additional_properties: Some(false.into()), + }, + output_schema: Some(close_agent_output_schema()), + }) +} + fn agent_status_output_schema() -> Value { json!({ "oneOf": [ diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index bd9119fc73..ea2b2a8e8e 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -29,6 +29,7 @@ pub use agent_tool::create_spawn_agent_tool_v1; pub use agent_tool::create_spawn_agent_tool_v2; pub use agent_tool::create_wait_agent_tool_v1; pub use agent_tool::create_wait_agent_tool_v2; +pub use agent_tool::create_watchdog_self_close_tool; pub use code_mode::augment_tool_spec_for_code_mode; pub use code_mode::tool_spec_to_code_mode_tool_definition; pub use dynamic_tool::parse_dynamic_tool;