Sanitize developer context in full-history agent forks (#39641)

## Why

Developer messages can contain both inherited agent policy and unrelated context. Full-history forks need to replace the parent policy without dropping the unrelated content that shares its message.

## What changed

- Filter fork-specific developer instructions by content item instead of excluding an entire developer message.
- Remove inherited multi-agent mode instructions before applying the child agent's current mode.
- Preserve unrelated content items and discard developer messages only when filtering leaves them empty.

## Testing

Extend agent-control and subagent notification tests to cover compound compacted messages, multi-agent mode instructions, and proactive-to-explicit mode transitions.

GitOrigin-RevId: 028834e237d8636c13b17a724574bc8eb09a55ba
This commit is contained in:
evanz-oai
2026-08-20 07:29:43 +00:00
committed by copyberry
parent d944ce83a2
commit 663da53823
5 changed files with 211 additions and 21 deletions
+18 -12
View File
@@ -4,6 +4,7 @@ use crate::agent::role::apply_role_to_config;
use crate::config::PermissionProfileSnapshot;
use crate::context::ContextualUserFragment;
use crate::context::CurrentTimeReminder;
use crate::context::MultiAgentModeInstructions;
use crate::context::MultiAgentRoleInstructions;
use crate::session::multi_agents::resolve_usage_hints;
use codex_extension_api::ExtensionDataInit;
@@ -85,22 +86,27 @@ fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item:
}
}
fn is_fork_excluded_developer_message(item: &ResponseItem, usage_hint_texts: &[String]) -> bool {
fn retain_forked_developer_message(item: &mut ResponseItem, usage_hint_texts: &[String]) -> bool {
let ResponseItem::Message { role, content, .. } = item else {
return false;
return true;
};
if role != "developer" {
return false;
return true;
}
let [ContentItem::InputText { text }] = content.as_slice() else {
return false;
};
MultiAgentRoleInstructions::matches_text(text)
|| CurrentTimeReminder::matches_text(text)
|| usage_hint_texts
.iter()
.any(|usage_hint_text| usage_hint_text == text)
content.retain(|content_item| {
let ContentItem::InputText { text } = content_item else {
return true;
};
!(MultiAgentRoleInstructions::matches_text(text)
|| MultiAgentModeInstructions::matches_text(text)
|| CurrentTimeReminder::matches_text(text)
|| usage_hint_texts
.iter()
.any(|usage_hint_text| usage_hint_text == text))
});
!content.is_empty()
}
async fn load_agent_model_context(
@@ -724,7 +730,7 @@ impl AgentControl {
if matches!(response_item, ResponseItem::AgentMessage { .. }) {
return false;
}
if is_fork_excluded_developer_message(
if !retain_forked_developer_message(
response_item,
&multi_agent_v2_usage_hint_texts_to_filter,
) {
+15
View File
@@ -1381,6 +1381,10 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
text: "Developer context before.\nParent developer instructions.\nDeveloper context after."
.to_string(),
},
ContentItem::InputText {
text: "<multi_agent_mode>Proactive multi-agent delegation is active.</multi_agent_mode>"
.to_string(),
},
ContentItem::InputText {
text: "Preserved developer context.".to_string(),
},
@@ -1650,6 +1654,10 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() {
text: "Compacted context before.\nParent developer instructions.\nCompacted context after."
.to_string(),
},
ContentItem::InputText {
text: "<multi_agent_mode>Proactive multi-agent delegation is active.</multi_agent_mode>"
.to_string(),
},
ContentItem::InputText {
text: "Preserved compacted developer context.".to_string(),
},
@@ -1742,6 +1750,13 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() {
!history_contains_text(history.raw_items(), "Parent root guidance."),
"forked child history should strip stale parent hints from compacted replacement history"
);
assert!(
!history_contains_text(
history.raw_items(),
"Proactive multi-agent delegation is active."
),
"forked child history should strip stale policy fragments from compound compacted messages"
);
assert!(
!history_contains_text(history.raw_items(), "Parent developer instructions."),
"forked child history should replace parent instructions in compacted replacement history"
+1
View File
@@ -66,6 +66,7 @@ pub(crate) use legacy_apply_patch_exec_command_warning::LegacyApplyPatchExecComm
pub(crate) use legacy_model_mismatch_warning::LegacyModelMismatchWarning;
pub(crate) use legacy_unified_exec_process_limit_warning::LegacyUnifiedExecProcessLimitWarning;
pub(crate) use model_switch_instructions::ModelSwitchInstructions;
pub(crate) use multi_agent_mode_instructions::MultiAgentModeInstructions;
pub(crate) use multi_agent_role_instructions::MultiAgentRoleInstructions;
pub(crate) use multi_agent_usage_hint::MultiAgentUsageHint;
pub(crate) use network_rule_saved::NetworkRuleSaved;
@@ -7,7 +7,7 @@ const EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT: &str = "Any earlier instructi
const PROACTIVE_MULTI_AGENT_MODE_TEXT: &str = "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it.";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct MultiAgentModeInstructions {
pub(crate) struct MultiAgentModeInstructions {
multi_agent_mode: MultiAgentMode,
}
@@ -13,9 +13,11 @@ use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::MultiAgentMessages;
use codex_protocol::openai_models::MultiAgentRoleMessages;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
@@ -81,6 +83,12 @@ const ROLE_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::High;
const SUBAGENT_START_CONTEXT: &str = "subagent start context reaches child";
const SUBAGENT_STOP_CONTINUATION: &str = "continue only the child";
const INTERNAL_SUBAGENT_PROMPT: &str = "internal subagent: review";
const FULL_HISTORY_MULTI_AGENT_MODE_HINT: &str = "Delegate independent work to another agent.";
const FULL_HISTORY_SHARED_USAGE_HINT: &str = "Shared delegation guidance.";
const FULL_HISTORY_PROACTIVE_PROMPT: &str = "switch to proactive delegation";
const FULL_HISTORY_EXPLICIT_PROMPT: &str = "restore explicit-only delegation";
const FULL_HISTORY_PROACTIVE_POLICY: &str = "Proactive multi-agent delegation is active.";
const FULL_HISTORY_EXPLICIT_POLICY: &str = "Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask";
fn body_contains(req: &wiremock::Request, text: &str) -> bool {
decoded_body(req)
@@ -1050,12 +1058,16 @@ enum FullHistoryV2ModelSelection {
ExplicitOverride,
WorldStateIdentity,
CurrentTimeReminders,
MultiAgentModeInstructions,
MultiAgentModeTransitions,
}
#[test_case(FullHistoryV2ModelSelection::ConfiguredDefault; "configured default with omitted fork_turns")]
#[test_case(FullHistoryV2ModelSelection::ExplicitOverride; "explicit override with fork_turns all")]
#[test_case(FullHistoryV2ModelSelection::WorldStateIdentity; "world state appends context window when agent identity changes")]
#[test_case(FullHistoryV2ModelSelection::CurrentTimeReminders; "full fork drops inherited current-time reminders")]
#[test_case(FullHistoryV2ModelSelection::MultiAgentModeInstructions; "full fork drops inherited multi-agent mode instructions")]
#[test_case(FullHistoryV2ModelSelection::MultiAgentModeTransitions; "full fork restores explicit policy after proactive transition")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn spawned_full_history_v2_child_uses_model_precedence_without_dropping_context(
selection: FullHistoryV2ModelSelection,
@@ -1076,7 +1088,9 @@ async fn spawned_full_history_v2_child_uses_model_precedence_without_dropping_co
let (spawn_args, expected_model, expected_reasoning_effort) = match selection {
FullHistoryV2ModelSelection::ConfiguredDefault
| FullHistoryV2ModelSelection::WorldStateIdentity
| FullHistoryV2ModelSelection::CurrentTimeReminders => (
| FullHistoryV2ModelSelection::CurrentTimeReminders
| FullHistoryV2ModelSelection::MultiAgentModeInstructions
| FullHistoryV2ModelSelection::MultiAgentModeTransitions => (
json!({
"message": CHILD_PROMPT,
"task_name": "worker",
@@ -1097,6 +1111,35 @@ async fn spawned_full_history_v2_child_uses_model_precedence_without_dropping_co
),
};
let spawn_args = serde_json::to_string(&spawn_args)?;
let mode_transition_turns = if matches!(
selection,
FullHistoryV2ModelSelection::MultiAgentModeTransitions
) {
Some((
mount_sse_once_match(
&server,
|req: &wiremock::Request| body_contains(req, FULL_HISTORY_PROACTIVE_PROMPT),
sse(vec![
ev_response_created("resp-proactive-1"),
ev_assistant_message("msg-proactive-1", "proactive done"),
ev_completed("resp-proactive-1"),
]),
)
.await,
mount_sse_once_match(
&server,
|req: &wiremock::Request| body_contains(req, FULL_HISTORY_EXPLICIT_PROMPT),
sse(vec![
ev_response_created("resp-explicit-1"),
ev_assistant_message("msg-explicit-1", "explicit done"),
ev_completed("resp-explicit-1"),
]),
)
.await,
))
} else {
None
};
let spawn_turn = mount_sse_once_match(
&server,
|req: &wiremock::Request| body_contains(req, TURN_1_PROMPT),
@@ -1162,6 +1205,18 @@ async fn spawned_full_history_v2_child_uses_model_precedence_without_dropping_co
root: Some(format!("{model} root role.")),
subagent: Some(format!("{model} subagent role.")),
});
if matches!(
selection,
FullHistoryV2ModelSelection::MultiAgentModeTransitions
) && model == INHERITED_MODEL
{
model_info
.supported_reasoning_levels
.push(ReasoningEffortPreset {
effort: ReasoningEffort::Ultra,
description: "Ultra".to_string(),
});
}
}
if matches!(selection, FullHistoryV2ModelSelection::WorldStateIdentity) {
config
@@ -1180,6 +1235,22 @@ async fn spawned_full_history_v2_child_uses_model_precedence_without_dropping_co
..CurrentTimeReminderConfig::default()
});
}
if matches!(
selection,
FullHistoryV2ModelSelection::MultiAgentModeInstructions
) {
config.multi_agent_v2.multi_agent_mode_hint_text =
Some(FULL_HISTORY_MULTI_AGENT_MODE_HINT.to_string());
}
if matches!(
selection,
FullHistoryV2ModelSelection::MultiAgentModeTransitions
) {
config.multi_agent_v2.root_agent_usage_hint_text =
Some(FULL_HISTORY_SHARED_USAGE_HINT.to_string());
config.multi_agent_v2.subagent_usage_hint_text =
Some(FULL_HISTORY_SHARED_USAGE_HINT.to_string());
}
config.model = Some(INHERITED_MODEL.to_string());
config.model_reasoning_effort = Some(INHERITED_REASONING_EFFORT);
config.agent_default_subagent_model = Some(V2_DEFAULT_MODEL.to_string());
@@ -1199,23 +1270,120 @@ async fn spawned_full_history_v2_child_uses_model_precedence_without_dropping_co
test.submit_turn(TURN_0_FORK_PROMPT).await?;
let _ = seed_turn.single_request();
if let Some((proactive_turn, explicit_turn)) = mode_transition_turns {
for (prompt, effort, approval_policy) in [
(
FULL_HISTORY_PROACTIVE_PROMPT,
ReasoningEffort::Ultra,
Some(AskForApproval::OnRequest),
),
(FULL_HISTORY_EXPLICIT_PROMPT, ReasoningEffort::High, None),
] {
test.codex
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: prompt.to_string(),
text_elements: Vec::new(),
}])
.with_thread_settings(ThreadSettingsOverrides {
effort: Some(Some(effort)),
approval_policy,
..Default::default()
}),
)
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
}
let proactive_request = proactive_turn.single_request();
let proactive_developer_messages = proactive_request.message_input_text_groups("developer");
assert!(
proactive_developer_messages.iter().any(|message| {
message.len() > 1
&& message
.iter()
.any(|text| text.contains(FULL_HISTORY_PROACTIVE_POLICY))
}),
"proactive policy should share a developer message with unrelated context: {proactive_developer_messages:?}"
);
let explicit_request = explicit_turn.single_request();
assert!(
explicit_request
.message_input_texts("developer")
.iter()
.any(|text| text.contains(FULL_HISTORY_EXPLICIT_POLICY)),
"restored parent policy should require an explicit delegation request"
);
}
test.submit_turn(TURN_1_PROMPT).await?;
let parent_request = spawn_turn.single_request();
let child_request = wait_for_request_with_model(&child_request_log, expected_model).await?;
assert!(child_request.body_contains_text(TURN_0_FORK_PROMPT));
let child_developer_messages = child_request.message_input_texts("developer");
assert_eq!(
child_developer_messages
.iter()
.filter(|message| message.contains(&format!("{expected_model} subagent role.")))
.count(),
1
);
if !matches!(
selection,
FullHistoryV2ModelSelection::MultiAgentModeTransitions
) {
assert_eq!(
child_developer_messages
.iter()
.filter(|message| message.contains(&format!("{expected_model} subagent role.")))
.count(),
1
);
}
assert!(!child_developer_messages.iter().any(|message| {
message.contains(&format!("{INHERITED_MODEL} root role."))
|| message.contains(&format!("{INHERITED_MODEL} subagent role."))
}));
if matches!(
selection,
FullHistoryV2ModelSelection::MultiAgentModeInstructions
) {
let mode_instruction_count = |request: &ResponsesRequest| {
request
.message_input_texts("developer")
.into_iter()
.filter(|text| text.starts_with(MULTI_AGENT_MODE_OPEN_TAG))
.count()
};
assert_eq!(
(
mode_instruction_count(&parent_request),
mode_instruction_count(&child_request),
),
(1, 1)
);
}
if matches!(
selection,
FullHistoryV2ModelSelection::MultiAgentModeTransitions
) {
assert_eq!(
(
child_developer_messages
.iter()
.filter(|message| message.starts_with(MULTI_AGENT_MODE_OPEN_TAG))
.count(),
child_developer_messages
.iter()
.filter(|message| message.contains(FULL_HISTORY_EXPLICIT_POLICY))
.count(),
child_developer_messages
.iter()
.filter(|message| message.contains(FULL_HISTORY_PROACTIVE_POLICY))
.count(),
child_developer_messages
.iter()
.filter(|message| message.contains(FULL_HISTORY_SHARED_USAGE_HINT))
.count(),
),
(1, 1, 0, 1)
);
}
if matches!(selection, FullHistoryV2ModelSelection::CurrentTimeReminders) {
let reminder_count = |request: &ResponsesRequest| {
request