Add agents.interrupt_message for interruption markers (#19351)

## Why

Agent interruptions currently always persist a model-visible
interrupted-turn marker before emitting `TurnAborted`. That marker is
useful by default because it gives the next model turn context about a
deliberately interrupted task, but some deployments need to suppress
that history injection entirely while still keeping the client-visible
interruption event.

## What changed

- Add `[agents] interrupt_message = false` to disable the model-visible
interrupted-turn marker.
- Resolve the setting into `Config::agent_interrupt_message_enabled`,
defaulting to `true` so existing behavior is unchanged.
- Apply the setting to both live interrupted turns and interrupted fork
snapshots.
- Keep emitting `TurnAborted` even when the history marker is disabled.
- Regenerate `core/config.schema.json` for the new
`agents.interrupt_message` field.

## Testing

- `cargo test -p codex-core load_config_resolves_agent_interrupt_message
-- --nocapture`
- `cargo test -p codex-core
disabled_interrupted_fork_snapshot_appends_only_interrupt_event --
--nocapture`
- `cargo test -p codex-core
multi_agent_v2_interrupted_marker_uses_developer_input_message --
--nocapture`
- `cargo test -p codex-core
multi_agent_v2_followup_task_can_disable_interrupted_marker --
--nocapture`
- `cargo test -p codex-core
multi_agent_v2_followup_task_interrupts_busy_child_without_losing_message
-- --nocapture`
- `cargo check -p codex-core`
This commit is contained in:
jif-oai
2026-04-24 16:02:45 +02:00
committed by GitHub
Unverified
parent deb4509302
commit 28742866c7
8 changed files with 287 additions and 57 deletions
+3
View File
@@ -566,6 +566,9 @@ pub struct AgentsToml {
/// Default maximum runtime in seconds for agent job workers.
#[schemars(range(min = 1))]
pub job_max_runtime_seconds: Option<u64>,
/// Whether to record a model-visible message when an agent turn is interrupted.
/// Defaults to true.
pub interrupt_message: Option<bool>,
/// User-defined role declarations keyed by role name.
///
+4
View File
@@ -36,6 +36,10 @@
"$ref": "#/definitions/AgentRoleToml"
},
"properties": {
"interrupt_message": {
"description": "Whether to record a model-visible message when an agent turn is interrupted. Defaults to true.",
"type": "boolean"
},
"job_max_runtime_seconds": {
"description": "Default maximum runtime in seconds for agent job workers.",
"format": "uint64",
+32
View File
@@ -3941,6 +3941,7 @@ async fn load_config_rejects_missing_agent_role_config_file() -> std::io::Result
max_threads: None,
max_depth: None,
job_max_runtime_seconds: None,
interrupt_message: None,
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
@@ -4856,6 +4857,29 @@ model = "gpt-5-mini"
Ok(())
}
#[tokio::test]
async fn load_config_resolves_agent_interrupt_message() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
agents: Some(AgentsToml {
interrupt_message: Some(false),
..Default::default()
}),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert!(!config.agent_interrupt_message_enabled);
Ok(())
}
#[tokio::test]
async fn load_config_normalizes_agent_role_nickname_candidates() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -4864,6 +4888,7 @@ async fn load_config_normalizes_agent_role_nickname_candidates() -> std::io::Res
max_threads: None,
max_depth: None,
job_max_runtime_seconds: None,
interrupt_message: None,
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
@@ -4906,6 +4931,7 @@ async fn load_config_rejects_empty_agent_role_nickname_candidates() -> std::io::
max_threads: None,
max_depth: None,
job_max_runtime_seconds: None,
interrupt_message: None,
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
@@ -4942,6 +4968,7 @@ async fn load_config_rejects_duplicate_agent_role_nickname_candidates() -> std::
max_threads: None,
max_depth: None,
job_max_runtime_seconds: None,
interrupt_message: None,
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
@@ -4978,6 +5005,7 @@ async fn load_config_rejects_unsafe_agent_role_nickname_candidates() -> std::io:
max_threads: None,
max_depth: None,
job_max_runtime_seconds: None,
interrupt_message: None,
roles: BTreeMap::from([(
"researcher".to_string(),
AgentRoleToml {
@@ -5234,6 +5262,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
agent_interrupt_message_enabled: true,
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home().to_path_buf(),
log_dir: fixture.codex_home().join("log").to_path_buf(),
@@ -5431,6 +5460,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
agent_interrupt_message_enabled: true,
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home().to_path_buf(),
log_dir: fixture.codex_home().join("log").to_path_buf(),
@@ -5582,6 +5612,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
agent_interrupt_message_enabled: true,
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home().to_path_buf(),
log_dir: fixture.codex_home().join("log").to_path_buf(),
@@ -5718,6 +5749,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
agent_interrupt_message_enabled: true,
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home().to_path_buf(),
log_dir: fixture.codex_home().join("log").to_path_buf(),
+9
View File
@@ -432,6 +432,9 @@ pub struct Config {
/// Maximum runtime in seconds for agent job workers before they are failed.
pub agent_job_max_runtime_seconds: Option<u64>,
/// Whether to record a model-visible message when an agent turn is interrupted.
pub agent_interrupt_message_enabled: bool,
/// Maximum nesting depth allowed for spawned agent threads.
pub agent_max_depth: i32,
@@ -2002,6 +2005,11 @@ impl Config {
"agents.job_max_runtime_seconds must fit within a 64-bit signed integer",
));
}
let agent_interrupt_message_enabled = cfg
.agents
.as_ref()
.and_then(|agents| agents.interrupt_message)
.unwrap_or(true);
let background_terminal_max_timeout = cfg
.background_terminal_max_timeout
.unwrap_or(DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS)
@@ -2366,6 +2374,7 @@ impl Config {
agent_roles,
memories: cfg.memories.unwrap_or_default().into(),
agent_job_max_runtime_seconds,
agent_interrupt_message_enabled,
codex_home,
sqlite_home,
log_dir,
+56 -27
View File
@@ -19,6 +19,7 @@ use tracing::info_span;
use tracing::trace;
use tracing::warn;
use crate::config::Config;
use crate::context::ContextualUserFragment;
use crate::hook_runtime::PendingInputHookDisposition;
use crate::hook_runtime::inspect_pending_input;
@@ -62,27 +63,50 @@ pub(crate) use user_shell::execute_user_shell_command;
const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InterruptedTurnHistoryMarker {
Disabled,
ContextualUser,
Developer,
}
impl InterruptedTurnHistoryMarker {
pub(crate) fn from_config(config: &Config) -> Self {
if !config.agent_interrupt_message_enabled {
return Self::Disabled;
}
if config.features.enabled(Feature::MultiAgentV2) {
Self::Developer
} else {
Self::ContextualUser
}
}
}
/// Shared model-visible marker used by both the real interrupt path and
/// interrupted fork snapshots.
pub(crate) fn interrupted_turn_history_marker(multi_agent_v2_enabled: bool) -> ResponseItem {
let guidance = if multi_agent_v2_enabled {
crate::context::TurnAborted::INTERRUPTED_DEVELOPER_GUIDANCE
} else {
crate::context::TurnAborted::INTERRUPTED_GUIDANCE
};
let marker = crate::context::TurnAborted::new(guidance);
if multi_agent_v2_enabled {
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: marker.render(),
}],
end_turn: None,
phase: None,
pub(crate) fn interrupted_turn_history_marker(
marker: InterruptedTurnHistoryMarker,
) -> Option<ResponseItem> {
match marker {
InterruptedTurnHistoryMarker::Disabled => None,
InterruptedTurnHistoryMarker::ContextualUser => Some(ContextualUserFragment::into(
crate::context::TurnAborted::new(crate::context::TurnAborted::INTERRUPTED_GUIDANCE),
)),
InterruptedTurnHistoryMarker::Developer => {
let marker = crate::context::TurnAborted::new(
crate::context::TurnAborted::INTERRUPTED_DEVELOPER_GUIDANCE,
);
Some(ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: marker.render(),
}],
end_turn: None,
phase: None,
})
}
} else {
ContextualUserFragment::into(marker)
}
}
@@ -692,15 +716,20 @@ impl Session {
if reason == TurnAbortReason::Interrupted {
self.cleanup_after_interrupt(&task.turn_context).await;
let marker = interrupted_turn_history_marker(self.enabled(Feature::MultiAgentV2));
self.record_into_history(std::slice::from_ref(&marker), task.turn_context.as_ref())
.await;
self.persist_rollout_items(&[RolloutItem::ResponseItem(marker)])
.await;
// Ensure the marker is durably visible before emitting TurnAborted: some clients
// synchronously re-read the rollout on receipt of the abort event.
if let Err(err) = self.flush_rollout().await {
warn!("failed to flush interrupted-turn marker before emitting TurnAborted: {err}");
if let Some(marker) = interrupted_turn_history_marker(
InterruptedTurnHistoryMarker::from_config(task.turn_context.config.as_ref()),
) {
self.record_into_history(std::slice::from_ref(&marker), task.turn_context.as_ref())
.await;
self.persist_rollout_items(&[RolloutItem::ResponseItem(marker)])
.await;
// Ensure the marker is durably visible before emitting TurnAborted: some clients
// synchronously re-read the rollout on receipt of the abort event.
if let Err(err) = self.flush_rollout().await {
warn!(
"failed to flush interrupted-turn marker before emitting TurnAborted: {err}"
);
}
}
}
+18 -16
View File
@@ -17,12 +17,12 @@ use crate::session::INITIAL_SUBMIT_ID;
use crate::shell_snapshot::ShellSnapshot;
use crate::skills_watcher::SkillsWatcher;
use crate::skills_watcher::SkillsWatcherEvent;
use crate::tasks::InterruptedTurnHistoryMarker;
use crate::tasks::interrupted_turn_history_marker;
use codex_analytics::AnalyticsEventsClient;
use codex_app_server_protocol::ThreadHistoryBuilder;
use codex_app_server_protocol::TurnStatus;
use codex_exec_server::EnvironmentManager;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_model_provider::create_model_provider;
@@ -746,7 +746,7 @@ impl ThreadManager {
let snapshot = snapshot.into();
let history = RolloutRecorder::get_rollout_history(&path).await?;
let snapshot_state = snapshot_turn_state(&history);
let multi_agent_v2_enabled = config.features.enabled(Feature::MultiAgentV2);
let interrupted_marker = InterruptedTurnHistoryMarker::from_config(&config);
let history = match snapshot {
ForkSnapshot::TruncateBeforeNthUserMessage(nth_user_message) => {
truncate_before_nth_user_message(history, nth_user_message, &snapshot_state)
@@ -762,7 +762,7 @@ impl ThreadManager {
append_interrupted_boundary(
history,
snapshot_state.active_turn_id,
multi_agent_v2_enabled,
interrupted_marker,
)
} else {
history
@@ -1234,7 +1234,7 @@ fn snapshot_turn_state(history: &InitialHistory) -> SnapshotTurnState {
fn append_interrupted_boundary(
history: InitialHistory,
turn_id: Option<String>,
multi_agent_v2_enabled: bool,
interrupted_marker: InterruptedTurnHistoryMarker,
) -> InitialHistory {
let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
turn_id,
@@ -1244,23 +1244,25 @@ fn append_interrupted_boundary(
}));
match history {
InitialHistory::New | InitialHistory::Cleared => InitialHistory::Forked(vec![
RolloutItem::ResponseItem(interrupted_turn_history_marker(multi_agent_v2_enabled)),
aborted_event,
]),
InitialHistory::New | InitialHistory::Cleared => {
let mut history = Vec::new();
if let Some(marker) = interrupted_turn_history_marker(interrupted_marker) {
history.push(RolloutItem::ResponseItem(marker));
}
history.push(aborted_event);
InitialHistory::Forked(history)
}
InitialHistory::Forked(mut history) => {
history.push(RolloutItem::ResponseItem(interrupted_turn_history_marker(
multi_agent_v2_enabled,
)));
if let Some(marker) = interrupted_turn_history_marker(interrupted_marker) {
history.push(RolloutItem::ResponseItem(marker));
}
history.push(aborted_event);
InitialHistory::Forked(history)
}
InitialHistory::Resumed(mut resumed) => {
resumed
.history
.push(RolloutItem::ResponseItem(interrupted_turn_history_marker(
multi_agent_v2_enabled,
)));
if let Some(marker) = interrupted_turn_history_marker(interrupted_marker) {
resumed.history.push(RolloutItem::ResponseItem(marker));
}
resumed.history.push(aborted_event);
InitialHistory::Forked(resumed.history)
}
+67 -14
View File
@@ -3,6 +3,7 @@ use crate::config::test_config;
use crate::rollout::RolloutRecorder;
use crate::session::session::SessionSettingsUpdate;
use crate::session::tests::make_session_and_context;
use crate::tasks::InterruptedTurnHistoryMarker;
use crate::tasks::interrupted_turn_history_marker;
use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig;
use codex_models_manager::manager::RefreshStrategy;
@@ -58,6 +59,16 @@ fn disabled_environment_manager_for_tests() -> Arc<codex_exec_server::Environmen
))
}
fn contextual_user_interrupted_marker() -> ResponseItem {
interrupted_turn_history_marker(InterruptedTurnHistoryMarker::ContextualUser)
.expect("contextual-user interrupted marker should be enabled")
}
fn developer_interrupted_marker() -> ResponseItem {
interrupted_turn_history_marker(InterruptedTurnHistoryMarker::Developer)
.expect("developer interrupted marker should be enabled")
}
#[test]
fn truncates_before_requested_user_message() {
let items = [
@@ -449,16 +460,14 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() {
append_interrupted_boundary(
committed_history,
/*turn_id*/ None,
/*multi_agent_v2_enabled*/ false,
InterruptedTurnHistoryMarker::ContextualUser,
)
.get_rollout_items()
)
.expect("serialize interrupted fork history"),
serde_json::to_value(vec![
RolloutItem::ResponseItem(user_msg("hello")),
RolloutItem::ResponseItem(interrupted_turn_history_marker(
/*multi_agent_v2_enabled*/ false,
)),
RolloutItem::ResponseItem(contextual_user_interrupted_marker()),
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: None,
reason: TurnAbortReason::Interrupted,
@@ -473,15 +482,13 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() {
append_interrupted_boundary(
InitialHistory::New,
/*turn_id*/ None,
/*multi_agent_v2_enabled*/ false,
InterruptedTurnHistoryMarker::ContextualUser,
)
.get_rollout_items()
)
.expect("serialize interrupted empty fork history"),
serde_json::to_value(vec![
RolloutItem::ResponseItem(interrupted_turn_history_marker(
/*multi_agent_v2_enabled*/ false,
)),
RolloutItem::ResponseItem(contextual_user_interrupted_marker()),
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: None,
reason: TurnAbortReason::Interrupted,
@@ -493,14 +500,60 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() {
);
}
#[test]
fn disabled_interrupted_fork_snapshot_appends_only_interrupt_event() {
let committed_history =
InitialHistory::Forked(vec![RolloutItem::ResponseItem(user_msg("hello"))]);
assert_eq!(
serde_json::to_value(
append_interrupted_boundary(
committed_history,
/*turn_id*/ None,
InterruptedTurnHistoryMarker::Disabled,
)
.get_rollout_items()
)
.expect("serialize disabled interrupted fork history"),
serde_json::to_value(vec![
RolloutItem::ResponseItem(user_msg("hello")),
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: None,
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
})),
])
.expect("serialize expected disabled interrupted fork history"),
);
assert_eq!(
serde_json::to_value(
append_interrupted_boundary(
InitialHistory::New,
/*turn_id*/ None,
InterruptedTurnHistoryMarker::Disabled,
)
.get_rollout_items()
)
.expect("serialize disabled interrupted empty fork history"),
serde_json::to_value(vec![RolloutItem::EventMsg(EventMsg::TurnAborted(
TurnAbortedEvent {
turn_id: None,
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
},
))])
.expect("serialize expected disabled interrupted empty fork history"),
);
}
#[test]
fn interrupted_snapshot_is_not_mid_turn() {
let interrupted_history = InitialHistory::Forked(vec![
RolloutItem::ResponseItem(user_msg("hello")),
RolloutItem::ResponseItem(assistant_msg("partial")),
RolloutItem::ResponseItem(interrupted_turn_history_marker(
/*multi_agent_v2_enabled*/ false,
)),
RolloutItem::ResponseItem(contextual_user_interrupted_marker()),
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
@@ -521,7 +574,7 @@ fn interrupted_snapshot_is_not_mid_turn() {
#[test]
fn multi_agent_v2_interrupted_marker_uses_developer_input_message() {
let marker = interrupted_turn_history_marker(/*multi_agent_v2_enabled*/ true);
let marker = developer_interrupted_marker();
let ResponseItem::Message { role, content, .. } = marker else {
panic!("expected interrupted marker to be a message");
@@ -653,7 +706,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor
.filter(|item| !matches!(item, RolloutItem::SessionMeta(_)))
.collect();
let interrupted_marker_json = serde_json::to_value(RolloutItem::ResponseItem(
interrupted_turn_history_marker(/*multi_agent_v2_enabled*/ false),
contextual_user_interrupted_marker(),
))
.expect("serialize interrupted marker");
let interrupted_abort_json = serde_json::to_value(RolloutItem::EventMsg(
@@ -845,7 +898,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_
.filter(|item| !matches!(item, RolloutItem::SessionMeta(_)))
.collect();
let interrupted_marker_json = serde_json::to_value(RolloutItem::ResponseItem(
interrupted_turn_history_marker(/*multi_agent_v2_enabled*/ false),
contextual_user_interrupted_marker(),
))
.expect("serialize interrupted marker");
assert_eq!(
@@ -1631,6 +1631,104 @@ async fn multi_agent_v2_followup_task_interrupts_busy_child_without_losing_messa
.expect("shutdown should submit");
}
#[tokio::test]
async fn multi_agent_v2_followup_task_can_disable_interrupted_marker() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.conversation_id = root.thread_id;
let mut config = turn.config.as_ref().clone();
let _ = config.features.enable(Feature::MultiAgentV2);
config.agent_interrupt_message_enabled = false;
turn.config = Arc::new(config);
let session = Arc::new(session);
let turn = Arc::new(turn);
let worker_path = AgentPath::try_from("/root/worker").expect("worker path");
let agent_id = session
.services
.agent_control
.spawn_agent_with_metadata(
(*turn.config).clone(),
Op::CleanBackgroundTerminals,
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root.thread_id,
depth: 1,
agent_path: Some(worker_path),
agent_nickname: None,
agent_role: None,
})),
crate::agent::control::SpawnAgentOptions::default(),
)
.await
.expect("worker spawn should succeed")
.thread_id;
let thread = manager
.get_thread(agent_id)
.await
.expect("worker thread should exist");
let active_turn = thread.codex.session.new_default_turn().await;
let interrupted_turn_id = active_turn.sub_id.clone();
thread
.codex
.session
.spawn_task(
Arc::clone(&active_turn),
vec![UserInput::Text {
text: "working".to_string(),
text_elements: Vec::new(),
}],
NeverEndingTask,
)
.await;
FollowupTaskHandlerV2
.handle(invocation(
session,
turn,
"followup_task",
function_payload(json!({
"target": agent_id.to_string(),
"message": "continue",
"interrupt": true
})),
))
.await
.expect("interrupting v2 followup_task should succeed");
wait_for_turn_aborted(&thread, &interrupted_turn_id, TurnAbortReason::Interrupted).await;
let history_items = thread
.codex
.session
.clone_history()
.await
.raw_items()
.to_vec();
assert!(
!history_items.iter().any(|item| matches!(
item,
ResponseItem::Message { content, .. }
if content.iter().any(|content_item| matches!(
content_item,
ContentItem::InputText { text } | ContentItem::OutputText { text }
if text.contains(TurnAborted::INTERRUPTED_GUIDANCE)
|| text.contains(TurnAborted::INTERRUPTED_DEVELOPER_GUIDANCE)
))
)),
"disabled interrupted-turn marker should not be recorded in history"
);
let _ = thread
.submit(Op::Shutdown {})
.await
.expect("shutdown should submit");
}
#[tokio::test]
async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() {
let (mut session, mut turn) = make_session_and_context().await;