Allow per-turn overrides for direct-only tool namespaces (#59481)

## What changed

- Add `direct_only_tool_namespaces` to `TurnStartOptions`. Omission inherits the thread configuration; an empty list clears direct-only routing for that turn. Steering ignores the option.
- Persist explicit overrides in turn context and restore them when recovering an interrupted turn, including turns started by standalone tool output.
- Keep subsequent turns and spawned or resumed child agents on the thread's routing defaults.

## Testing

Add coverage for routing changes across turns in Responses and Responses Lite, recovery without carrying overrides into subsequent turns, and child-agent inheritance.

GitOrigin-RevId: cea1a997424572d4cd372cd15b098626904c6796
This commit is contained in:
Henry Levy
2026-11-04 19:58:49 +00:00
committed by copyberry
parent 41cbd5b9b2
commit d78f68f8d1
24 changed files with 322 additions and 39 deletions
+6
View File
@@ -143,6 +143,12 @@ fn build_agent_shared_config(turn: &TurnContext) -> Result<Config, String> {
// Preserve activation for history forks without freezing the parent's model-owned prompts.
// Fresh child startup restores configured preferences from the retained snapshot.
config.token_budget = turn.configured_token_budget.clone();
if let Some(thread_namespaces) = &turn.overridden_direct_only_tool_namespaces {
config
.code_mode
.direct_only_tool_namespaces
.clone_from(thread_namespaces);
}
config.model = Some(turn.model_info().slug.clone());
config.model_provider = turn.provider.info().clone();
config.model_reasoning_effort = turn
@@ -845,6 +845,7 @@ fn reference_context_item() -> TurnContextItem {
TurnContextItem {
turn_id: Some("reference-turn".to_string()),
root_turn_id: None,
direct_only_tool_namespaces: None,
disabled_plugin_ids: None,
cwd: AbsolutePathBuf::try_from(
std::env::current_dir()
+18
View File
@@ -1950,6 +1950,23 @@ impl Session {
} = self
.reconstruct_history_from_rollout(turn_context, rollout_items)
.await;
// An automatic turn can have a persisted routing override without establishing the
// user-turn context baseline. Recover its own latest context by surviving turn ID.
let restored_turn_routing = turn_attribution.as_ref().and_then(|turn| {
rollout_items
.iter()
.rev()
.find_map(|item| match item {
RolloutItem::TurnContext(context)
if context.turn_id.as_deref() == Some(turn.turn_id.as_str()) =>
{
Some(context)
}
_ => None,
})
.and_then(|context| context.direct_only_tool_namespaces.clone())
.map(|namespaces| (turn.turn_id.clone(), namespaces))
});
// Keep the recorded rollout unchanged. Prepare its reconstructed history before
// installing it, so legacy media is processed once for this resume or fork and
// will be processed again if the rollout is reconstructed in a future session.
@@ -2015,6 +2032,7 @@ impl Session {
);
state.last_started_turn_id = last_started_turn_id;
state.turn_attribution = turn_attribution;
state.restored_turn_routing = restored_turn_routing;
if let Some(world_state) = world_state_baseline {
state.history.set_world_state_baseline(world_state);
}
@@ -466,6 +466,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
root_turn_id: None,
direct_only_tool_namespaces: None,
disabled_plugin_ids: None,
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
@@ -512,6 +513,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
let mut previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
root_turn_id: None,
direct_only_tool_namespaces: None,
disabled_plugin_ids: None,
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
@@ -2167,6 +2169,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
root_turn_id: Some("root-turn".to_string()),
direct_only_tool_namespaces: None,
disabled_plugin_ids: None,
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
@@ -2265,6 +2268,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
serde_json::to_value(Some(TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
root_turn_id: Some("root-turn".to_string()),
direct_only_tool_namespaces: None,
disabled_plugin_ids: None,
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
@@ -2295,6 +2299,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
root_turn_id: None,
direct_only_tool_namespaces: None,
disabled_plugin_ids: None,
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
@@ -2438,6 +2443,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
let current_context_item = TurnContextItem {
turn_id: Some(current_turn_id.clone()),
root_turn_id: None,
direct_only_tool_namespaces: None,
disabled_plugin_ids: None,
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
@@ -2573,6 +2579,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
root_turn_id: None,
direct_only_tool_namespaces: None,
disabled_plugin_ids: None,
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
@@ -2758,6 +2765,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
root_turn_id: None,
direct_only_tool_namespaces: None,
disabled_plugin_ids: None,
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
+1
View File
@@ -4439,6 +4439,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
root_turn_id: None,
direct_only_tool_namespaces: None,
disabled_plugin_ids: None,
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
+22 -1
View File
@@ -335,6 +335,7 @@ impl std::fmt::Debug for TurnEnvironment {
/// Request options for one turn, separate from persistent thread settings.
#[derive(Default)]
pub(crate) struct NewTurnContextOptions {
pub(crate) direct_only_tool_namespaces: Option<Vec<String>>,
pub(crate) final_output_json_schema: Option<Value>,
pub(crate) prompt_cache_ttl: Option<crate::PromptCacheTtl>,
pub(crate) cyber_access_program: Option<CyberAccessProgram>,
@@ -351,6 +352,8 @@ pub struct TurnContext {
/// Turn-scoped configuration. Read step-specific settings such as service tier and
/// approvals reviewer from the corresponding `StepContext` instead.
pub config: Arc<Config>,
/// Thread defaults displaced by an explicit routing override for this turn.
pub(crate) overridden_direct_only_tool_namespaces: Option<Vec<String>>,
/// Preferences captured before token-budget defaults from the turn's initial model.
pub(crate) configured_token_budget: Option<TokenBudgetConfig>,
/// Captured once so later steps do not re-read config layers to detect user preferences.
@@ -451,6 +454,7 @@ impl TurnContext {
trace_id: current_span_trace_id(),
realtime_active: self.realtime_active,
code_mode_available: self.code_mode_available,
overridden_direct_only_tool_namespaces: None,
configured_token_budget: config.token_budget.clone(),
use_model_token_budget_defaults: config.features.enabled(Feature::TokenBudget)
&& !has_explicit_settings(&config),
@@ -894,6 +898,9 @@ impl TurnContext {
realtime_active: self.realtime_active,
code_mode_available: self.code_mode_available,
config: Arc::new(config),
overridden_direct_only_tool_namespaces: self
.overridden_direct_only_tool_namespaces
.clone(),
configured_token_budget: self.configured_token_budget.clone(),
use_model_token_budget_defaults: self.use_model_token_budget_defaults,
auth_manager: self.auth_manager.clone(),
@@ -962,6 +969,10 @@ impl TurnContext {
TurnContextItem {
turn_id: Some(self.sub_id.clone()),
root_turn_id: self.turn_metadata_state.root_turn_id(),
direct_only_tool_namespaces: self
.overridden_direct_only_tool_namespaces
.as_ref()
.map(|_| self.config.code_mode.direct_only_tool_namespaces.clone()),
disabled_plugin_ids: Some(self.disabled_plugin_ids.clone()),
cwd,
approval_policy: self.approval_policy(),
@@ -1180,6 +1191,7 @@ impl Session {
realtime_active: false,
code_mode_available: true,
config: per_turn_config,
overridden_direct_only_tool_namespaces: None,
configured_token_budget,
use_model_token_budget_defaults,
auth_manager,
@@ -1345,8 +1357,15 @@ impl Session {
git_enrichment_policy: GitEnrichmentPolicy,
) -> Arc<TurnContext> {
let primary_turn_environment = turn_environments.primary();
let per_turn_config =
let mut per_turn_config =
self.build_per_turn_config(&session_configuration, turn_environments.local_selection());
let overridden_direct_only_tool_namespaces =
options.direct_only_tool_namespaces.map(|namespaces| {
std::mem::replace(
&mut per_turn_config.code_mode.direct_only_tool_namespaces,
namespaces,
)
});
// Legacy turn consumers still refer to the primary executor's repository.
let cwd = primary_turn_environment
.and_then(|environment| environment.cwd().to_abs_path().ok())
@@ -1475,6 +1494,8 @@ impl Session {
if let Some(discovery) = skill_discovery {
turn_context.extension_data.insert(discovery);
}
turn_context.overridden_direct_only_tool_namespaces =
overridden_direct_only_tool_namespaces;
turn_context.code_mode_available = self.services.code_mode_service.is_available();
turn_context.extension_data.insert(trusted_plugin_roots);
turn_context.active_host_plugin_identities = Some(
+2
View File
@@ -151,6 +151,7 @@ impl PreparedTurnInputSettings {
let TurnStartOptions {
turn_trigger,
final_output_json_schema,
direct_only_tool_namespaces,
service_tier,
prompt_cache_ttl,
parent_turn_id,
@@ -168,6 +169,7 @@ impl PreparedTurnInputSettings {
updates.service_tier_for_turn = service_tier;
let options = NewTurnContextOptions {
direct_only_tool_namespaces,
final_output_json_schema,
prompt_cache_ttl,
cyber_access_program,
+20 -9
View File
@@ -96,6 +96,8 @@ pub(crate) struct SessionState {
pub(crate) last_started_turn_id: Option<String>,
/// Latest regular turn, retained across completion, settings changes, and compaction.
pub(crate) turn_attribution: Option<TurnAttribution>,
/// Explicit namespaces from the recovered turn's own rollout record.
pub(crate) restored_turn_routing: Option<(String, Vec<String>)>,
/// Runtime accounting state for the active auto-compaction window.
auto_compact_window: AutoCompactWindow,
context_window_id_generator: ContextWindowIdGenerator,
@@ -150,6 +152,7 @@ impl SessionState {
previous_turn_settings: None,
last_started_turn_id: None,
turn_attribution: None,
restored_turn_routing: None,
auto_compact_window: AutoCompactWindow::new_with_ids(auto_compact_window_ids),
context_window_id_generator,
async_compaction: crate::session::async_compaction::AsyncCompactionState::default(),
@@ -223,6 +226,7 @@ impl SessionState {
.replace_compacted(items, reviewer_compaction_hash.as_deref()),
HistoryReplacement::Reset => {
self.turn_attribution = None;
self.restored_turn_routing = None;
self.history.replace_annotated(items);
true
}
@@ -286,18 +290,25 @@ impl SessionState {
}
pub(crate) fn recovered_turn_start_options(&self, turn_id: &str) -> TurnStartOptions {
self.turn_attribution
let mut options = self
.turn_attribution
.as_ref()
.filter(|attribution| attribution.turn_id == turn_id)
.map(TurnAttribution::start_options)
.unwrap_or_else(|| TurnStartOptions {
// Older rollouts only persisted the root on the model-context record.
root_turn_id: self
.reference_context_item()
.filter(|context| context.turn_id.as_deref() == Some(turn_id))
.and_then(|context| context.root_turn_id),
..Default::default()
})
.unwrap_or_default();
if let Some(context) = self
.reference_context_item()
.filter(|context| context.turn_id.as_deref() == Some(turn_id))
{
// Older rollouts only persisted the root on the model-context record.
options.root_turn_id = options.root_turn_id.or(context.root_turn_id);
options.direct_only_tool_namespaces = context.direct_only_tool_namespaces;
} else if let Some((id, namespaces)) = &self.restored_turn_routing
&& id == turn_id
{
options.direct_only_tool_namespaces = Some(namespaces.clone());
}
options
}
// Token/rate limit helpers
+1
View File
@@ -556,6 +556,7 @@ impl Session {
.and_then(|context| context.cyber_access_program);
}
let options = NewTurnContextOptions {
direct_only_tool_namespaces: start_options.direct_only_tool_namespaces,
final_output_json_schema: start_options.final_output_json_schema,
prompt_cache_ttl: start_options.prompt_cache_ttl,
cyber_access_program: start_options.cyber_access_program,
@@ -4689,6 +4689,41 @@ async fn build_agent_spawn_config_uses_captured_step_settings_and_turn_context_v
assert_eq!(config, expected);
}
#[tokio::test]
async fn child_agents_inherit_thread_routing_when_parent_overrides_it_for_one_turn() {
let home = tempfile::tempdir().expect("create test home");
let (session, _, _events) = make_session_and_context_with_auth_config_home_and_rx(
CodexAuth::from_api_key("test"),
Vec::new(),
home.path(),
|config| config.code_mode.direct_only_tool_namespaces = vec!["thread_tools".into()],
)
.await;
let turn = session
.new_turn_with_default_settings(
"parent-turn".into(),
crate::session::turn_context::NewTurnContextOptions {
direct_only_tool_namespaces: Some(vec!["user_message".into()]),
..Default::default()
},
)
.await;
assert_eq!(
turn.config.code_mode.direct_only_tool_namespaces,
["user_message"]
);
let resumed = build_agent_resume_config(&turn).expect("resume config");
let step = StepContext::for_test(turn);
let spawned =
build_agent_spawn_config(&BaseInstructions::default(), &step).expect("spawn config");
for config in [spawned, resumed] {
assert_eq!(
config.code_mode.direct_only_tool_namespaces,
["thread_tools"]
);
}
}
#[tokio::test]
async fn build_agent_resume_config_clears_base_instructions() {
let (_session, mut turn) = make_session_and_context().await;