add deferred watchdog self-close tool

This commit is contained in:
Friel
2026-03-29 22:10:23 +00:00
parent 63b1bb2a8e
commit 4abe6e63e8
10 changed files with 400 additions and 6 deletions

View File

@@ -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"]

View File

@@ -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<Self::Output, FunctionCallError> {
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")
}
}

View File

@@ -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;

View File

@@ -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<Self::Output, FunctionCallError> {
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")
}
}

View File

@@ -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),
);
}
}
}

View File

@@ -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();

View File

@@ -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.

View File

@@ -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

View File

@@ -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": [

View File

@@ -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;