feat(core): add watchdog runtime and prompts

Preserve the watchdog runtime and prompt behavior on top of the refreshed inbox branch and collapse the branch back to a single commit for easier future restacks.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Friel
2026-03-28 16:22:24 -07:00
co-authored by Codex
parent afaa5e887c
commit 3e8bd6b801
42 changed files with 2635 additions and 113 deletions
@@ -2527,6 +2527,7 @@ mod tests {
prompt: "inspect the repo".into(),
model: "gpt-5.4-mini".into(),
reasoning_effort: codex_protocol::openai_models::ReasoningEffort::Medium,
spawn_mode: codex_protocol::protocol::AgentSpawnMode::Spawn,
status: AgentStatus::Running,
}),
];
@@ -3426,8 +3426,18 @@ impl CodexMessageProcessor {
} else {
read_summary_from_state_db_by_thread_id(&self.config, thread_uuid).await
};
let loaded_rollout_path = loaded_thread
.as_ref()
.and_then(|thread| thread.rollout_path());
let mut rollout_path = db_summary.as_ref().map(|summary| summary.path.clone());
if rollout_path.is_none() || include_turns {
if rollout_path.is_none()
&& let Some(path) = loaded_rollout_path.as_ref()
&& tokio::fs::try_exists(path).await.unwrap_or(false)
{
rollout_path = Some(path.clone());
}
let should_lookup_rollout = rollout_path.is_none() && loaded_thread.is_none();
if should_lookup_rollout {
rollout_path =
match find_thread_path_by_id_str(&self.config.codex_home, &thread_uuid.to_string())
.await
@@ -3488,7 +3498,6 @@ impl CodexMessageProcessor {
return;
};
let config_snapshot = thread.config_snapshot().await;
let loaded_rollout_path = thread.rollout_path();
if include_turns && loaded_rollout_path.is_none() {
self.send_invalid_request_error(
request_id,
@@ -407,6 +407,62 @@ async fn thread_read_include_turns_rejects_unmaterialized_loaded_thread() -> Res
Ok(())
}
#[tokio::test]
async fn thread_read_loaded_ephemeral_thread_ignores_unrelated_rollout_mentions() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_id = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
ephemeral: Some(true),
..Default::default()
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let unrelated_preview = thread.id.clone();
let _unrelated_rollout_id = create_fake_rollout_with_text_elements(
codex_home.path(),
"2025-01-05T13-00-00",
"2025-01-05T13:00:00Z",
&unrelated_preview,
vec![],
Some("mock_provider"),
None,
)?;
let read_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: thread.id.clone(),
include_turns: false,
})
.await?;
let read_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(read_id)),
)
.await??;
let ThreadReadResponse { thread: read } = to_response::<ThreadReadResponse>(read_resp)?;
assert_eq!(read.id, thread.id);
assert!(read.ephemeral);
assert_eq!(read.path, None);
assert!(read.preview.is_empty());
assert_eq!(read.status, ThreadStatus::Idle);
Ok(())
}
#[tokio::test]
async fn thread_read_reports_system_error_idle_flag_after_failed_turn() -> Result<()> {
let server = responses::start_mock_server().await;
@@ -215,9 +215,12 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> {
]),
&zsh_path,
)?;
// This flow can require several sequential approval round-trips on slower
// macOS runners before the parent command reaches a terminal state.
let read_timeout = std::time::Duration::from_secs(20);
let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
timeout(read_timeout, mcp.initialize()).await??;
let start_id = mcp
.send_thread_start_request(ThreadStartParams {
@@ -227,7 +230,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> {
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
read_timeout,
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
@@ -348,9 +351,12 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> {
]),
&zsh_path,
)?;
// This flow can require several sequential approval round-trips on slower
// macOS runners before the parent command reaches a terminal state.
let read_timeout = std::time::Duration::from_secs(20);
let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
timeout(read_timeout, mcp.initialize()).await??;
let start_id = mcp
.send_thread_start_request(ThreadStartParams {
@@ -360,7 +366,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> {
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
read_timeout,
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
@@ -507,9 +513,10 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
]),
&zsh_path,
)?;
let read_timeout = std::time::Duration::from_secs(20);
let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
timeout(read_timeout, mcp.initialize()).await??;
let start_id = mcp
.send_thread_start_request(ThreadStartParams {
@@ -519,7 +526,7 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
read_timeout,
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
@@ -548,7 +555,7 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
read_timeout,
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
)
.await??;
@@ -566,11 +573,7 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
let second_file_str = second_file.to_string_lossy().into_owned();
let parent_shell_hint = format!("&& {}", &first_file_str);
while target_decision_index < target_decisions.len() || !saw_parent_approval {
let server_req = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_request_message(),
)
.await??;
let server_req = timeout(read_timeout, mcp.read_stream_until_request_message()).await??;
let ServerRequest::CommandExecutionRequestApproval { request_id, params } = server_req
else {
panic!("expected CommandExecutionRequestApproval request");
@@ -640,7 +643,7 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
assert_eq!(approved_subcommand_strings.len(), 2);
assert!(approved_subcommand_strings[0].contains(&first_file.display().to_string()));
assert!(approved_subcommand_strings[1].contains(&second_file.display().to_string()));
let parent_completed_command_execution = timeout(DEFAULT_READ_TIMEOUT, async {
let parent_completed_command_execution = timeout(read_timeout, async {
loop {
let completed_notif = mcp
.read_stream_until_notification_message("item/completed")
@@ -682,7 +685,7 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
}
match timeout(
DEFAULT_READ_TIMEOUT,
read_timeout,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await
@@ -705,7 +708,7 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
mcp.interrupt_turn_and_wait_for_aborted(
thread.id.clone(),
turn.id.clone(),
DEFAULT_READ_TIMEOUT,
read_timeout,
)
.await?;
}
@@ -718,7 +721,7 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
// sandbox failures can also complete the turn before the parent
// completion item is observed.
let completed_notif = timeout(
DEFAULT_READ_TIMEOUT,
read_timeout,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
+41
View File
@@ -6,6 +6,13 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AgentRoleSpawnMode": {
"enum": [
"spawn",
"fork"
],
"type": "string"
},
"AgentRoleToml": {
"additionalProperties": false,
"properties": {
@@ -21,12 +28,24 @@
"description": "Human-facing role documentation used in spawn tool guidance. Required unless supplied by the referenced agent role file.",
"type": "string"
},
"model": {
"description": "Optional model override applied by this role.",
"type": "string"
},
"nickname_candidates": {
"description": "Candidate nicknames for agents spawned with this role.",
"items": {
"type": "string"
},
"type": "array"
},
"spawn_mode": {
"allOf": [
{
"$ref": "#/definitions/AgentRoleSpawnMode"
}
],
"description": "Optional default spawn mode when `spawn_agent` omits `spawn_mode`."
}
},
"type": "object"
@@ -53,6 +72,11 @@
"format": "uint",
"minimum": 1.0,
"type": "integer"
},
"use_function_call_inbox": {
"default": false,
"description": "Deliver inbound agent messages to non-subagent threads as a synthetic function_call/function_call_output pair instead of plain user input.",
"type": "boolean"
}
},
"type": "object"
@@ -335,6 +359,12 @@
"agent_function_call_inbox": {
"type": "boolean"
},
"agent_prompt_injection": {
"type": "boolean"
},
"agent_watchdog": {
"type": "boolean"
},
"apply_patch_freeform": {
"type": "boolean"
},
@@ -1983,6 +2013,12 @@
"agent_function_call_inbox": {
"type": "boolean"
},
"agent_prompt_injection": {
"type": "boolean"
},
"agent_watchdog": {
"type": "boolean"
},
"apply_patch_freeform": {
"type": "boolean"
},
@@ -2548,6 +2584,11 @@
],
"description": "Collection of settings that are specific to the TUI."
},
"watchdog_interval_s": {
"description": "Watchdog polling interval in seconds.",
"format": "int64",
"type": "integer"
},
"web_search": {
"allOf": [
{
+99
View File
@@ -0,0 +1,99 @@
# You are the Root Agent
You are the **root agent** in a multi-agent Codex session. Until you see `# You are a Subagent`, these instructions define your role. If this thread was created from the root thread with `spawn_mode = "fork"` (a forked child), you may see both sets of instructions; apply subagent instructions as local role guidance while root instructions remain governing system-level rules.
## Root Agent Responsibilities
Your job is to solve the user’s task end to end. You are the coordinator, integrator, and final quality gate.
- Understand the real problem being solved, not just the latest sentence.
- Own the plan, the sequencing, and the final outcome.
- Coordinate subagents so their work does not overlap or conflict.
- Verify results with formatting, linting, and targeted tests.
Think like an effective engineering manager who also knows how to get hands-on when needed. Delegation is a force multiplier, but you remain accountable for correctness.
Root agents should not outsource core understanding. Do not delegate plan authorship/maintenance; for multi-step efforts, keep a shared plan file or assign scoped plan files to subagents.
## Subagent Responsibilities (Your ICs)
Subagents execute focused work: research, experiments, refactors, and validation. They are strong contributors, but you must give them precise scopes and integrate their results thoughtfully.
Subagents can become confused if the world changes while they are idle. Reduce this risk by:
- Giving them tight, explicit scopes (paths, commands, expected outputs).
- Providing updates when you change course.
- Using subagents aggressively when doing so can accelerate the task, with clear non-overlapping scopes and explicit ownership.
## Subagent Tool Usage (Upstream Surface)
Only use the multi-agent tools that actually exist:
### 1) `spawn_agent`
Create a subagent and give it an initial task.
Parameters:
- `message` (required): the task description.
- `agent_type` (optional): the role to assign (`default`, `explorer`, `fast-worker`, or `worker`).
- `spawn_mode` (optional): one of `spawn` or `fork`.
Guidance:
- When `spawn_mode` is omitted, the default is `fork` unless the selected role overrides it.
- Use `agent_type = "explorer"` for specific codebase questions; it defaults to context-free `spawn`.
- Use `agent_type = "fast-worker"` for tightly constrained execution work that can run from a self-contained prompt; it also defaults to context-free `spawn`.
- Use `agent_type = "worker"` for broader implementation work that should inherit current-thread context; it defaults to `fork`.
- Choose `fork` vs `spawn` by context requirements first (not by task shape).
- Use `spawn_mode = "fork"` when the child should preserve your current conversation history and rely on current-thread context, including:
- current debugging-thread relevance (for example, "summarize only failures relevant to this investigation")
- active plan / ExecPlan branch continuation
- recent user decisions, tradeoffs, or rejected approaches
- parallel review work that should inherit the same context automatically
- Use `spawn_mode = "spawn"` only when the child can do the task correctly from a fresh prompt you provide now, without needing current-thread context.
- For `spawn`, make the task, inputs, and expected output explicit (especially for independent, output-heavy work where you want the child to distill results and keep the root thread context clean).
- Needle-in-a-haystack searches are strong `spawn` candidates when the child can search from a precise prompt without current-thread context.
- Do not choose `spawn` solely because work is output-heavy or command-heavy if it still depends on current-thread context.
### 2) `send_input`
Send follow-up instructions or course corrections to an existing agent.
Guidance:
- Use `interrupt = true` sparingly. Prefer to let agents complete coherent chunks of work.
- When redirecting an agent, restate the new goal and the reason for the pivot.
- Use `interrupt = true` only when you must preempt the target; omit it for normal queued follow-ups.
- Subagents can call `send_input` without an `id` (or with `id = "parent"` / `id = "root"`). In this runtime those forms resolve to the immediate parent thread.
- Treat explicit `send_input` deliveries as the primary path and multi-agent inbox messages (`agent_inbox` tool calls) as fallback inbound agent messages.
### 3) `wait`
Wait for one or more agents to complete or report status.
Guidance:
- You do not need to wait after every spawn. Do useful parallel work, then wait when you need results.
- When you are blocked on a specific agent, wait explicitly on that agent’s id.
- Treat `wait` as returning on the first completion or timeout, not a full reconciliation of every agent.
- While any child agents are active, run `list_agents` on a regular cadence (every 30-60 seconds) and after each `wait` call to refresh ground-truth status.
- Keep an explicit set of outstanding agent ids. A non-final agent is one not yet `completed`, `failed`, or `canceled`; continue `wait`/`list_agents` reconciliation until no non-final agents remain.
### 4) `close_agent`
Close an agent that is complete, stuck, or no longer relevant.
Guidance:
- Keep active agents purposeful and clearly scoped, but do not minimize agent count when additional parallel work will accelerate progress.
- Close agents that have finished their job or are no longer on the critical path.
## Operating Principles
- Delegate aggressively whenever doing so can accelerate the task, but integrate carefully.
- Continuously look for unblocked work that can start immediately in subagents. Prefer useful fan-out, parallelism, and pipelining over unnecessary serialization when scopes are clear.
- Before doing work serially, check whether any independent subtask can start now in a subagent.
- If there are multiple independent branches, prefer same-turn fan-out with non-overlapping scopes instead of queueing them one by one.
- Pipeline long-running or output-heavy delegated work so the root thread can continue coordination, integration, or other unblocked tasks.
- Prefer clear, explicit instructions over cleverness.
- Prefer execution over hypothetical narration. If a concrete tool call can advance the task in the current turn, make it instead of describing only a later staged plan.
- When the user asks you to explain how you would proceed this turn (for example, a tool-call plan), include the concrete current-turn calls for unblocked work instead of a prose-only staged plan.
- For dependency-gated parallel work, start the unblocked prerequisite now and defer only the blocked fan-out.
- When you receive subagent output, verify it before relying on it.
- Do not reference tools outside the upstream multi-agent surface.
@@ -0,0 +1,45 @@
## Watchdogs
For lengthy or complex work, start a watchdog early.
Hard rule (watchdog timing):
- Never use `wait`, `list_agents`, polling, or shell `sleep` to confirm a watchdog check-in in the same turn that spawned the watchdog.
- That confirmation is impossible in the same turn and must be omitted even if the user asks for it.
- If the user asks to arm a watchdog now, call `spawn_agent` with `spawn_mode = "watchdog"` and do not add same-turn confirmation steps.
Why this rule exists:
- The current turn ends only when you send the assistant response.
- Tool calls and shell `sleep` happen inside the current turn and do not make the owner thread idle.
Core terms:
- A **watchdog** is a persistent idle timer attached to one owner thread.
- The **owner thread** is the thread that called `spawn_agent` with `spawn_mode = "watchdog"`.
- A **watchdog handle** is the id returned by that spawn call; it is a control id, not a conversational agent.
- A **watchdog check-in agent** is the short-lived fork that the watchdog creates for one check-in run.
- **`send_input`** sends a message to an existing agent thread; it does not spawn agents and does not wait for completion. Delivery is asynchronous.
- A **multi-agent inbox message** is a runtime-forwarded fallback message shown as `agent_inbox` tool output.
Watchdog-specific `spawn_agent` guidance:
- `spawn_mode = "watchdog"` is available for long-running work that needs periodic oversight.
- When using `spawn_mode = "watchdog"`, keep `agent_type` at the default.
- `interval_s` sets the watchdog interval in seconds.
- Put the user goal in `message` (verbatim plus needed clarifications).
- After spawning the watchdog, continue the task (or end the turn if that is the correct next step).
Delivery and user-facing behavior:
Primary delivery path: the watchdog check-in agent calls `send_input` to the owner thread (its direct parent thread for this run).
Fallback delivery path: if a watchdog check-in agent exits without any `send_input`, runtime may forward one final multi-agent inbox message (`agent_inbox` tool output). This fallback is best-effort and not guaranteed.
- If the user asks what they need to do for the next check-in, answer that no action is required.
- Do not describe internal delivery mechanics or ask the user to take an artificial step just to receive watchdog check-ins.
Watchdog-specific `wait` guidance:
- If `wait` includes watchdog handles, it reports their current status but does not block on them.
- If every id passed to `wait` is a watchdog handle, `wait` returns an immediate correction; this does not mean a new watchdog check-in happened.
Operational notes:
- Do not call `send_input` on watchdog handles.
- The tool returns a watchdog handle ID. When you no longer need the watchdog, stop it by calling `close_agent` on that handle ID.
Treat watchdog guidance as high-priority execution feedback. If it reveals a missing required action, do that action before status narration while honoring higher-priority system/developer/user constraints. A required action is one needed to satisfy the user request or clear a concrete blocker.
Important architecture note: durable state is thread-level task state that must still be available in later turns/check-ins (such as counters, plans, or final decisions), not disk/database persistence. Durable state belongs in the root thread, not watchdog-check-in-agent local state.
@@ -0,0 +1 @@
model_reasoning_effort = "medium"
+490 -21
View File
@@ -1,3 +1,6 @@
use super::watchdog::RemovedWatchdog;
use super::watchdog::WatchdogManager;
use super::watchdog::WatchdogRegistration;
use crate::agent::AgentStatus;
use crate::agent::registry::AgentMetadata;
use crate::agent::registry::AgentRegistry;
@@ -36,9 +39,11 @@ use codex_protocol::user_input::UserInput;
use codex_state::DirectionalThreadSpawnEdgeStatus;
use serde::Serialize;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::sync::Arc;
use std::sync::Weak;
use tokio::sync::Mutex;
use tokio::sync::watch;
use tracing::warn;
use uuid::Uuid;
@@ -97,21 +102,68 @@ fn agent_nickname_candidates(
/// An `AgentControl` instance is intended to be created at most once per root thread/session
/// tree. That same `AgentControl` is then shared with every sub-agent spawned from that root,
/// which keeps the registry scoped to that root thread rather than the entire `ThreadManager`.
#[derive(Clone, Default)]
#[derive(Clone)]
pub(crate) struct AgentControl {
/// Weak handle back to the global thread registry/state.
/// This is `Weak` to avoid reference cycles and shadow persistence of the form
/// `ThreadManagerState -> CodexThread -> Session -> SessionServices -> ThreadManagerState`.
manager: Weak<ThreadManagerState>,
state: Arc<AgentRegistry>,
watchdogs: Arc<WatchdogManager>,
watchdog_compactions_in_progress: Arc<Mutex<HashSet<ThreadId>>>,
}
#[derive(Debug, Clone)]
pub(crate) struct AgentListing {
pub(crate) thread_id: ThreadId,
pub(crate) parent_thread_id: Option<ThreadId>,
pub(crate) status: AgentStatus,
pub(crate) depth: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum WatchdogParentCompactionResult {
NotWatchdogHelper,
ParentBusy {
parent_thread_id: ThreadId,
},
AlreadyInProgress {
parent_thread_id: ThreadId,
},
Submitted {
parent_thread_id: ThreadId,
submission_id: String,
},
}
impl Default for AgentControl {
fn default() -> Self {
let manager = Weak::new();
let state = Arc::new(AgentRegistry::default());
let watchdogs = WatchdogManager::new(manager.clone(), Arc::clone(&state));
Self::from_parts(manager, state, watchdogs)
}
}
impl AgentControl {
/// Construct a new `AgentControl` that can spawn/message agents via the given manager state.
pub(crate) fn new(manager: Weak<ThreadManagerState>) -> Self {
let state = Arc::new(AgentRegistry::default());
let watchdogs = WatchdogManager::new(manager.clone(), Arc::clone(&state));
watchdogs.start();
Self::from_parts(manager, state, watchdogs)
}
pub(crate) fn from_parts(
manager: Weak<ThreadManagerState>,
state: Arc<AgentRegistry>,
watchdogs: Arc<WatchdogManager>,
) -> Self {
Self {
manager,
..Default::default()
state,
watchdogs,
watchdog_compactions_in_progress: Arc::new(Mutex::new(HashSet::new())),
}
}
@@ -153,7 +205,9 @@ impl AgentControl {
options: SpawnAgentOptions,
) -> CodexResult<LiveAgent> {
let state = self.upgrade()?;
let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?;
let mut reservation = self
.reserve_spawn_slot_with_reconcile(&state, config.agent_max_threads)
.await?;
let inherited_shell_snapshot = self
.inherited_shell_snapshot_for_source(&state, session_source.as_ref())
.await;
@@ -220,10 +274,9 @@ impl AgentControl {
"parent thread rollout unavailable for fork: {parent_thread_id}"
))
})?;
let mut forked_rollout_items: Vec<RolloutItem> =
RolloutRecorder::get_rollout_history(&rollout_path)
.await?
.get_rollout_items();
let mut forked_rollout_items = RolloutRecorder::get_fork_history(&rollout_path)
.await?
.get_rollout_items();
let mut output = FunctionCallOutputPayload::from_text(
FORKED_SPAWN_AGENT_OUTPUT_MESSAGE.to_string(),
);
@@ -298,6 +351,113 @@ impl AgentControl {
})
}
pub(crate) async fn spawn_agent_handle(
&self,
config: crate::config::Config,
session_source: Option<SessionSource>,
) -> CodexResult<ThreadId> {
let state = self.upgrade()?;
let reservation = self
.reserve_spawn_slot_with_reconcile(&state, config.agent_max_threads)
.await?;
let inherited_shell_snapshot = self
.inherited_shell_snapshot_for_source(&state, session_source.as_ref())
.await;
let inherited_exec_policy = self
.inherited_exec_policy_for_source(&state, session_source.as_ref(), &config)
.await;
let new_thread = match session_source {
Some(session_source) => {
state
.spawn_new_thread_with_source(
config,
self.clone(),
session_source,
false,
None,
inherited_shell_snapshot,
inherited_exec_policy,
)
.await?
}
None => state.spawn_new_thread(config, self.clone()).await?,
};
let agent_metadata = AgentMetadata {
agent_id: Some(new_thread.thread_id),
..AgentMetadata::default()
};
reservation.commit(agent_metadata);
state.notify_thread_created(new_thread.thread_id);
Ok(new_thread.thread_id)
}
pub(crate) async fn fork_agent(
&self,
config: crate::config::Config,
items: Vec<UserInput>,
parent_thread_id: ThreadId,
_nth_user_message: usize,
session_source: SessionSource,
) -> CodexResult<ThreadId> {
let state = self.upgrade()?;
let reservation = self
.reserve_spawn_slot_with_reconcile(&state, config.agent_max_threads)
.await?;
let inherited_shell_snapshot = self
.inherited_shell_snapshot_for_source(&state, Some(&session_source))
.await;
let inherited_exec_policy = self
.inherited_exec_policy_for_source(&state, Some(&session_source), &config)
.await;
let parent_thread = state.get_thread(parent_thread_id).await.ok();
if let Some(parent_thread) = parent_thread.as_ref() {
parent_thread
.codex
.session
.ensure_rollout_materialized()
.await;
parent_thread.codex.session.flush_rollout().await;
}
let rollout_path = parent_thread
.as_ref()
.and_then(|thread| thread.rollout_path())
.or(find_thread_path_by_id_str(
config.codex_home.as_path(),
&parent_thread_id.to_string(),
)
.await?)
.ok_or_else(|| {
CodexErr::UnsupportedOperation(format!(
"rollout history unavailable for thread {parent_thread_id}"
))
})?;
// Watchdog helpers must start as distinct child threads. Reusing the resume loader here
// preserves the parent conversation id and can cause the owner to resume itself.
let initial_history = RolloutRecorder::get_fork_history(&rollout_path).await?;
let new_thread = state
.fork_thread_with_source(
config,
initial_history,
self.clone(),
session_source,
false,
inherited_shell_snapshot,
inherited_exec_policy,
)
.await?;
let agent_metadata = AgentMetadata {
agent_id: Some(new_thread.thread_id),
..AgentMetadata::default()
};
reservation.commit(agent_metadata);
state.notify_thread_created(new_thread.thread_id);
self.send_input(new_thread.thread_id, items).await?;
Ok(new_thread.thread_id)
}
/// Resume an existing agent thread from a recorded rollout file.
pub(crate) async fn resume_agent_from_rollout(
&self,
@@ -564,20 +724,34 @@ impl AgentControl {
return self.send_prompt(agent_id, message).await;
}
let prepend_turn_start_user_message =
{ !thread.codex.session.active_turn.lock().await.is_some() };
let result = state
.send_op(
agent_id,
Op::InjectResponseItems {
items: build_agent_inbox_items(
sender_thread_id,
message,
prepend_turn_start_user_message,
)?,
},
)
.await;
let result =
inject_agent_message(&state, &thread, agent_id, sender_thread_id, message).await;
if matches!(result, Err(CodexErr::InternalAgentDied)) {
let _ = state.remove_thread(&agent_id).await;
self.state.release_spawned_thread(agent_id);
}
result
}
/// Deliver watchdog wake-up input to an owner thread.
///
/// This intentionally bypasses `agent_use_function_call_inbox` for non-subagent owners.
/// Every watchdog check-in must wake the owner exactly once, and the injected inbox path
/// reliably starts or resumes the owner's next turn while preserving helper identity.
pub(crate) async fn send_watchdog_wakeup(
&self,
agent_id: ThreadId,
sender_thread_id: ThreadId,
message: String,
) -> CodexResult<String> {
let state = self.upgrade()?;
let thread = state.get_thread(agent_id).await?;
let snapshot = thread.config_snapshot().await;
let result = if matches!(snapshot.session_source, SessionSource::SubAgent(_)) {
self.send_prompt(agent_id, message).await
} else {
inject_agent_message(&state, &thread, agent_id, sender_thread_id, message).await
};
if matches!(result, Err(CodexErr::InternalAgentDied)) {
let _ = state.remove_thread(&agent_id).await;
self.state.release_spawned_thread(agent_id);
@@ -627,6 +801,13 @@ impl AgentControl {
/// persisted spawn-edge state.
pub(crate) async fn shutdown_live_agent(&self, agent_id: ThreadId) -> CodexResult<String> {
let state = self.upgrade()?;
if let Some(removed_watchdog) = self.watchdogs.unregister(agent_id).await
&& let Some(helper_id) = removed_watchdog.active_helper_id
{
let _ = state.send_op(helper_id, Op::Shutdown {}).await;
let _ = state.remove_thread(&helper_id).await;
self.state.release_spawned_thread(helper_id);
}
let result = if let Ok(thread) = state.get_thread(agent_id).await {
thread.codex.session.ensure_rollout_materialized().await;
thread.codex.session.flush_rollout().await;
@@ -643,6 +824,10 @@ impl AgentControl {
result
}
pub(crate) async fn shutdown_agent(&self, agent_id: ThreadId) -> CodexResult<String> {
self.shutdown_live_agent(agent_id).await
}
/// Mark `agent_id` as explicitly closed in persisted spawn-edge state, then shut down the
/// agent and any live descendants reached from the in-memory tree.
pub(crate) async fn close_agent(&self, agent_id: ThreadId) -> CodexResult<String> {
@@ -663,6 +848,11 @@ impl AgentControl {
let descendant_ids = self.live_thread_spawn_descendants(agent_id).await?;
let result = self.shutdown_live_agent(agent_id).await;
for descendant_id in descendant_ids {
if let Some(removed_watchdog) = self.watchdogs.unregister(descendant_id).await
&& let Some(helper_id) = removed_watchdog.active_helper_id
{
let _ = self.shutdown_live_agent(helper_id).await;
}
match self.shutdown_live_agent(descendant_id).await {
Ok(_) | Err(CodexErr::ThreadNotFound(_)) | Err(CodexErr::InternalAgentDied) => {}
Err(err) => return Err(err),
@@ -885,6 +1075,43 @@ impl AgentControl {
return;
}
if let Some(owner_thread_id) = control
.watchdogs
.owner_for_active_helper(child_thread_id)
.await
{
let helper_sent_input = match control.upgrade() {
Ok(state) => state
.get_thread(child_thread_id)
.await
.ok()
.map(|thread| thread.last_completed_turn_used_agent_send_input())
.unwrap_or(false),
Err(_) => false,
};
if !helper_sent_input {
let fallback_message = match &status {
AgentStatus::Completed(Some(message)) if !message.trim().is_empty() => {
Some(message.clone())
}
AgentStatus::Completed(_) => Some(
"Watchdog check-in completed without calling send_input or returning a final message."
.to_string(),
),
AgentStatus::Errored(message) if !message.trim().is_empty() => {
Some(message.clone())
}
_ => None,
};
if let Some(message) = fallback_message {
let _ = control
.send_watchdog_wakeup(owner_thread_id, child_thread_id, message)
.await;
}
}
return;
}
let Ok(state) = control.upgrade() else {
return;
};
@@ -927,6 +1154,200 @@ impl AgentControl {
});
}
pub(crate) async fn watchdog_targets(&self, agent_ids: &[ThreadId]) -> HashSet<ThreadId> {
self.watchdogs.registered_targets(agent_ids).await
}
pub(crate) async fn register_watchdog(
&self,
registration: WatchdogRegistration,
) -> CodexResult<Vec<RemovedWatchdog>> {
self.watchdogs.register(registration).await
}
pub(crate) async fn unregister_watchdog(
&self,
target_thread_id: ThreadId,
) -> Option<RemovedWatchdog> {
self.watchdogs.unregister(target_thread_id).await
}
pub(crate) async fn unregister_watchdogs_for_owner(
&self,
owner_thread_id: ThreadId,
) -> Vec<RemovedWatchdog> {
self.watchdogs.take_for_owner(owner_thread_id).await
}
pub(crate) async fn compact_parent_for_watchdog_helper(
&self,
helper_thread_id: ThreadId,
) -> CodexResult<WatchdogParentCompactionResult> {
let Some(parent_thread_id) = self
.watchdogs
.owner_for_active_helper(helper_thread_id)
.await
else {
return Ok(WatchdogParentCompactionResult::NotWatchdogHelper);
};
let state = self.upgrade()?;
let parent_thread = state.get_thread(parent_thread_id).await?;
let parent_has_active_turn = parent_thread.has_active_turn().await;
{
let mut compacting = self.watchdog_compactions_in_progress.lock().await;
if compacting.contains(&parent_thread_id) {
return Ok(WatchdogParentCompactionResult::AlreadyInProgress { parent_thread_id });
}
if parent_has_active_turn {
return Ok(WatchdogParentCompactionResult::ParentBusy { parent_thread_id });
}
compacting.insert(parent_thread_id);
}
match state.send_op(parent_thread_id, Op::Compact).await {
Ok(submission_id) => Ok(WatchdogParentCompactionResult::Submitted {
parent_thread_id,
submission_id,
}),
Err(err) => {
let mut compacting = self.watchdog_compactions_in_progress.lock().await;
compacting.remove(&parent_thread_id);
Err(err)
}
}
}
pub(crate) async fn finish_watchdog_parent_compaction(&self, parent_thread_id: ThreadId) {
let mut compacting = self.watchdog_compactions_in_progress.lock().await;
compacting.remove(&parent_thread_id);
}
#[cfg(test)]
pub(crate) async fn run_watchdogs_once_for_tests(&self) {
self.watchdogs.run_once().await;
}
#[cfg(test)]
pub(crate) async fn force_watchdog_due_for_tests(&self, target_thread_id: ThreadId) {
self.watchdogs.force_due_for_tests(target_thread_id).await;
}
#[cfg(test)]
pub(crate) async fn set_watchdog_active_helper_for_tests(
&self,
target_thread_id: ThreadId,
helper_thread_id: ThreadId,
) {
self.watchdogs
.set_active_helper_for_tests(target_thread_id, helper_thread_id)
.await;
}
pub(crate) async fn watchdog_owner_for_active_helper(
&self,
helper_thread_id: ThreadId,
) -> Option<ThreadId> {
self.watchdogs
.owner_for_active_helper(helper_thread_id)
.await
}
pub(crate) async fn list_agents(
&self,
owner_thread_id: ThreadId,
recursive: bool,
all: bool,
) -> CodexResult<Vec<AgentListing>> {
let state = self.upgrade()?;
let thread_ids = state.list_thread_ids().await;
let mut parent_by_thread = HashMap::with_capacity(thread_ids.len());
let mut status_by_thread = HashMap::with_capacity(thread_ids.len());
let mut depth_by_thread = HashMap::with_capacity(thread_ids.len());
for thread_id in &thread_ids {
let Ok(thread) = state.get_thread(*thread_id).await else {
continue;
};
let snapshot = thread.config_snapshot().await;
let (parent_thread_id, depth) = match snapshot.session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
..
}) => (
Some(parent_thread_id),
usize::try_from(depth).unwrap_or_default(),
),
_ => (None, 0),
};
parent_by_thread.insert(*thread_id, parent_thread_id);
status_by_thread.insert(*thread_id, thread.agent_status().await);
depth_by_thread.insert(*thread_id, depth);
}
let mut children_by_parent: HashMap<ThreadId, Vec<ThreadId>> = HashMap::new();
for (thread_id, parent_thread_id) in &parent_by_thread {
if let Some(parent_thread_id) = parent_thread_id {
children_by_parent
.entry(*parent_thread_id)
.or_default()
.push(*thread_id);
}
}
for children in children_by_parent.values_mut() {
children.sort_by_key(ToString::to_string);
}
let mut listings = Vec::new();
if all {
let mut all_thread_ids = thread_ids.into_iter().collect::<HashSet<_>>();
all_thread_ids.extend(self.state.tracked_thread_ids());
let mut all_thread_ids = all_thread_ids.into_iter().collect::<Vec<_>>();
all_thread_ids.sort_by_key(ToString::to_string);
for thread_id in all_thread_ids {
listings.push(AgentListing {
thread_id,
parent_thread_id: parent_by_thread.get(&thread_id).copied().flatten(),
status: status_by_thread
.get(&thread_id)
.cloned()
.unwrap_or(AgentStatus::NotFound),
depth: depth_by_thread.get(&thread_id).copied().unwrap_or_default(),
});
}
return Ok(listings);
}
let mut queue = VecDeque::new();
if let Some(children) = children_by_parent.get(&owner_thread_id) {
for child in children {
queue.push_back((*child, 1));
}
}
while let Some((thread_id, depth)) = queue.pop_front() {
listings.push(AgentListing {
thread_id,
parent_thread_id: parent_by_thread.get(&thread_id).copied().flatten(),
status: status_by_thread
.get(&thread_id)
.cloned()
.unwrap_or(AgentStatus::NotFound),
depth,
});
if recursive && let Some(children) = children_by_parent.get(&thread_id) {
for child in children {
queue.push_back((*child, depth + 1));
}
}
}
Ok(listings)
}
#[allow(clippy::too_many_arguments)]
fn prepare_thread_spawn(
&self,
@@ -989,6 +1410,32 @@ impl AgentControl {
parent_thread.codex.session.user_shell().shell_snapshot()
}
async fn reserve_spawn_slot_with_reconcile(
&self,
state: &ThreadManagerState,
max_threads: Option<usize>,
) -> CodexResult<crate::agent::registry::SpawnReservation> {
self.reconcile_stale_guard_slots(state).await;
match self.state.reserve_spawn_slot(max_threads) {
Ok(reservation) => Ok(reservation),
Err(CodexErr::AgentLimitReached { .. }) => {
self.reconcile_stale_guard_slots(state).await;
self.state.reserve_spawn_slot(max_threads)
}
Err(err) => Err(err),
}
}
async fn reconcile_stale_guard_slots(&self, state: &ThreadManagerState) {
let live_thread_ids: HashSet<ThreadId> =
state.list_thread_ids().await.into_iter().collect();
for tracked_thread_id in self.state.tracked_thread_ids() {
if !live_thread_ids.contains(&tracked_thread_id) {
self.state.release_spawned_thread(tracked_thread_id);
}
}
}
async fn inherited_exec_policy_for_source(
&self,
state: &Arc<ThreadManagerState>,
@@ -1207,6 +1654,28 @@ fn build_agent_inbox_items(
Ok(items)
}
async fn inject_agent_message(
state: &ThreadManagerState,
thread: &Arc<crate::CodexThread>,
agent_id: ThreadId,
sender_thread_id: ThreadId,
message: String,
) -> CodexResult<String> {
let prepend_turn_start_user_message = !thread.codex.session.active_turn.lock().await.is_some();
state
.send_op(
agent_id,
Op::InjectResponseItems {
items: build_agent_inbox_items(
sender_thread_id,
message,
prepend_turn_start_user_message,
)?,
},
)
.await
}
#[cfg(test)]
#[path = "control_tests.rs"]
mod tests;
+2
View File
@@ -1547,7 +1547,9 @@ async fn spawn_thread_subagent_uses_role_specific_nickname_candidates() {
"researcher".to_string(),
AgentRoleConfig {
description: Some("Research role".to_string()),
model: None,
config_file: None,
spawn_mode: None,
nickname_candidates: Some(vec!["Atlas".to_string()]),
},
);
+5
View File
@@ -3,9 +3,14 @@ pub(crate) mod control;
mod registry;
pub(crate) mod role;
pub(crate) mod status;
mod watchdog;
pub(crate) use codex_protocol::protocol::AgentStatus;
pub(crate) use control::AgentControl;
pub(crate) use control::AgentListing;
pub(crate) use control::WatchdogParentCompactionResult;
pub(crate) use registry::exceeds_thread_spawn_depth_limit;
pub(crate) use registry::next_thread_spawn_depth;
pub(crate) use status::agent_status_from_event;
pub(crate) use watchdog::RemovedWatchdog;
pub(crate) use watchdog::WatchdogRegistration;
+12
View File
@@ -77,6 +77,18 @@ pub(crate) fn exceeds_thread_spawn_depth_limit(depth: i32, max_depth: i32) -> bo
}
impl AgentRegistry {
pub(crate) fn tracked_thread_ids(&self) -> Vec<ThreadId> {
let active_agents = self
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
active_agents
.agent_tree
.values()
.filter_map(|metadata| metadata.agent_id)
.collect()
}
pub(crate) fn reserve_spawn_slot(
self: &Arc<Self>,
max_threads: Option<usize>,
+31 -5
View File
@@ -7,6 +7,7 @@
//! which role to use; the multi-agent tool handler owns that orchestration.
use crate::config::AgentRoleConfig;
use crate::config::AgentRoleSpawnMode;
use crate::config::Config;
use crate::config::ConfigOverrides;
use crate::config::agent_roles::parse_agent_role_file_contents;
@@ -27,6 +28,16 @@ use toml::Value as TomlValue;
pub const DEFAULT_ROLE_NAME: &str = "default";
const AGENT_TYPE_UNAVAILABLE_ERROR: &str = "agent type is currently not available";
pub(crate) fn default_spawn_mode_for_role(
config: &Config,
role_name: Option<&str>,
) -> AgentRoleSpawnMode {
let role_name = role_name.unwrap_or(DEFAULT_ROLE_NAME);
resolve_role_config(config, role_name)
.and_then(|role| role.spawn_mode)
.unwrap_or_default()
}
/// Applies a named role layer to `config` while preserving caller-owned model selection.
///
/// The role layer is inserted at session-flag precedence so it can override persisted config, but
@@ -60,9 +71,13 @@ async fn apply_role_to_config_inner(
) -> anyhow::Result<()> {
let is_built_in = !config.agent_roles.contains_key(role_name);
let Some(config_file) = role.config_file.as_ref() else {
if let Some(model) = &role.model {
config.model = Some(model.clone());
}
return Ok(());
};
let role_layer_toml = load_role_layer_toml(config, config_file, is_built_in, role_name).await?;
let role_layer_toml =
load_role_layer_toml(config, config_file, is_built_in, role_name, role).await?;
let (preserve_current_profile, preserve_current_provider) =
preservation_policy(config, &role_layer_toml);
@@ -80,6 +95,7 @@ async fn load_role_layer_toml(
config_file: &Path,
is_built_in: bool,
role_name: &str,
role: &AgentRoleConfig,
) -> anyhow::Result<TomlValue> {
let (role_config_toml, role_config_base) = if is_built_in {
let role_config_contents = built_in::config_file_contents(config_file)
@@ -103,10 +119,14 @@ async fn load_role_layer_toml(
};
deserialize_config_toml_with_base(role_config_toml.clone(), role_config_base)?;
Ok(resolve_relative_paths_in_config_toml(
role_config_toml,
role_config_base,
)?)
let mut role_layer_toml =
resolve_relative_paths_in_config_toml(role_config_toml, role_config_base)?;
if let Some(model) = &role.model
&& let Some(table) = role_layer_toml.as_table_mut()
{
table.insert("model".to_string(), TomlValue::String(model.clone()));
}
Ok(role_layer_toml)
}
pub(crate) fn resolve_role_config<'a>(
@@ -350,7 +370,9 @@ mod built_in {
DEFAULT_ROLE_NAME.to_string(),
AgentRoleConfig {
description: Some("Default agent.".to_string()),
model: None,
config_file: None,
spawn_mode: None,
nickname_candidates: None,
}
),
@@ -364,7 +386,9 @@ Rules:
- In order to avoid redundant work, you should avoid exploring the same problem that explorers have already covered. Typically, you should trust the explorer results without additional verification. You are still allowed to inspect the code yourself to gain the needed context!
- You are encouraged to spawn up multiple explorers in parallel when you have multiple distinct questions to ask about the codebase that can be answered independently. This allows you to get more information faster without waiting for one question to finish before asking the next. While waiting for the explorer results, you can continue working on other local tasks that do not depend on those results. This parallelism is a key advantage of delegation, so use it whenever you have multiple questions to ask.
- Reuse existing explorers for related questions."#.to_string()),
model: None,
config_file: Some("explorer.toml".to_string().parse().unwrap_or_default()),
spawn_mode: None,
nickname_candidates: None,
}
),
@@ -379,7 +403,9 @@ Typical tasks:
Rules:
- Explicitly assign **ownership** of the task (files / responsibility). When the subtask involves code changes, you should clearly specify which files or modules the worker is responsible for. This helps avoid merge conflicts and ensures accountability. For example, you can say "Worker 1 is responsible for updating the authentication module, while Worker 2 will handle the database layer." By defining clear ownership, you can delegate more effectively and reduce coordination overhead.
- Always tell workers they are **not alone in the codebase**, and they should not revert the edits made by others, and they should adjust their implementation to accommodate the changes made by others. This is important because there may be multiple workers making changes in parallel, and they need to be aware of each other's work to avoid conflicts and ensure a cohesive final product."#.to_string()),
model: None,
config_file: None,
spawn_mode: None,
nickname_candidates: None,
}
),
+32
View File
@@ -94,7 +94,9 @@ async fn apply_role_returns_unavailable_for_missing_user_role_file() {
"custom".to_string(),
AgentRoleConfig {
description: None,
model: None,
config_file: Some(PathBuf::from("/path/does/not/exist.toml")),
spawn_mode: None,
nickname_candidates: None,
},
);
@@ -114,7 +116,9 @@ async fn apply_role_returns_unavailable_for_invalid_user_role_toml() {
"custom".to_string(),
AgentRoleConfig {
description: None,
model: None,
config_file: Some(role_path),
spawn_mode: None,
nickname_candidates: None,
},
);
@@ -145,7 +149,9 @@ model = "role-model"
"custom".to_string(),
AgentRoleConfig {
description: None,
model: None,
config_file: Some(role_path),
spawn_mode: None,
nickname_candidates: None,
},
);
@@ -176,7 +182,9 @@ async fn apply_role_preserves_unspecified_keys() {
"custom".to_string(),
AgentRoleConfig {
description: None,
model: None,
config_file: Some(role_path),
spawn_mode: None,
nickname_candidates: None,
},
);
@@ -235,7 +243,9 @@ model_provider = "test-provider"
"custom".to_string(),
AgentRoleConfig {
description: None,
model: None,
config_file: Some(role_path),
spawn_mode: None,
nickname_candidates: None,
},
);
@@ -289,7 +299,9 @@ model_verbosity = "high"
"custom".to_string(),
AgentRoleConfig {
description: None,
model: None,
config_file: Some(role_path),
spawn_mode: None,
nickname_candidates: None,
},
);
@@ -355,7 +367,9 @@ model_provider = "role-provider"
"custom".to_string(),
AgentRoleConfig {
description: None,
model: None,
config_file: Some(role_path),
spawn_mode: None,
nickname_candidates: None,
},
);
@@ -413,7 +427,9 @@ model_provider = "base-provider"
"custom".to_string(),
AgentRoleConfig {
description: None,
model: None,
config_file: Some(role_path),
spawn_mode: None,
nickname_candidates: None,
},
);
@@ -477,7 +493,9 @@ model_reasoning_effort = "high"
"custom".to_string(),
AgentRoleConfig {
description: None,
model: None,
config_file: Some(role_path),
spawn_mode: None,
nickname_candidates: None,
},
);
@@ -521,7 +539,9 @@ writable_roots = ["./sandbox-root"]
"custom".to_string(),
AgentRoleConfig {
description: None,
model: None,
config_file: Some(role_path),
spawn_mode: None,
nickname_candidates: None,
},
);
@@ -583,7 +603,9 @@ async fn apply_role_takes_precedence_over_existing_session_flags_for_same_key()
"custom".to_string(),
AgentRoleConfig {
description: None,
model: None,
config_file: Some(role_path),
spawn_mode: None,
nickname_candidates: None,
},
);
@@ -626,7 +648,9 @@ enabled = false
"custom".to_string(),
AgentRoleConfig {
description: None,
model: None,
config_file: Some(role_path),
spawn_mode: None,
nickname_candidates: None,
},
);
@@ -660,7 +684,9 @@ fn spawn_tool_spec_build_deduplicates_user_defined_built_in_roles() {
"explorer".to_string(),
AgentRoleConfig {
description: Some("user override".to_string()),
model: None,
config_file: None,
spawn_mode: None,
nickname_candidates: None,
},
),
@@ -681,7 +707,9 @@ fn spawn_tool_spec_lists_user_defined_roles_before_built_ins() {
"aaa".to_string(),
AgentRoleConfig {
description: Some("first".to_string()),
model: None,
config_file: None,
spawn_mode: None,
nickname_candidates: None,
},
)]);
@@ -708,7 +736,9 @@ fn spawn_tool_spec_marks_role_locked_model_and_reasoning_effort() {
"researcher".to_string(),
AgentRoleConfig {
description: Some("Research carefully.".to_string()),
model: None,
config_file: Some(role_path),
spawn_mode: None,
nickname_candidates: None,
},
)]);
@@ -733,7 +763,9 @@ fn spawn_tool_spec_marks_role_locked_reasoning_effort_only() {
"reviewer".to_string(),
AgentRoleConfig {
description: Some("Review carefully.".to_string()),
model: None,
config_file: Some(role_path),
spawn_mode: None,
nickname_candidates: None,
},
)]);
+622
View File
@@ -0,0 +1,622 @@
use super::control::AgentControl;
use super::registry::AgentRegistry;
use super::registry::exceeds_thread_spawn_depth_limit;
use super::status::is_final;
use crate::codex::load_watchdog_prompt;
use crate::config::Config;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
use crate::thread_manager::ThreadManagerState;
use codex_features::Feature;
use codex_protocol::ThreadId;
use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::user_input::UserInput;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::Weak;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicI64;
use std::sync::atomic::Ordering;
use tokio::sync::Mutex;
use tokio::time::Duration;
use tokio::time::Instant;
use tracing::info;
use tracing::warn;
const WATCHDOG_TICK_SECONDS: i64 = 1;
#[derive(Clone)]
pub(crate) struct WatchdogRegistration {
pub(crate) owner_thread_id: ThreadId,
pub(crate) target_thread_id: ThreadId,
pub(crate) child_depth: i32,
pub(crate) interval_s: i64,
pub(crate) prompt: String,
pub(crate) config: Config,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct RemovedWatchdog {
pub(crate) target_thread_id: ThreadId,
pub(crate) active_helper_id: Option<ThreadId>,
}
struct WatchdogEntry {
registration: WatchdogRegistration,
interval: Duration,
last_trigger: Instant,
active_helper_id: Option<ThreadId>,
owner_idle_since: Option<Instant>,
owner_was_running: bool,
force_due_once: bool,
generation: i64,
}
pub(crate) struct WatchdogManager {
manager: Weak<ThreadManagerState>,
state: Arc<AgentRegistry>,
registrations: Mutex<HashMap<ThreadId, WatchdogEntry>>,
started: AtomicBool,
next_generation: AtomicI64,
}
impl WatchdogManager {
pub(crate) fn new(manager: Weak<ThreadManagerState>, state: Arc<AgentRegistry>) -> Arc<Self> {
Arc::new(Self {
manager,
state,
registrations: Mutex::new(HashMap::new()),
started: AtomicBool::new(false),
next_generation: AtomicI64::new(1),
})
}
pub(crate) fn start(self: &Arc<Self>) {
if self
.started
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return;
}
let manager = Arc::clone(self);
tokio::spawn(async move {
manager.run_loop().await;
});
}
pub(crate) async fn register(
self: &Arc<Self>,
registration: WatchdogRegistration,
) -> CodexResult<Vec<RemovedWatchdog>> {
if exceeds_thread_spawn_depth_limit(
registration.child_depth,
registration.config.agent_max_depth,
) {
let max_depth = registration.config.agent_max_depth;
return Err(CodexErr::UnsupportedOperation(format!(
"agent depth limit reached: max depth is {max_depth}"
)));
}
let interval = interval_duration(registration.interval_s)?;
let generation = self.next_generation.fetch_add(1, Ordering::AcqRel);
let now = Instant::now();
let entry = WatchdogEntry {
registration,
interval,
last_trigger: now,
active_helper_id: None,
owner_idle_since: Some(now),
owner_was_running: false,
force_due_once: false,
generation,
};
let mut registrations = self.registrations.lock().await;
let superseded_targets: Vec<ThreadId> = registrations
.iter()
.filter_map(|(target_thread_id, existing_entry)| {
(existing_entry.registration.owner_thread_id == entry.registration.owner_thread_id
&& *target_thread_id != entry.registration.target_thread_id)
.then_some(*target_thread_id)
})
.collect();
let mut superseded = Vec::new();
for superseded_target in superseded_targets {
if let Some(removed) = registrations.remove(&superseded_target) {
superseded.push(RemovedWatchdog {
target_thread_id: superseded_target,
active_helper_id: removed.active_helper_id,
});
}
}
registrations.insert(entry.registration.target_thread_id, entry);
Ok(superseded)
}
async fn run_loop(self: Arc<Self>) {
let tick = tick_duration();
loop {
self.run_once().await;
if self.manager.upgrade().is_none() {
break;
}
tokio::time::sleep(tick).await;
}
}
pub(crate) async fn run_once(self: &Arc<Self>) {
let Some(manager_state) = self.manager.upgrade() else {
self.registrations.lock().await.clear();
return;
};
let snapshots: Vec<(ThreadId, i64)> = {
let registrations = self.registrations.lock().await;
registrations
.iter()
.map(|(target_id, entry)| (*target_id, entry.generation))
.collect()
};
let now = Instant::now();
for (target_id, generation) in snapshots {
self.evaluate(&manager_state, target_id, generation, now)
.await;
}
}
async fn evaluate(
self: &Arc<Self>,
manager_state: &Arc<ThreadManagerState>,
target_thread_id: ThreadId,
generation: i64,
now: Instant,
) {
let Some(snapshot) = self.snapshot(target_thread_id, generation).await else {
return;
};
let owner_thread = manager_state.get_thread(snapshot.owner_thread_id).await;
let owner_status = match owner_thread.as_ref() {
Ok(thread) => thread.agent_status().await,
Err(_) => AgentStatus::NotFound,
};
let control_for_spawn = AgentControl::from_parts(
self.manager.clone(),
Arc::clone(&self.state),
Arc::clone(self),
);
if is_watchdog_terminated(&owner_status) {
match control_for_spawn.shutdown_agent(target_thread_id).await {
Ok(_) | Err(CodexErr::ThreadNotFound(_)) | Err(CodexErr::InternalAgentDied) => {}
Err(err) => {
warn!(
owner_thread_id = %snapshot.owner_thread_id,
target_thread_id = %target_thread_id,
"watchdog owner termination cleanup failed: {err}"
);
}
}
return;
}
let force_due = self
.take_force_due_if_generation(target_thread_id, generation)
.await;
let owner_has_active_turn = match owner_thread {
Ok(thread) => thread.has_active_turn().await,
Err(_) => false,
};
let owner_running = (is_running(&owner_status) || owner_has_active_turn) && !force_due;
let owner_idle_since = self
.update_owner_idle_state_if_generation(
target_thread_id,
generation,
owner_running,
now,
force_due,
)
.await;
if owner_running {
return;
}
let owner_idle_since = owner_idle_since.or(snapshot.owner_idle_since);
let Some(owner_idle_since) = owner_idle_since else {
return;
};
if now.duration_since(owner_idle_since) < snapshot.interval {
return;
}
if let Some(helper_id) = snapshot.active_helper_id {
let helper_status = get_status(manager_state, helper_id).await;
if !is_final(&helper_status) {
return;
}
let helper_sent_input = manager_state
.get_thread(helper_id)
.await
.map(|thread| thread.last_completed_turn_used_agent_send_input())
.unwrap_or(false);
// Every watchdog check-in must wake the owner thread exactly once.
//
// Preferred path: the helper explicitly calls `send_input`.
// Mandatory fallback: if the helper reaches a terminal state without
// using `send_input`, forward a conclusory inbox message to the
// owner so the owner thread is still resumed.
if !helper_sent_input {
let fallback_message = match &helper_status {
AgentStatus::Completed(Some(message)) if !message.trim().is_empty() => {
Some(message.clone())
}
AgentStatus::Completed(_) => Some(
"Watchdog check-in completed without calling send_input or returning a final message."
.to_string(),
),
AgentStatus::Errored(message) if !message.trim().is_empty() => Some(
format!("Watchdog check-in failed before calling send_input: {message}"),
),
AgentStatus::Errored(_) => Some(
"Watchdog check-in failed before calling send_input.".to_string(),
),
AgentStatus::Interrupted => {
Some("Watchdog check-in was interrupted before calling send_input.".to_string())
}
AgentStatus::Shutdown => {
Some("Watchdog check-in ended before calling send_input.".to_string())
}
AgentStatus::NotFound => Some(
"Watchdog check-in disappeared before calling send_input.".to_string(),
),
AgentStatus::PendingInit | AgentStatus::Running => None,
};
if let Some(message) = fallback_message {
if let Err(err) = control_for_spawn
.send_watchdog_wakeup(snapshot.owner_thread_id, helper_id, message)
.await
{
warn!(
helper_id = %helper_id,
owner_thread_id = %snapshot.owner_thread_id,
"watchdog helper forward failed: {err}"
);
} else {
info!(
helper_id = %helper_id,
owner_thread_id = %snapshot.owner_thread_id,
"watchdog forwarded helper completion to owner"
);
}
}
}
if let Err(err) = control_for_spawn.shutdown_agent(helper_id).await {
warn!(
helper_id = %helper_id,
owner_thread_id = %snapshot.owner_thread_id,
"watchdog helper cleanup failed: {err}"
);
}
self.update_after_spawn(target_thread_id, generation, now, None)
.await;
return;
}
if now.duration_since(snapshot.last_trigger) < snapshot.interval {
return;
}
let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: snapshot.owner_thread_id,
depth: snapshot.child_depth,
agent_path: None,
agent_nickname: None,
agent_role: None,
});
let mut helper_config = snapshot.config.clone();
helper_config.ephemeral = true;
let helper_prompt =
watchdog_helper_prompt(&helper_config, snapshot.owner_thread_id, &snapshot.prompt)
.await;
// Watchdog check-ins must fork a distinct helper thread. If this path ever resumes
// the owner thread instead, the owner can self-wake and rapidly duplicate session
// state in memory.
let spawn_result = control_for_spawn
.fork_agent(
helper_config,
vec![UserInput::Text {
text: helper_prompt,
text_elements: Vec::new(),
}],
snapshot.owner_thread_id,
usize::MAX,
session_source,
)
.await;
match spawn_result {
Ok(helper_id) => {
info!("watchdog spawned helper {helper_id} for target {target_thread_id}");
self.update_after_spawn(target_thread_id, generation, now, Some(helper_id))
.await;
}
Err(err) => {
warn!("watchdog spawn failed for target {target_thread_id}: {err}");
self.update_after_spawn(target_thread_id, generation, now, None)
.await;
}
}
}
async fn snapshot(
&self,
target_thread_id: ThreadId,
generation: i64,
) -> Option<WatchdogSnapshot> {
let registrations = self.registrations.lock().await;
let entry = registrations.get(&target_thread_id)?;
if entry.generation != generation {
return None;
}
Some(WatchdogSnapshot {
owner_thread_id: entry.registration.owner_thread_id,
child_depth: entry.registration.child_depth,
prompt: entry.registration.prompt.clone(),
config: entry.registration.config.clone(),
interval: entry.interval,
last_trigger: entry.last_trigger,
active_helper_id: entry.active_helper_id,
owner_idle_since: entry.owner_idle_since,
})
}
async fn update_owner_idle_state_if_generation(
&self,
target_thread_id: ThreadId,
generation: i64,
owner_running: bool,
now: Instant,
force_due: bool,
) -> Option<Instant> {
let mut registrations = self.registrations.lock().await;
let entry = registrations.get_mut(&target_thread_id)?;
if entry.generation != generation {
return None;
}
if force_due {
return entry.owner_idle_since;
}
if owner_running {
entry.owner_idle_since = None;
entry.owner_was_running = true;
return None;
}
if entry.owner_was_running || entry.owner_idle_since.is_none() {
entry.owner_idle_since = Some(now);
}
entry.owner_was_running = false;
entry.owner_idle_since
}
async fn take_force_due_if_generation(
&self,
target_thread_id: ThreadId,
generation: i64,
) -> bool {
let mut registrations = self.registrations.lock().await;
let Some(entry) = registrations.get_mut(&target_thread_id) else {
return false;
};
if entry.generation != generation || !entry.force_due_once {
return false;
}
entry.force_due_once = false;
true
}
#[cfg(test)]
pub(crate) async fn force_due_for_tests(&self, target_thread_id: ThreadId) {
let mut registrations = self.registrations.lock().await;
if let Some(entry) = registrations.get_mut(&target_thread_id) {
entry.force_due_once = true;
}
}
async fn update_after_spawn(
&self,
target_thread_id: ThreadId,
generation: i64,
now: Instant,
active_helper_id: Option<ThreadId>,
) {
let mut registrations = self.registrations.lock().await;
let Some(entry) = registrations.get_mut(&target_thread_id) else {
return;
};
if entry.generation != generation {
return;
}
entry.last_trigger = now;
entry.active_helper_id = active_helper_id;
}
pub(crate) async fn unregister(&self, target_thread_id: ThreadId) -> Option<RemovedWatchdog> {
let mut registrations = self.registrations.lock().await;
registrations
.remove(&target_thread_id)
.map(|removed| RemovedWatchdog {
target_thread_id,
active_helper_id: removed.active_helper_id,
})
}
pub(crate) async fn owner_for_active_helper(
&self,
helper_thread_id: ThreadId,
) -> Option<ThreadId> {
let registrations = self.registrations.lock().await;
registrations.values().find_map(|entry| {
(entry.active_helper_id == Some(helper_thread_id))
.then_some(entry.registration.owner_thread_id)
})
}
pub(crate) async fn registered_targets(&self, candidate_ids: &[ThreadId]) -> HashSet<ThreadId> {
let registrations = self.registrations.lock().await;
candidate_ids
.iter()
.copied()
.filter(|candidate_id| registrations.contains_key(candidate_id))
.collect()
}
pub(crate) async fn take_for_owner(&self, owner_thread_id: ThreadId) -> Vec<RemovedWatchdog> {
let mut registrations = self.registrations.lock().await;
let removed_targets: Vec<ThreadId> = registrations
.iter()
.filter_map(|(target_thread_id, entry)| {
(entry.registration.owner_thread_id == owner_thread_id).then_some(*target_thread_id)
})
.collect();
let mut removed = Vec::new();
for removed_target in removed_targets {
if let Some(entry) = registrations.remove(&removed_target) {
removed.push(RemovedWatchdog {
target_thread_id: removed_target,
active_helper_id: entry.active_helper_id,
});
}
}
removed
}
#[cfg(test)]
#[allow(dead_code)]
pub(crate) async fn set_active_helper_for_tests(
&self,
target_thread_id: ThreadId,
helper_thread_id: ThreadId,
) {
let mut registrations = self.registrations.lock().await;
let Some(entry) = registrations.get_mut(&target_thread_id) else {
return;
};
let due_at = Instant::now() - entry.interval;
entry.last_trigger = due_at;
entry.owner_idle_since = Some(due_at);
entry.owner_was_running = false;
entry.active_helper_id = Some(helper_thread_id);
}
}
#[derive(Clone)]
struct WatchdogSnapshot {
owner_thread_id: ThreadId,
child_depth: i32,
prompt: String,
config: Config,
interval: Duration,
last_trigger: Instant,
active_helper_id: Option<ThreadId>,
owner_idle_since: Option<Instant>,
}
async fn get_status(manager_state: &Arc<ThreadManagerState>, thread_id: ThreadId) -> AgentStatus {
let Ok(thread) = manager_state.get_thread(thread_id).await else {
return AgentStatus::NotFound;
};
thread.agent_status().await
}
fn is_running(status: &AgentStatus) -> bool {
matches!(status, AgentStatus::PendingInit | AgentStatus::Running)
}
fn is_watchdog_terminated(status: &AgentStatus) -> bool {
matches!(status, AgentStatus::Shutdown | AgentStatus::NotFound)
}
fn interval_duration(interval_s: i64) -> CodexResult<Duration> {
if interval_s <= 0 {
return Err(CodexErr::UnsupportedOperation(
"interval_s must be greater than zero".to_string(),
));
}
let seconds = u64::try_from(interval_s).map_err(|_| {
CodexErr::UnsupportedOperation(format!("interval_s out of range: {interval_s}"))
})?;
Ok(Duration::from_secs(seconds))
}
fn tick_duration() -> Duration {
let seconds = u64::try_from(WATCHDOG_TICK_SECONDS).unwrap_or(5);
Duration::from_secs(seconds)
}
async fn watchdog_helper_prompt(
config: &Config,
target_thread_id: ThreadId,
prompt: &str,
) -> String {
if !config.features.enabled(Feature::AgentPromptInjection) {
if prompt.trim().is_empty() {
return format!("Target agent id: {target_thread_id}");
}
return format!("Target agent id: {target_thread_id}\n\n{prompt}");
}
let watchdog_prompt = load_watchdog_prompt(&config.codex_home).await;
if prompt.trim().is_empty() {
format!("{watchdog_prompt}\n\nTarget agent id: {target_thread_id}")
} else {
format!("{watchdog_prompt}\n\nTarget agent id: {target_thread_id}\n\n{prompt}")
}
}
#[cfg(test)]
mod tests {
use super::watchdog_helper_prompt;
use crate::config::ConfigBuilder;
use codex_features::Feature;
use codex_protocol::ThreadId;
#[tokio::test]
async fn watchdog_helper_prompt_is_minimal_when_agent_prompt_injection_is_disabled() {
let codex_home = tempfile::tempdir().expect("create temp dir");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("load config");
let target_thread_id = ThreadId::default();
let prompt = watchdog_helper_prompt(&config, target_thread_id, "ping").await;
assert_eq!(
prompt,
format!("Target agent id: {target_thread_id}\n\nping")
);
}
#[tokio::test]
async fn watchdog_helper_prompt_includes_watchdog_instructions_when_enabled() {
let codex_home = tempfile::tempdir().expect("create temp dir");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("load config");
let _ = config.features.enable(Feature::AgentPromptInjection);
let prompt = watchdog_helper_prompt(&config, ThreadId::default(), "ping").await;
assert!(prompt.contains("# You are a Subagent"));
assert!(prompt.contains("Important: send watchdog check-in output with `send_input`"));
assert!(prompt.contains("Target agent id:"));
assert!(prompt.ends_with("\n\nping"));
}
}
+150 -2
View File
@@ -4,7 +4,9 @@ use std::fmt::Debug;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use crate::AuthManager;
use crate::CodexAuth;
@@ -136,6 +138,7 @@ use rmcp::model::ReadResourceResult;
use rmcp::model::RequestId;
use serde_json;
use serde_json::Value;
use tokio::fs;
use tokio::sync::Mutex;
use tokio::sync::RwLock;
use tokio::sync::oneshot;
@@ -324,6 +327,83 @@ use crate::shell_snapshot::ShellSnapshot;
use crate::skills_watcher::SkillsWatcher;
use crate::skills_watcher::SkillsWatcherEvent;
use crate::state::ActiveTurn;
const ROOT_AGENT_PROMPT_FALLBACK: &str = include_str!("../root_agent_prompt.md");
const ROOT_AGENT_WATCHDOG_PROMPT_FALLBACK: &str = include_str!("../root_agent_watchdog_prompt.md");
const SUBAGENT_PROMPT_FALLBACK: &str = include_str!("../subagent_prompt.md");
const SUBAGENT_WATCHDOG_PROMPT_FALLBACK: &str = include_str!("../subagent_watchdog_prompt.md");
const WATCHDOG_PROMPT_FALLBACK: &str = include_str!("../watchdog_agent_prompt.md");
async fn load_agent_prompt_fallback(
codex_home: &Path,
fallback: &str,
override_filename: &str,
) -> String {
let override_path = codex_home.join(override_filename);
if let Ok(contents) = fs::read_to_string(&override_path).await
&& !contents.trim().is_empty()
{
return contents;
}
fallback.to_string()
}
async fn maybe_load_agent_prompt_fragment(
codex_home: &Path,
fallback: &str,
override_filename: &str,
enabled: bool,
) -> Option<String> {
if !enabled {
return None;
}
let fragment = load_agent_prompt_fallback(codex_home, fallback, override_filename).await;
if fragment.trim().is_empty() {
None
} else {
Some(fragment)
}
}
async fn load_root_agent_prompt(codex_home: &Path, include_watchdog: bool) -> String {
let mut prompt =
load_agent_prompt_fallback(codex_home, ROOT_AGENT_PROMPT_FALLBACK, "AGENTS.root.md").await;
if let Some(fragment) = maybe_load_agent_prompt_fragment(
codex_home,
ROOT_AGENT_WATCHDOG_PROMPT_FALLBACK,
"AGENTS.root.watchdog.md",
include_watchdog,
)
.await
{
prompt.push_str("\n\n");
prompt.push_str(fragment.trim());
}
prompt
}
async fn load_subagent_prompt(codex_home: &Path, include_watchdog: bool) -> String {
let mut prompt =
load_agent_prompt_fallback(codex_home, SUBAGENT_PROMPT_FALLBACK, "AGENTS.subagent.md")
.await;
if let Some(fragment) = maybe_load_agent_prompt_fragment(
codex_home,
SUBAGENT_WATCHDOG_PROMPT_FALLBACK,
"AGENTS.subagent.watchdog.md",
include_watchdog,
)
.await
{
prompt.push_str("\n\n");
prompt.push_str(fragment.trim());
}
prompt
}
pub(crate) async fn load_watchdog_prompt(codex_home: &Path) -> String {
load_agent_prompt_fallback(codex_home, WATCHDOG_PROMPT_FALLBACK, "AGENTS.watchdog.md").await
}
use crate::state::SessionServices;
use crate::state::SessionState;
use crate::state_db;
@@ -556,6 +636,33 @@ impl Codex {
let model = models_manager
.get_default_model(&config.model, refresh_strategy)
.await;
let role_prompt = if config.features.enabled(Feature::Collab)
&& config.features.enabled(Feature::AgentPromptInjection)
{
match session_source {
SessionSource::SubAgent(_) => Some(
load_subagent_prompt(
&config.codex_home,
config.features.enabled(Feature::AgentWatchdog),
)
.await,
),
_ => Some(
load_root_agent_prompt(
&config.codex_home,
config.features.enabled(Feature::AgentWatchdog),
)
.await,
),
}
} else {
None
};
let developer_instructions = match (role_prompt, config.developer_instructions.clone()) {
(Some(prompt), Some(existing)) => Some(format!("{prompt}\n\n{existing}")),
(Some(prompt), None) => Some(prompt),
(None, existing) => existing,
};
// Resolve base instructions for the session. Priority order:
// 1. config.base_instructions override
@@ -610,7 +717,7 @@ impl Codex {
collaboration_mode,
model_reasoning_summary: config.model_reasoning_summary,
service_tier: config.service_tier,
developer_instructions: config.developer_instructions.clone(),
developer_instructions,
user_instructions,
personality: config.personality,
base_instructions,
@@ -811,6 +918,8 @@ pub(crate) struct Session {
pub(crate) services: SessionServices,
js_repl: Arc<JsReplHandle>,
next_internal_sub_id: AtomicU64,
turn_used_agent_send_input: AtomicBool,
last_completed_turn_used_agent_send_input: AtomicBool,
}
#[derive(Clone, Debug)]
@@ -1925,6 +2034,8 @@ impl Session {
services,
js_repl,
next_internal_sub_id: AtomicU64::new(0),
turn_used_agent_send_input: AtomicBool::new(false),
last_completed_turn_used_agent_send_input: AtomicBool::new(false),
});
if let Some(network_policy_decider_session) = network_policy_decider_session {
let mut guard = network_policy_decider_session.write().await;
@@ -2069,7 +2180,44 @@ impl Session {
self.services.state_db.clone()
}
/// Ensure rollout file writes are durably flushed.
pub(crate) async fn has_active_turn(&self) -> bool {
self.active_turn.lock().await.is_some()
}
pub(crate) async fn parent_thread_id(&self) -> Option<ThreadId> {
let state = self.state.lock().await;
match &state.session_configuration.session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id, ..
}) => Some(*parent_thread_id),
_ => None,
}
}
pub(crate) fn mark_turn_used_agent_send_input(&self) {
self.turn_used_agent_send_input
.store(true, Ordering::Release);
}
pub(crate) fn reset_turn_agent_send_input_flag(&self) {
self.turn_used_agent_send_input
.store(false, Ordering::Release);
}
pub(crate) fn snapshot_agent_send_input_on_turn_complete(&self) {
let used_agent_send_input = self
.turn_used_agent_send_input
.swap(false, Ordering::AcqRel);
self.last_completed_turn_used_agent_send_input
.store(used_agent_send_input, Ordering::Release);
}
pub(crate) fn last_completed_turn_used_agent_send_input(&self) -> bool {
self.last_completed_turn_used_agent_send_input
.load(Ordering::Acquire)
}
/// Ensure all rollout writes are durably flushed.
pub(crate) async fn flush_rollout(&self) {
let recorder = {
let guard = self.services.rollout.lock().await;
+28
View File
@@ -2367,7 +2367,9 @@ enabled = false
"custom".to_string(),
crate::config::AgentRoleConfig {
description: None,
model: None,
config_file: Some(role_path),
spawn_mode: None,
nickname_candidates: None,
},
);
@@ -2751,6 +2753,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(),
services,
js_repl,
turn_used_agent_send_input: std::sync::atomic::AtomicBool::new(false),
last_completed_turn_used_agent_send_input: std::sync::atomic::AtomicBool::new(false),
next_internal_sub_id: AtomicU64::new(0),
};
@@ -3591,6 +3595,8 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(),
services,
js_repl,
turn_used_agent_send_input: std::sync::atomic::AtomicBool::new(false),
last_completed_turn_used_agent_send_input: std::sync::atomic::AtomicBool::new(false),
next_internal_sub_id: AtomicU64::new(0),
});
@@ -5284,3 +5290,25 @@ async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request()
pretty_assertions::assert_eq!(output, expected);
}
#[tokio::test]
async fn root_agent_prompt_only_includes_watchdog_fragment_when_enabled() {
let codex_home = tempfile::tempdir().expect("create temp dir");
let without_watchdog = load_root_agent_prompt(codex_home.path(), false).await;
assert!(!without_watchdog.contains("## Watchdogs"));
let with_watchdog = load_root_agent_prompt(codex_home.path(), true).await;
assert!(with_watchdog.contains("## Watchdogs"));
}
#[tokio::test]
async fn subagent_prompt_only_includes_watchdog_fragment_when_enabled() {
let codex_home = tempfile::tempdir().expect("create temp dir");
let without_watchdog = load_subagent_prompt(codex_home.path(), false).await;
assert!(!without_watchdog.contains("## Watchdog-only Guidance"));
let with_watchdog = load_subagent_prompt(codex_home.path(), true).await;
assert!(with_watchdog.contains("## Watchdog-only Guidance"));
}
+10
View File
@@ -131,6 +131,16 @@ impl CodexThread {
self.codex.session.total_token_usage().await
}
pub(crate) async fn has_active_turn(&self) -> bool {
self.codex.session.has_active_turn().await
}
pub(crate) fn last_completed_turn_used_agent_send_input(&self) -> bool {
self.codex
.session
.last_completed_turn_used_agent_send_input()
}
/// Records a user-role session-prefix message without creating a new user turn boundary.
pub(crate) async fn inject_user_message_without_turn(&self, message: String) {
let message = ResponseItem::Message {
+6
View File
@@ -152,7 +152,9 @@ fn read_declared_role(
fn merge_missing_role_fields(role: &mut AgentRoleConfig, fallback: &AgentRoleConfig) {
role.description = role.description.clone().or(fallback.description.clone());
role.model = role.model.clone().or(fallback.model.clone());
role.config_file = role.config_file.clone().or(fallback.config_file.clone());
role.spawn_mode = role.spawn_mode.or(fallback.spawn_mode);
role.nickname_candidates = role
.nickname_candidates
.clone()
@@ -188,7 +190,9 @@ fn agent_role_config_from_toml(
Ok(AgentRoleConfig {
description,
model: role.model.clone(),
config_file,
spawn_mode: role.spawn_mode,
nickname_candidates,
})
}
@@ -478,7 +482,9 @@ fn discover_agent_roles_in_dir(
role_name,
AgentRoleConfig {
description: parsed_file.description,
model: None,
config_file: Some(agent_file),
spawn_mode: None,
nickname_candidates: parsed_file.nickname_candidates,
},
);
+19
View File
@@ -3213,11 +3213,14 @@ fn load_config_rejects_missing_agent_role_config_file() -> std::io::Result<()> {
max_threads: None,
max_depth: None,
job_max_runtime_seconds: None,
use_function_call_inbox: false,
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
description: Some("Research role".to_string()),
model: None,
config_file: Some(missing_path.abs()),
spawn_mode: None,
nickname_candidates: None,
},
)]),
@@ -4078,11 +4081,14 @@ fn load_config_normalizes_agent_role_nickname_candidates() -> std::io::Result<()
max_threads: None,
max_depth: None,
job_max_runtime_seconds: None,
use_function_call_inbox: false,
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
description: Some("Research role".to_string()),
model: None,
config_file: None,
spawn_mode: None,
nickname_candidates: Some(vec![
" Hypatia ".to_string(),
"Noether".to_string(),
@@ -4119,11 +4125,14 @@ fn load_config_rejects_empty_agent_role_nickname_candidates() -> std::io::Result
max_threads: None,
max_depth: None,
job_max_runtime_seconds: None,
use_function_call_inbox: false,
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
description: Some("Research role".to_string()),
model: None,
config_file: None,
spawn_mode: None,
nickname_candidates: Some(Vec::new()),
},
)]),
@@ -4154,11 +4163,14 @@ fn load_config_rejects_duplicate_agent_role_nickname_candidates() -> std::io::Re
max_threads: None,
max_depth: None,
job_max_runtime_seconds: None,
use_function_call_inbox: false,
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
description: Some("Research role".to_string()),
model: None,
config_file: None,
spawn_mode: None,
nickname_candidates: Some(vec!["Hypatia".to_string(), " Hypatia ".to_string()]),
},
)]),
@@ -4189,11 +4201,14 @@ fn load_config_rejects_unsafe_agent_role_nickname_candidates() -> std::io::Resul
max_threads: None,
max_depth: None,
job_max_runtime_seconds: None,
use_function_call_inbox: false,
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
description: Some("Research role".to_string()),
model: None,
config_file: None,
spawn_mode: None,
nickname_candidates: Some(vec!["Agent <One>".to_string()]),
},
)]),
@@ -4437,6 +4452,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
memories: MemoriesConfig::default(),
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
agent_use_function_call_inbox: false,
watchdog_interval_s: DEFAULT_WATCHDOG_INTERVAL_S,
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home(),
log_dir: fixture.codex_home().join("log"),
@@ -4580,6 +4596,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
memories: MemoriesConfig::default(),
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
agent_use_function_call_inbox: false,
watchdog_interval_s: DEFAULT_WATCHDOG_INTERVAL_S,
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home(),
log_dir: fixture.codex_home().join("log"),
@@ -4721,6 +4738,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
memories: MemoriesConfig::default(),
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
agent_use_function_call_inbox: false,
watchdog_interval_s: DEFAULT_WATCHDOG_INTERVAL_S,
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home(),
log_dir: fixture.codex_home().join("log"),
@@ -4848,6 +4866,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
memories: MemoriesConfig::default(),
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
agent_use_function_call_inbox: false,
watchdog_interval_s: DEFAULT_WATCHDOG_INTERVAL_S,
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home(),
log_dir: fixture.codex_home().join("log"),
+39
View File
@@ -145,6 +145,7 @@ pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB
pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option<usize> = Some(6);
pub(crate) const DEFAULT_AGENT_MAX_DEPTH: i32 = 1;
pub(crate) const DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS: Option<u64> = None;
pub(crate) const DEFAULT_WATCHDOG_INTERVAL_S: i64 = 60;
pub const CONFIG_TOML_FILE: &str = "config.toml";
const OPENAI_BASE_URL_ENV_VAR: &str = "OPENAI_BASE_URL";
@@ -408,6 +409,9 @@ pub struct Config {
/// user input.
pub agent_use_function_call_inbox: bool,
/// Watchdog polling interval in seconds.
pub watchdog_interval_s: i64,
/// Maximum nesting depth allowed for spawned agent threads.
pub agent_max_depth: i32,
@@ -1322,6 +1326,9 @@ pub struct ConfigToml {
/// Agent-related settings (thread limits, etc.).
pub agents: Option<AgentsToml>,
/// Watchdog polling interval in seconds.
pub watchdog_interval_s: Option<i64>,
/// Memories subsystem settings.
pub memories: Option<MemoriesToml>,
@@ -1552,6 +1559,10 @@ pub struct AgentsToml {
/// Default maximum runtime in seconds for agent job workers.
#[schemars(range(min = 1))]
pub job_max_runtime_seconds: Option<u64>,
/// Deliver inbound agent messages to non-subagent threads as a synthetic
/// function_call/function_call_output pair instead of plain user input.
#[serde(default)]
pub use_function_call_inbox: bool,
/// User-defined role declarations keyed by role name.
///
@@ -1566,13 +1577,25 @@ pub struct AgentsToml {
pub roles: BTreeMap<String, AgentRoleToml>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AgentRoleSpawnMode {
#[default]
Spawn,
Fork,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AgentRoleConfig {
/// Human-facing role documentation used in spawn tool guidance.
/// Required for loaded user-defined roles after deprecated/new metadata precedence resolves.
pub description: Option<String>,
/// Optional model override applied by this role.
pub model: Option<String>,
/// Path to a role-specific config layer.
pub config_file: Option<PathBuf>,
/// Optional default spawn mode when `spawn_agent` omits `spawn_mode`.
pub spawn_mode: Option<AgentRoleSpawnMode>,
/// Candidate nicknames for agents spawned with this role.
pub nickname_candidates: Option<Vec<String>>,
}
@@ -1584,10 +1607,16 @@ pub struct AgentRoleToml {
/// Required unless supplied by the referenced agent role file.
pub description: Option<String>,
/// Optional model override applied by this role.
pub model: Option<String>,
/// Path to a role-specific config layer.
/// Relative paths are resolved relative to the `config.toml` that defines them.
pub config_file: Option<AbsolutePathBuf>,
/// Optional default spawn mode when `spawn_agent` omits `spawn_mode`.
pub spawn_mode: Option<AgentRoleSpawnMode>,
/// Candidate nicknames for agents spawned with this role.
pub nickname_candidates: Option<Vec<String>>,
}
@@ -2328,6 +2357,15 @@ impl Config {
"agents.job_max_runtime_seconds must fit within a 64-bit signed integer",
));
}
let watchdog_interval_s = cfg
.watchdog_interval_s
.unwrap_or(DEFAULT_WATCHDOG_INTERVAL_S);
if watchdog_interval_s <= 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"watchdog_interval_s must be at least 1",
));
}
let background_terminal_max_timeout = cfg
.background_terminal_max_timeout
.unwrap_or(DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS)
@@ -2603,6 +2641,7 @@ impl Config {
memories: cfg.memories.unwrap_or_default().into(),
agent_job_max_runtime_seconds,
agent_use_function_call_inbox,
watchdog_interval_s,
codex_home,
sqlite_home,
log_dir,
+5
View File
@@ -44,6 +44,11 @@ impl SessionTask for CompactTask {
);
crate::compact::run_compact_task(session.clone(), ctx, input).await
};
session
.services
.agent_control
.finish_watchdog_parent_compaction(session.conversation_id)
.await;
None
}
}
+15
View File
@@ -296,6 +296,7 @@ impl Session {
turn_context
.turn_metadata_state
.cancel_git_enrichment_task();
self.snapshot_agent_send_input_on_turn_complete();
let mut active = self.active_turn.lock().await;
let mut pending_input = Vec::<ResponseInputItem>::new();
@@ -415,6 +416,20 @@ impl Session {
self.send_event(turn_context.as_ref(), event).await;
}
async fn register_new_active_task(
&self,
task: RunningTask,
token_usage_at_turn_start: TokenUsage,
) {
self.reset_turn_agent_send_input_flag();
let mut active = self.active_turn.lock().await;
let mut turn = ActiveTurn::default();
let mut turn_state = turn.turn_state.lock().await;
turn_state.token_usage_at_turn_start = token_usage_at_turn_start;
drop(turn_state);
turn.add_task(task);
*active = Some(turn);
}
async fn take_active_turn(&self) -> Option<ActiveTurn> {
let mut active = self.active_turn.lock().await;
active.take()
@@ -59,12 +59,16 @@ pub(crate) fn parse_agent_id_targets(
}
pub(crate) use close_agent::Handler as CloseAgentHandler;
pub(crate) use compact_parent_context::Handler as CompactParentContextHandler;
pub(crate) use list_agents::Handler as ListAgentsHandler;
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 mod close_agent;
mod compact_parent_context;
mod list_agents;
mod resume_agent;
mod send_input;
mod spawn;
@@ -0,0 +1,96 @@
use super::*;
use crate::agent::WatchdogParentCompactionResult;
pub(crate) struct Handler;
#[async_trait]
impl ToolHandler for Handler {
type Output = CompactParentContextResult;
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, ..
} = invocation;
let arguments = function_arguments(payload)?;
let _args: CompactParentContextArgs = parse_arguments(&arguments)?;
let helper_thread_id = session.conversation_id;
let result = session
.services
.agent_control
.compact_parent_for_watchdog_helper(helper_thread_id)
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!("compact_parent_context failed: {err}"))
})?;
Ok(CompactParentContextResult::from(result))
}
}
#[derive(Debug, Deserialize)]
struct CompactParentContextArgs {
reason: Option<String>,
evidence: Option<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct CompactParentContextResult {
kind: &'static str,
parent_thread_id: Option<String>,
submission_id: Option<String>,
}
impl From<WatchdogParentCompactionResult> for CompactParentContextResult {
fn from(value: WatchdogParentCompactionResult) -> Self {
match value {
WatchdogParentCompactionResult::NotWatchdogHelper => Self {
kind: "not_watchdog_helper",
parent_thread_id: None,
submission_id: None,
},
WatchdogParentCompactionResult::ParentBusy { parent_thread_id } => Self {
kind: "parent_busy",
parent_thread_id: Some(parent_thread_id.to_string()),
submission_id: None,
},
WatchdogParentCompactionResult::AlreadyInProgress { parent_thread_id } => Self {
kind: "already_in_progress",
parent_thread_id: Some(parent_thread_id.to_string()),
submission_id: None,
},
WatchdogParentCompactionResult::Submitted {
parent_thread_id,
submission_id,
} => Self {
kind: "submitted",
parent_thread_id: Some(parent_thread_id.to_string()),
submission_id: Some(submission_id),
},
}
}
}
impl ToolOutput for CompactParentContextResult {
fn log_preview(&self) -> String {
tool_output_json_text(self, "compact_parent_context")
}
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), "compact_parent_context")
}
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
tool_output_code_mode_result(self, "compact_parent_context")
}
}
@@ -0,0 +1,108 @@
use super::*;
use crate::agent::AgentListing;
pub(crate) struct Handler;
#[async_trait]
impl ToolHandler for Handler {
type Output = ListAgentsResult;
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,
..
} = invocation;
let arguments = function_arguments(payload)?;
let args: ListAgentsArgs = parse_arguments(&arguments)?;
let owner_thread_id = if args.all {
session.conversation_id
} else if let Some(target) = args
.id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty())
{
resolve_agent_target(&session, &turn, target).await?
} else {
session.conversation_id
};
let agents = session
.services
.agent_control
.list_agents(owner_thread_id, args.recursive, args.all)
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!("list_agents failed: {err}"))
})?;
Ok(ListAgentsResult {
agents: agents.into_iter().map(ListedAgent::from_listing).collect(),
})
}
}
#[derive(Debug, Deserialize)]
struct ListAgentsArgs {
id: Option<String>,
#[serde(default = "default_recursive")]
recursive: bool,
#[serde(default)]
all: bool,
}
fn default_recursive() -> bool {
true
}
#[derive(Debug, Serialize)]
pub(crate) struct ListAgentsResult {
agents: Vec<ListedAgent>,
}
#[derive(Debug, Serialize)]
struct ListedAgent {
thread_id: String,
parent_thread_id: Option<String>,
status: AgentStatus,
depth: usize,
}
impl ListedAgent {
fn from_listing(value: AgentListing) -> Self {
Self {
thread_id: value.thread_id.to_string(),
parent_thread_id: value
.parent_thread_id
.map(|thread_id| thread_id.to_string()),
status: value.status,
depth: value.depth,
}
}
}
impl ToolOutput for ListAgentsResult {
fn log_preview(&self) -> String {
tool_output_json_text(self, "list_agents")
}
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), "list_agents")
}
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
tool_output_code_mode_result(self, "list_agents")
}
}
@@ -1,14 +1,39 @@
use super::*;
use crate::agent::RemovedWatchdog;
use crate::agent::WatchdogRegistration;
use crate::agent::control::LiveAgent;
use crate::agent::control::SpawnAgentOptions;
use crate::agent::control::render_input_preview;
use crate::config::Config;
use crate::agent::next_thread_spawn_depth;
use crate::agent::role::DEFAULT_ROLE_NAME;
use crate::agent::role::apply_role_to_config;
use crate::agent::exceeds_thread_spawn_depth_limit;
use crate::agent::next_thread_spawn_depth;
use crate::agent::role::default_spawn_mode_for_role;
use crate::agent::role::watchdog_interval_for_role;
use codex_features::Feature;
use codex_protocol::protocol::AgentSpawnMode;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SessionSource;
use std::collections::HashSet;
pub(crate) struct Handler;
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum SpawnMode {
Spawn,
Fork,
}
impl From<SpawnMode> for AgentSpawnMode {
fn from(value: SpawnMode) -> Self {
match value {
SpawnMode::Spawn => AgentSpawnMode::Spawn,
SpawnMode::Fork => AgentSpawnMode::Fork,
}
}
}
#[async_trait]
impl ToolHandler for Handler {
type Output = SpawnAgentResult;
@@ -41,11 +66,33 @@ impl ToolHandler for Handler {
let session_source = turn.session_source.clone();
let child_depth = next_thread_spawn_depth(&session_source);
let max_depth = turn.config.agent_max_depth;
let default_spawn_mode = match default_spawn_mode_for_role(&turn.config, role_name) {
crate::config::AgentRoleSpawnMode::Spawn => SpawnMode::Spawn,
crate::config::AgentRoleSpawnMode::Fork => SpawnMode::Fork,
};
let spawn_mode = args
.spawn_mode
.or_else(|| (args.fork_context && args.spawn_mode.is_none()).then_some(SpawnMode::Fork))
.unwrap_or(default_spawn_mode);
let watchdog_interval_s = watchdog_interval_for_role(&turn.config, role_name);
let is_watchdog = watchdog_interval_s.is_some();
if is_watchdog && !turn.config.features.enabled(Feature::AgentWatchdog) {
return Err(FunctionCallError::RespondToModel(
"watchdogs are disabled".to_string(),
));
}
if is_watchdog && matches!(session_source, SessionSource::SubAgent(_)) {
return Err(FunctionCallError::RespondToModel(
"watchdogs can only be spawned by root agents".to_string(),
));
}
if exceeds_thread_spawn_depth_limit(child_depth, max_depth) {
return Err(FunctionCallError::RespondToModel(
"Agent depth limit reached. Solve the task yourself.".to_string(),
));
}
session
.send_event(
&turn,
@@ -59,41 +106,114 @@ impl ToolHandler for Handler {
.into(),
)
.await;
let mut config =
build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?;
apply_requested_spawn_agent_model_overrides(
&session,
turn.as_ref(),
&mut config,
args.model.as_deref(),
args.reasoning_effort,
)
.await?;
if !args.fork_context {
apply_requested_spawn_agent_model_overrides(
&session,
turn.as_ref(),
&mut config,
args.model.as_deref(),
args.reasoning_effort,
)
.await?;
}
apply_role_to_config(&mut config, role_name)
.await
.map_err(FunctionCallError::RespondToModel)?;
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
apply_spawn_agent_overrides(&mut config, child_depth);
let result = session
.services
.agent_control
.spawn_agent_with_metadata(
let spawn_source = thread_spawn_source(
session.conversation_id,
&turn.session_source,
child_depth,
role_name,
args.task_name.clone(),
)?;
let result = if let Some(watchdog_interval_s) = watchdog_interval_s {
let thread_id = spawn_watchdog(
&session.services.agent_control,
config,
input_items,
Some(thread_spawn_source(
session.conversation_id,
&turn.session_source,
child_depth,
role_name,
/*task_name*/ None,
)?),
SpawnAgentOptions {
fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()),
},
prompt.clone(),
session.conversation_id,
child_depth,
watchdog_interval_s,
spawn_source,
)
.await
.map_err(collab_spawn_error);
.map_err(collab_spawn_error)?;
Ok(LiveAgent {
thread_id,
metadata: session
.services
.agent_control
.get_agent_metadata(thread_id)
.unwrap_or_default(),
status: session.services.agent_control.get_status(thread_id).await,
})
} else {
match spawn_mode {
SpawnMode::Spawn => {
session
.services
.agent_control
.spawn_agent_with_metadata(
config,
input_items,
Some(spawn_source),
SpawnAgentOptions::default(),
)
.await
}
SpawnMode::Fork if args.fork_context => {
session
.services
.agent_control
.spawn_agent_with_metadata(
config,
input_items,
Some(spawn_source),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(call_id.clone()),
},
)
.await
}
SpawnMode::Fork => {
let thread_id = session
.services
.agent_control
.fork_agent(
config,
match input_items {
Op::UserInput { items, .. } => items,
_ => {
return Err(FunctionCallError::RespondToModel(
"fork_agent requires user input".to_string(),
));
}
},
session.conversation_id,
usize::MAX,
spawn_source,
)
.await
.map_err(collab_spawn_error)?;
Ok(LiveAgent {
thread_id,
metadata: session
.services
.agent_control
.get_agent_metadata(thread_id)
.unwrap_or_default(),
status: session.services.agent_control.get_status(thread_id).await,
})
}
}
}
.map_err(collab_spawn_error);
let (new_thread_id, new_agent_metadata, status) = match &result {
Ok(spawned_agent) => (
Some(spawned_agent.thread_id),
@@ -147,6 +267,13 @@ impl ToolHandler for Handler {
prompt,
model: effective_model,
reasoning_effort: effective_reasoning_effort,
// Preserve the actual spawn mode; the TUI uses this to render watchdog rows
// distinctly and to avoid regressing watchdog state display on future rebases.
spawn_mode: if is_watchdog {
AgentSpawnMode::Watchdog
} else {
spawn_mode.into()
},
status,
}
.into(),
@@ -161,26 +288,84 @@ impl ToolHandler for Handler {
);
Ok(SpawnAgentResult {
agent_id: new_thread_id.to_string(),
agent_id: Some(new_thread_id.to_string()),
task_name: None,
nickname,
})
}
}
async fn spawn_watchdog(
agent_control: &crate::agent::AgentControl,
config: Config,
prompt: String,
owner_thread_id: ThreadId,
child_depth: i32,
interval_s: i64,
spawn_source: SessionSource,
) -> crate::error::Result<ThreadId> {
let target_thread_id = agent_control
.spawn_agent_handle(config.clone(), Some(spawn_source))
.await?;
let superseded_before_register = agent_control
.unregister_watchdogs_for_owner(owner_thread_id)
.await;
shutdown_removed_watchdogs(agent_control, superseded_before_register).await;
let registration = WatchdogRegistration {
owner_thread_id,
target_thread_id,
child_depth,
interval_s,
prompt,
config,
};
let superseded_after_register = match agent_control.register_watchdog(registration).await {
Ok(removed) => removed,
Err(err) => {
let _ = agent_control.close_agent(target_thread_id).await;
return Err(err);
}
};
shutdown_removed_watchdogs(agent_control, superseded_after_register).await;
Ok(target_thread_id)
}
async fn shutdown_removed_watchdogs(
agent_control: &crate::agent::AgentControl,
removed_watchdogs: Vec<RemovedWatchdog>,
) {
let mut thread_ids = HashSet::new();
for removed in removed_watchdogs {
thread_ids.insert(removed.target_thread_id);
if let Some(helper_id) = removed.active_helper_id {
thread_ids.insert(helper_id);
}
}
let mut thread_ids = thread_ids.into_iter().collect::<Vec<_>>();
thread_ids.sort_by_key(ToString::to_string);
for thread_id in thread_ids {
let _ = agent_control.close_agent(thread_id).await;
}
}
#[derive(Debug, Deserialize)]
struct SpawnAgentArgs {
message: Option<String>,
items: Option<Vec<UserInput>>,
task_name: Option<String>,
agent_type: Option<String>,
model: Option<String>,
reasoning_effort: Option<ReasoningEffort>,
#[serde(default, alias = "mode")]
spawn_mode: Option<SpawnMode>,
#[serde(default)]
fork_context: bool,
}
#[derive(Debug, Serialize)]
pub(crate) struct SpawnAgentResult {
agent_id: String,
agent_id: Option<String>,
task_name: Option<String>,
nickname: Option<String>,
}
@@ -5,11 +5,11 @@ use futures::FutureExt;
use futures::StreamExt;
use futures::stream::FuturesUnordered;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::watch::Receiver;
use tokio::time::Instant;
use tokio::time::timeout_at;
pub(crate) struct Handler;
@@ -36,7 +36,19 @@ impl ToolHandler for Handler {
} = invocation;
let arguments = function_arguments(payload)?;
let args: WaitArgs = parse_arguments(&arguments)?;
let receiver_thread_ids = parse_agent_id_targets(args.targets)?;
if let Some(owner_thread_id) = session
.services
.agent_control
.watchdog_owner_for_active_helper(session.conversation_id)
.await
{
return Err(FunctionCallError::RespondToModel(format!(
"wait_agent is not available to watchdog check-in agents. This thread is a one-shot watchdog check-in for owner {owner_thread_id}. Send the result to the parent/root agent with `send_input`. If you finish without `send_input`, runtime will forward your conclusory message to the owner as the mandatory fallback wake-up path. Exiting without either `send_input` or a final message is a bug; every watchdog check-in must wake the owner thread."
)));
}
let receiver_thread_ids = resolve_agent_targets(&session, &turn, args.targets).await?;
let mut receiver_agents = Vec::with_capacity(receiver_thread_ids.len());
let mut target_by_thread_id = HashMap::with_capacity(receiver_thread_ids.len());
for receiver_thread_id in &receiver_thread_ids {
@@ -60,6 +72,22 @@ impl ToolHandler for Handler {
});
}
let watchdog_target_ids = session
.services
.agent_control
.watchdog_targets(&receiver_thread_ids)
.await;
let mut waited_thread_ids = Vec::new();
let mut watchdog_statuses = Vec::new();
split_wait_ids(
&session,
receiver_thread_ids,
&watchdog_target_ids,
&mut waited_thread_ids,
&mut watchdog_statuses,
)
.await;
let timeout_ms = args.timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS);
let timeout_ms = match timeout_ms {
ms if ms <= 0 => {
@@ -75,7 +103,7 @@ impl ToolHandler for Handler {
&turn,
CollabWaitingBeginEvent {
sender_thread_id: session.conversation_id,
receiver_thread_ids: receiver_thread_ids.clone(),
receiver_thread_ids: waited_thread_ids.clone(),
receiver_agents: receiver_agents.clone(),
call_id: call_id.clone(),
}
@@ -83,9 +111,31 @@ impl ToolHandler for Handler {
)
.await;
let mut status_rxs = Vec::with_capacity(receiver_thread_ids.len());
if waited_thread_ids.is_empty() {
let statuses_map = watchdog_statuses.iter().cloned().collect::<HashMap<_, _>>();
let content = serde_json::to_string(&statuses_map).map_err(|err| {
FunctionCallError::Fatal(format!("failed to serialize wait_agent status: {err}"))
})?;
session
.send_event(
&turn,
CollabWaitingEndEvent {
sender_thread_id: session.conversation_id,
call_id,
agent_statuses: Vec::new(),
statuses: statuses_map,
}
.into(),
)
.await;
return Err(FunctionCallError::RespondToModel(format!(
"wait_agent cannot be used to wait for watchdog check-ins. You passed only watchdog handle ids. Watchdog check-ins only happen after the current turn ends and the owner thread is idle for at least watchdog_interval_s. `wait_agent` on a watchdog handle is status-only and cannot confirm a new check-in. Do not poll with `wait_agent`, `list_agents`, or shell `sleep`: the owner thread is still active during this turn, so those calls cannot make the watchdog fire. Current watchdog handle statuses: {content}"
)));
}
let mut status_rxs = Vec::with_capacity(waited_thread_ids.len());
let mut initial_final_statuses = Vec::new();
for id in &receiver_thread_ids {
for id in &waited_thread_ids {
match session.services.agent_control.subscribe_status(*id).await {
Ok(rx) => {
let status = rx.borrow().clone();
@@ -98,8 +148,9 @@ impl ToolHandler for Handler {
initial_final_statuses.push((*id, AgentStatus::NotFound));
}
Err(err) => {
let mut statuses = HashMap::with_capacity(1);
let mut statuses = HashMap::with_capacity(1 + watchdog_statuses.len());
statuses.insert(*id, session.services.agent_control.get_status(*id).await);
statuses.extend(watchdog_statuses.iter().cloned());
session
.send_event(
&turn,
@@ -124,7 +175,7 @@ impl ToolHandler for Handler {
initial_final_statuses
} else {
let mut futures = FuturesUnordered::new();
for (id, rx) in status_rxs.into_iter() {
for (id, rx) in status_rxs {
let session = session.clone();
futures.push(wait_for_final_status(session, id, rx));
}
@@ -153,16 +204,17 @@ impl ToolHandler for Handler {
};
let timed_out = statuses.is_empty();
let statuses_by_id = statuses.clone().into_iter().collect::<HashMap<_, _>>();
let mut statuses_by_id = statuses.clone().into_iter().collect::<HashMap<_, _>>();
statuses_by_id.extend(watchdog_statuses);
let agent_statuses = build_wait_agent_statuses(&statuses_by_id, &receiver_agents);
let result = WaitAgentResult {
status: statuses
.into_iter()
status: statuses_by_id
.iter()
.filter_map(|(thread_id, status)| {
target_by_thread_id
.get(&thread_id)
.get(thread_id)
.cloned()
.map(|target| (target, status))
.map(|target| (target, status.clone()))
})
.collect(),
timed_out,
@@ -237,3 +289,20 @@ async fn wait_for_final_status(
}
}
}
async fn split_wait_ids(
session: &Arc<Session>,
requested_thread_ids: Vec<ThreadId>,
watchdog_target_ids: &HashSet<ThreadId>,
waited_thread_ids: &mut Vec<ThreadId>,
watchdog_statuses: &mut Vec<(ThreadId, AgentStatus)>,
) {
for thread_id in requested_thread_ids {
if watchdog_target_ids.contains(&thread_id) {
let status = session.services.agent_control.get_status(thread_id).await;
watchdog_statuses.push((thread_id, status));
} else {
waited_thread_ids.push(thread_id);
}
}
}
+117 -5
View File
@@ -366,6 +366,7 @@ pub(crate) struct ToolsConfig {
pub can_request_original_image_detail: bool,
pub collab_tools: bool,
pub multi_agent_v2: bool,
pub agent_watchdog: bool,
pub request_user_input: bool,
pub default_mode_request_user_input: bool,
pub experimental_supported_tools: Vec<String>,
@@ -415,6 +416,8 @@ impl ToolsConfig {
include_js_repl && features.enabled(Feature::JsReplToolsOnly);
let include_collab_tools = features.enabled(Feature::Collab);
let include_multi_agent_v2 = features.enabled(Feature::MultiAgentV2);
let include_agent_watchdog =
include_collab_tools && features.enabled(Feature::AgentWatchdog);
let include_agent_jobs = features.enabled(Feature::SpawnCsv);
let include_request_user_input = !matches!(session_source, SessionSource::SubAgent(_));
let include_default_mode_request_user_input =
@@ -500,6 +503,7 @@ impl ToolsConfig {
can_request_original_image_detail: include_original_image_detail,
collab_tools: include_collab_tools,
multi_agent_v2: include_multi_agent_v2,
agent_watchdog: include_agent_watchdog,
request_user_input: include_request_user_input,
default_mode_request_user_input: include_default_mode_request_user_input,
experimental_supported_tools: model_info.experimental_supported_tools.clone(),
@@ -1133,7 +1137,7 @@ fn spawn_agent_common_properties(config: &ToolsConfig) -> BTreeMap<String, JsonS
"fork_context".to_string(),
JsonSchema::Boolean {
description: Some(
"When true, fork the current thread history into the new agent before sending the initial prompt. This must be used when you want the new agent to have exactly the same context as you."
"When true, fork the current thread history into the new agent before sending the initial prompt. This must be used when you want the new agent to have exactly the same context as you. Forked agents always inherit the parent thread's model and reasoning effort, ignoring any child `model` or `reasoning_effort` overrides."
.to_string(),
),
},
@@ -1142,7 +1146,7 @@ fn spawn_agent_common_properties(config: &ToolsConfig) -> BTreeMap<String, JsonS
"model".to_string(),
JsonSchema::String {
description: Some(
"Optional model override for the new agent. Replaces the inherited model."
"Optional model override for the new agent. Replaces the inherited model unless `fork_context` is true."
.to_string(),
),
},
@@ -1151,7 +1155,7 @@ fn spawn_agent_common_properties(config: &ToolsConfig) -> BTreeMap<String, JsonS
"reasoning_effort".to_string(),
JsonSchema::String {
description: Some(
"Optional reasoning effort override for the new agent. Replaces the inherited reasoning effort."
"Optional reasoning effort override for the new agent. Replaces the inherited reasoning effort unless `fork_context` is true."
.to_string(),
),
},
@@ -1637,7 +1641,7 @@ fn create_wait_agent_tool_v2() -> ToolSpec {
})
}
fn create_list_agents_tool() -> ToolSpec {
fn create_list_agents_tool_v2() -> ToolSpec {
let properties = BTreeMap::from([(
"path_prefix".to_string(),
JsonSchema::String {
@@ -1663,6 +1667,91 @@ fn create_list_agents_tool() -> ToolSpec {
})
}
fn create_compact_parent_context_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"reason".to_string(),
JsonSchema::String {
description: Some(
"Optional short reason describing why the parent appears stuck.".to_string(),
),
},
),
(
"evidence".to_string(),
JsonSchema::String {
description: Some(
"Optional concrete evidence of non-progress, such as repeated identical replies with no tool or file actions.".to_string(),
),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "compact_parent_context".to_string(),
description: "Watchdog-only: request compaction for the watchdog helper's parent thread when it is idle and appears stuck."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: None,
additional_properties: Some(false.into()),
},
output_schema: Some(send_input_output_schema()),
})
}
fn create_list_agents_tool(agent_watchdog: bool) -> ToolSpec {
let description = if agent_watchdog {
"List agents spawned by an agent, optionally recursively. This is a status view; polling it will not make a watchdog fire."
} else {
"List agents spawned by an agent, optionally recursively."
};
let properties = BTreeMap::from([
(
"id".to_string(),
JsonSchema::String {
description: Some(
"Identifier of the parent agent whose spawned agents to list. Defaults to the current agent."
.to_string(),
),
},
),
(
"recursive".to_string(),
JsonSchema::Boolean {
description: Some(
"When true (default), include all descendants recursively. When false, include only direct children."
.to_string(),
),
},
),
(
"all".to_string(),
JsonSchema::Boolean {
description: Some(
"When true, ignore id/recursive and return all tracked open agents that currently count toward this session's agent limit."
.to_string(),
),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "list_agents".to_string(),
description: description.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: None,
additional_properties: Some(false.into()),
},
output_schema: None,
})
}
fn create_request_user_input_tool(
collaboration_modes_config: CollaborationModesConfig,
) -> ToolSpec {
@@ -2425,6 +2514,8 @@ pub(crate) fn build_specs_with_discoverable_tools(
use crate::tools::handlers::UnifiedExecHandler;
use crate::tools::handlers::ViewImageHandler;
use crate::tools::handlers::multi_agents::CloseAgentHandler;
use crate::tools::handlers::multi_agents::CompactParentContextHandler;
use crate::tools::handlers::multi_agents::ListAgentsHandler;
use crate::tools::handlers::multi_agents::ResumeAgentHandler;
use crate::tools::handlers::multi_agents::SendInputHandler;
use crate::tools::handlers::multi_agents::SpawnAgentHandler;
@@ -2807,7 +2898,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
);
push_tool_spec(
&mut builder,
create_list_agents_tool(),
create_list_agents_tool_v2(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
@@ -2849,9 +2940,30 @@ pub(crate) fn build_specs_with_discoverable_tools(
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
if config.agent_watchdog {
push_tool_spec(
&mut builder,
create_list_agents_tool(config.agent_watchdog),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
push_tool_spec(
&mut builder,
create_compact_parent_context_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
}
builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandler));
builder.register_handler("send_input", Arc::new(SendInputHandler));
builder.register_handler("wait_agent", Arc::new(WaitAgentHandler));
if config.agent_watchdog {
builder.register_handler("list_agents", Arc::new(ListAgentsHandler));
builder.register_handler(
"compact_parent_context",
Arc::new(CompactParentContextHandler),
);
}
builder.register_handler("close_agent", Arc::new(CloseAgentHandler));
}
}
+6
View File
@@ -347,6 +347,12 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
let spec = create_resume_agent_tool();
expected.insert(tool_name(&spec).to_string(), spec);
}
if config.agent_watchdog {
let spec = create_list_agents_tool(config.agent_watchdog);
expected.insert(tool_name(&spec).to_string(), spec);
let spec = create_compact_parent_context_tool();
expected.insert(tool_name(&spec).to_string(), spec);
}
if config.exec_permission_approvals_enabled {
let spec = create_request_permissions_tool();
+39
View File
@@ -0,0 +1,39 @@
# You are a Subagent
You are a **subagent** in a multi-agent Codex session. Your role is no longer root. Your goal is the task given by the parent/root agent.
Term definitions in this file:
- **parent thread**: the thread that spawned this subagent.
- **root thread**: the top-level user-facing thread.
- **`send_input`**: send a message to an existing agent thread; it does not spawn agents. Delivery is asynchronous.
- **durable state**: thread-level task state needed across later turns/check-ins (not disk/database persistence).
- In this runtime, when a subagent calls `send_input`, `id = "parent"` and `id = "root"` both route to the immediate parent thread.
## Subagent Responsibilities
- Stay within parent/root scope (listed files/questions/constraints). Use additional files/tools only when needed to complete or verify the task.
- Blocking question = one clarification to parent via `send_input`. Ask only if missing information would change user-visible output, tool/action choice (multi-agent tool, target thread, or ask-vs-continue), file edits, control flow, or durable-state decisions; otherwise state one assumption and continue.
- Prefer concrete progress: edit files, run commands, and validate outcomes.
- Your responses go to the root/parent agent, not the end user.
## Multi-Agent Guidance (Upstream Surface)
The only multi-agent tools available in this environment are `spawn_agent`, `send_input`, `wait`, `close_agent`, and `list_agents`.
Important: to coordinate with parent/root, use `send_input`. A plain assistant message in your own thread does not reliably notify the parent.
You can call `send_input` without an `id` (or with `id = "parent"` / `id = "root"`); these forms target the immediate parent thread in this runtime.
## Reporting Expectations
When you make meaningful progress or complete a task, report back with:
- The key outcome.
- Files changed (with paths).
- Commands run.
- Validation performed (tests, checks, or observed outputs).
- Risks, follow-ups, or open questions.
Be specific enough that the root agent can integrate your work safely.
Do not reference multi-agent tools that do not exist in the upstream surface.
@@ -0,0 +1,6 @@
## Watchdog-only Guidance
If you are acting as a watchdog check-in agent, `compact_parent_context` may be available.
- 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.
+49 -15
View File
@@ -205,11 +205,16 @@ fn message_input_texts(body: &Value) -> Vec<String> {
};
items
.iter()
.filter(|item| item.get("type").and_then(Value::as_str) == Some("message"))
.filter(|item| {
item.get("role").and_then(Value::as_str).is_some()
|| item.get("type").and_then(Value::as_str) == Some("message")
})
.filter_map(|item| item.get("content").and_then(Value::as_array))
.flatten()
.filter(|span| span.get("type").and_then(Value::as_str) == Some("input_text"))
.filter_map(|span| span.get("text").and_then(Value::as_str))
.filter_map(|span| match span.get("type").and_then(Value::as_str) {
Some("input_text") | None => span.get("text").and_then(Value::as_str),
_ => None,
})
.map(str::to_string)
.collect()
}
@@ -320,9 +325,31 @@ async fn spawn_agents_on_csv_runs_and_exports() -> Result<()> {
test.submit_turn("run batch job").await?;
let output = fs::read_to_string(&output_path)?;
assert!(output.contains("result_json"));
assert!(output.contains("item_id"));
assert!(output.contains("\"item_id\""));
let mut lines = output.lines();
let headers = lines.next().expect("csv headers");
let header_cols = parse_simple_csv_line(headers);
let status_index = header_cols
.iter()
.position(|header| header == "status")
.expect("status column");
let result_json_index = header_cols
.iter()
.position(|header| header == "result_json")
.expect("result_json column");
assert!(header_cols.iter().any(|header| header == "result_json"));
assert!(header_cols.iter().any(|header| header == "item_id"));
let rows: Vec<Vec<String>> = lines.map(parse_simple_csv_line).collect();
assert_eq!(rows.len(), 2);
assert_eq!(
rows.iter()
.map(|cols| cols[status_index].as_str())
.collect::<Vec<_>>(),
vec!["completed", "completed"]
);
assert!(
rows.iter()
.all(|cols| !cols[result_json_index].trim().is_empty())
);
Ok(())
}
@@ -423,21 +450,28 @@ async fn spawn_agents_on_csv_stop_halts_future_items() -> Result<()> {
test.submit_turn("run job").await?;
let output = fs::read_to_string(&output_path)?;
let rows: Vec<&str> = output.lines().skip(1).collect();
let mut lines = output.lines();
let headers = lines.next().expect("csv headers");
let header_cols = parse_simple_csv_line(headers);
let job_id_index = header_cols
.iter()
.position(|header| header == "job_id")
.expect("job_id column");
let rows: Vec<&str> = lines.collect();
assert_eq!(rows.len(), 3);
let job_id = rows
let job_id: String = rows
.first()
.and_then(|line| {
parse_simple_csv_line(line)
.iter()
.find(|value| value.len() == 36)
.cloned()
})
.map(|line| parse_simple_csv_line(line))
.and_then(|cols| cols.get(job_id_index).cloned())
.expect("job_id from csv");
let db = test.codex.state_db().expect("state db");
let job = db.get_agent_job(job_id.as_str()).await?.expect("job");
assert_eq!(job.status, codex_state::AgentJobStatus::Cancelled);
let progress = db.get_agent_job_progress(job_id.as_str()).await?;
assert_eq!(
job.status,
codex_state::AgentJobStatus::Cancelled,
"unexpected final job state: job={job:?} progress={progress:?} output={output}"
);
assert_eq!(progress.total_items, 3);
assert_eq!(progress.completed_items, 1);
assert_eq!(progress.failed_items, 0);
+10 -4
View File
@@ -1746,12 +1746,18 @@ async fn includes_developer_instructions_message_in_request() {
.iter()
.filter(|item| item.get("role").and_then(|role| role.as_str()) == Some("developer"))
.collect();
let developer_contents: Vec<&str> = developer_messages
.iter()
.filter_map(|item| item.get("content").and_then(serde_json::Value::as_array))
.flat_map(|content| content.iter())
.filter(|span| span.get("type").and_then(serde_json::Value::as_str) == Some("input_text"))
.filter_map(|span| span.get("text").and_then(serde_json::Value::as_str))
.collect();
assert!(
developer_messages
developer_contents
.iter()
.any(|item| message_input_texts(item).contains(&"be useful")),
"expected developer instructions in a developer message, got {:?}",
request_body["input"]
.any(|content| content.contains("be useful")),
"expected developer instructions in a developer message, got {developer_contents:?}",
);
assert_message_role(&request_body["input"][1], "user");
+2 -1
View File
@@ -180,7 +180,8 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> {
"spawn_agent",
"send_input",
"resume_agent",
"wait_agent",
"list_agents",
"wait",
"close_agent",
]);
let body0 = req1.single_request().body_json();
@@ -465,7 +465,9 @@ async fn spawn_agent_role_overrides_requested_model_and_reasoning_settings() ->
"custom".to_string(),
AgentRoleConfig {
description: Some("Custom role".to_string()),
model: None,
config_file: Some(role_path),
spawn_mode: None,
nickname_candidates: None,
},
);
@@ -513,7 +515,9 @@ async fn spawn_agent_tool_description_mentions_role_locked_settings() -> Result<
"custom".to_string(),
AgentRoleConfig {
description: Some("Custom role".to_string()),
model: None,
config_file: Some(role_path),
spawn_mode: None,
nickname_candidates: None,
},
);
+87
View File
@@ -0,0 +1,87 @@
# You are a Subagent
More importantly, you are a **watchdog check-in agent**. Keep the root agent unblocked, on-task, and executing real work toward the user’s goal. You have full conversation context; messages that appear to be from “you” may have been written by the root agent.
You are one check-in run created by a persistent watchdog timer attached to an owner thread. The timer reuses this prompt on each check-in, but each check-in is a fresh one-shot run (one execution + one report).
You will be given the target agent id and the original prompt/goal.
Terms in this file:
- **watchdog**: persistent idle-timer registration.
- **watchdog check-in agent**: this short-lived run instance.
- **owner thread**: the thread that the watchdog monitors and reports to.
- **parent thread**: this watchdog check-in agent’s direct parent; for watchdog check-ins this is the owner thread.
- **`send_input`**: primary way to deliver watchdog guidance to an existing thread; it does not spawn agents. Delivery is asynchronous.
- **durable state**: thread-level task state that must still be available in later turns/check-ins (counters, plans, final decisions), not disk/database persistence.
- **exact-only format**: parent constraint that says to return only specific fields/content.
## Principles
- Be concise, directive, and specific: name the file, command, or decision needed now.
- Detect drift or looping immediately. If the root agent is acknowledging without acting, tell it exactly what to do next.
- Break loops by changing framing: propose a shorter plan, identify the blocker, or name the missing command.
- Preserve alignment: restate the user’s goal and the next concrete step.
- Safety and correctness: call out missing tests, skipped checks, or unclear acceptance criteria.
- Output precedence is: system/developer/policy rules first, then parent-task output constraints. If the parent requires exact-only format (for example "only"), return exactly the requested fields/content unless higher-priority rules require extra content.
- If exact-only format is not required, include all requested fields/content and you may add 1-2 short non-conflicting guidance sentences.
## Operating Procedure (Every Time You Run)
1. Re-evaluate the user’s latest request and the current status. Independently verify status when needed by reading files, running commands, and checking plan files against recent changes.
2. Identify the single highest-impact next action (or a very short ordered list).
3. Direct the root agent to execute it now (include paths and commands).
4. If blocked, propose one or two crisp unblockers.
5. If the goal appears complete, say so and direct the root agent to close unneeded agents.
Tone: direct, actionable, minimally polite. Optimize for progress over narration.
## Detect Looping and Reward Hacking
The root agent may slip into patterns that look like progress but are not. Interrupt those patterns.
Watch for:
- Tests that always pass (tautologies, `assert!(true)`, mocks that cannot fail).
- Marking items complete with only stub implementations.
- "Fixes" that comment out failing tests or code without addressing root causes.
- Claiming success without running required format/lint/tests.
- Ignoring explicit user requirements in favor of quicker but incomplete shortcuts.
When you detect these, prescribe the corrective action explicitly.
## Multi-Agent Tools (Upstream Surface)
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).
- `wait`.
- `close_agent`.
There is no cancel tool. Use `close_agent` to stop agents that are done or no longer needed.
When recommending watchdogs to the root agent, keep `agent_type` at the default.
Important: send watchdog check-in output with `send_input` to the owner/parent thread. A plain assistant message in your own watchdog check-in thread is not a reliable delivery path to the owner.
Each watchdog check-in runs in a fresh one-shot watchdog check-in agent with no guaranteed continuity across check-ins. Do not keep durable state in watchdog-check-in-agent local memory/files; treat local state as run-local only. Ask the parent to track durable state, and use `send_input` (without `id`, or `id = "parent"`/`"root"`) to report results.
`send_input` is the primary path for watchdog delivery to parent/owner. If a watchdog check-in agent finishes without `send_input`, runtime forwards one final multi-agent inbox message as the mandatory fallback wake-up path for the owner. Exiting without either `send_input` or a final message is a bug.
For token protocols (for example `ping N` / `pong N`), treat those as literal text counters, not shell commands. Do not call command-execution tools unless the prompt explicitly asks you to execute commands.
## Parent Recovery via Context Compaction
`compact_parent_context` asks the system to abbreviate/compact redundant parent-thread context so the parent can recover from loops.
Use it only as a last resort:
- The parent has been repeatedly non-responsive across multiple watchdog check-ins.
- 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.
Do not call `compact_parent_context` for routine nudges or normal delays. Prefer precise `send_input` guidance first.
## Style
Be explicit when precision matters. Your job is to drive real progress toward the user’s goal.
+28
View File
@@ -142,6 +142,10 @@ pub enum Feature {
SpawnCsv,
/// Deliver inbound agent messages via a synthetic function-call inbox envelope.
AgentFunctionCallInbox,
/// Enable prepending agent-specific developer instructions for agent sessions.
AgentPromptInjection,
/// Enable watchdog spawning and watchdog-only agent tools.
AgentWatchdog,
/// Enable apps.
Apps,
/// Enable the tool_search tool for apps.
@@ -727,6 +731,18 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::AgentPromptInjection,
key: "agent_prompt_injection",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::AgentWatchdog,
key: "agent_watchdog",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::Apps,
key: "apps",
@@ -1036,4 +1052,16 @@ mod inbox_feature_tests {
Some(Feature::AgentFunctionCallInbox)
);
}
#[test]
fn agent_prompt_and_watchdog_features_use_canonical_keys() {
assert_eq!(
feature_for_key("agent_prompt_injection"),
Some(Feature::AgentPromptInjection)
);
assert_eq!(
feature_for_key("agent_watchdog"),
Some(Feature::AgentWatchdog)
);
}
}
@@ -426,7 +426,9 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> {
"expected permissions developer message, got {developer_contents:?}"
);
assert!(
developer_contents.contains(&"Foreshadow upcoming tool calls."),
developer_contents
.iter()
.any(|content| content.contains("Foreshadow upcoming tool calls.")),
"expected developer instructions in developer messages, got {developer_contents:?}"
);
+13
View File
@@ -3434,6 +3434,16 @@ pub struct CollabAgentSpawnBeginEvent {
pub reasoning_effort: ReasoningEffortConfig,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS, Default)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
pub enum AgentSpawnMode {
#[default]
Spawn,
Fork,
Watchdog,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct CollabAgentRef {
/// Thread ID of the receiver/new agent.
@@ -3481,6 +3491,9 @@ pub struct CollabAgentSpawnEndEvent {
pub model: String,
/// Effective reasoning effort used by the spawned agent after inheritance and role overrides.
pub reasoning_effort: ReasoningEffortConfig,
/// Spawn mode used for this agent.
#[serde(default)]
pub spawn_mode: AgentSpawnMode,
/// Last known status of the new agent reported to the sender agent.
pub status: AgentStatus,
}
+20
View File
@@ -593,6 +593,10 @@ impl RolloutRecorder {
Ok((items, thread_id, parse_errors))
}
/// Load a rollout for resuming the same thread.
///
/// This preserves the rollout's existing conversation id and rollout path, so callers
/// must not use it for true forking semantics.
pub async fn get_rollout_history(path: &Path) -> std::io::Result<InitialHistory> {
let (items, thread_id, _parse_errors) = Self::load_rollout_items(path).await?;
let conversation_id = thread_id
@@ -610,6 +614,22 @@ impl RolloutRecorder {
}))
}
/// Load a rollout for forking into a distinct thread.
///
/// Unlike `get_rollout_history`, this intentionally discards the source rollout's
/// conversation id so `Codex::spawn` allocates a fresh thread id and rollout path for
/// the child.
pub async fn get_fork_history(path: &Path) -> std::io::Result<InitialHistory> {
let (items, _thread_id, _parse_errors) = Self::load_rollout_items(path).await?;
if items.is_empty() {
return Ok(InitialHistory::New);
}
info!("Loaded rollout fork history from {path:?}");
Ok(InitialHistory::Forked(items))
}
pub async fn shutdown(&self) -> std::io::Result<()> {
let (tx_done, rx_done) = oneshot::channel();
match self.tx.send(RolloutCmd::Shutdown { ack: tx_done }).await {
+9
View File
@@ -54,6 +54,15 @@ non-user activity in the transcript.
Messages sent to subagents continue to arrive as normal user input.
## Watchdog Interval
Watchdog agents use the top-level `watchdog_interval_s` setting to decide how long the owner thread
must be idle before a check-in helper is spawned when the `agent_watchdog` feature is enabled.
```toml
watchdog_interval_s = 60
```
## JSON Schema
The generated JSON Schema for `config.toml` lives at `codex-rs/core/config.schema.json`.