mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Track multi-agent usage hints in world state (#37189)
## Why Resumed sessions need the current multi-agent usage instructions when configuration changes or the saved history predates usage-hint tracking. ## What changed - Store multi-agent usage hints in world state and refresh changed or previously untracked hints before re-emitting the active multi-agent mode. - Present `wait_agent` polling guidance in the overridable developer instructions only when the tool is enabled, instead of embedding it in the tool schema. ## Testing - Cover cold resumes, changes to `wait_agent` availability, custom usage hints, message ordering, and tool-schema behavior. GitOrigin-RevId: a9c6798d5dc2dedc8dadc49e0a80ff50a757ab83
This commit is contained in:
committed by
copyberry
parent
98da2c4499
commit
92b83e226d
@@ -11029,8 +11029,9 @@ max_concurrent_threads_per_session = 17
|
||||
|
||||
let config = resolve_multi_agent_v2_config(&config_toml);
|
||||
let concurrency_guidance = "There are 17 available concurrency slots, meaning that up to 17 agents can be active at once, including you.";
|
||||
let expected_suffix =
|
||||
format!("{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{concurrency_guidance}");
|
||||
let expected_suffix = format!(
|
||||
"{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT}\n\n{concurrency_guidance}"
|
||||
);
|
||||
assert!(
|
||||
[
|
||||
config.root_agent_usage_hint_text,
|
||||
|
||||
@@ -255,6 +255,8 @@ You may also see them addressed as to=/root/..., which indicates your identity i
|
||||
"#;
|
||||
const DEFAULT_MULTI_AGENT_V2_MODEL_OVERRIDE_USAGE_HINT_TEXT: &str = "Full-history forks (`fork_turns` omitted or `\"all\"`) inherit the parent model and reasoning effort and do not accept overrides. Only set `model` or `reasoning_effort` when explicitly requested by the user, applicable `AGENTS.md` instructions, or skill instructions; when doing so, set `fork_turns` to `\"none\"` or a positive integer string.";
|
||||
const DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE: &str = "collaboration";
|
||||
const DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT: &str =
|
||||
"When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.";
|
||||
const DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT: &str = r#"Note that collaboration tools cannot be called from inside `functions.exec`. Call `spawn_agent`, `send_message`, `followup_task`, `wait_agent`, `interrupt_agent`, and `list_agents` only as direct tool calls using the recipient shown in their tool definitions, such as `to=functions.collaboration.spawn_agent`, since they are intentionally absent from the `functions.exec` `tools.*` namespace. Available tools in `functions.exec` are explicitly described with a `tools` namespace in the developer message.
|
||||
|
||||
All agents share the same directory. In detail:
|
||||
@@ -262,9 +264,17 @@ All agents share the same directory. In detail:
|
||||
- All agents use the same current working directory.
|
||||
- As a result, edits made by one agent are immediately visible to all other agents.
|
||||
"#;
|
||||
fn default_multi_agent_v2_usage_hint_text(usage_hint_text: &str, max_concurrency: usize) -> String {
|
||||
fn default_multi_agent_v2_usage_hint_text(
|
||||
usage_hint_text: &str,
|
||||
max_concurrency: usize,
|
||||
wait_agent_usage_hint_text: Option<&str>,
|
||||
) -> String {
|
||||
let wait_agent_usage_hint_text = match wait_agent_usage_hint_text {
|
||||
Some(wait_agent_usage_hint_text) => format!("{wait_agent_usage_hint_text}\n\n"),
|
||||
None => String::new(),
|
||||
};
|
||||
format!(
|
||||
"{usage_hint_text}\n{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\nThere are {max_concurrency} available concurrency slots, meaning that up to {max_concurrency} agents can be active at once, including you."
|
||||
"{usage_hint_text}\n{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{wait_agent_usage_hint_text}There are {max_concurrency} available concurrency slots, meaning that up to {max_concurrency} agents can be active at once, including you."
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1286,10 +1296,12 @@ impl MultiAgentV2Config {
|
||||
root_agent_usage_hint_text: Some(default_multi_agent_v2_usage_hint_text(
|
||||
DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT,
|
||||
max_concurrent_threads_per_session,
|
||||
Some(DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT),
|
||||
)),
|
||||
subagent_usage_hint_text: Some(default_multi_agent_v2_usage_hint_text(
|
||||
DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT,
|
||||
max_concurrent_threads_per_session,
|
||||
Some(DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT),
|
||||
)),
|
||||
subagent_developer_instructions: None,
|
||||
multi_agent_mode_hint_text: None,
|
||||
@@ -2726,8 +2738,21 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config
|
||||
let wait_agent_enabled = base
|
||||
.and_then(|config| config.wait_agent_enabled)
|
||||
.unwrap_or(default.wait_agent_enabled);
|
||||
let mut default_root_agent_usage_hint_text = default.root_agent_usage_hint_text;
|
||||
let mut default_subagent_usage_hint_text = default.subagent_usage_hint_text;
|
||||
let default_wait_agent_usage_hint_text = if wait_agent_enabled {
|
||||
Some(DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut default_root_agent_usage_hint_text = Some(default_multi_agent_v2_usage_hint_text(
|
||||
DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT,
|
||||
max_concurrent_threads_per_session,
|
||||
default_wait_agent_usage_hint_text,
|
||||
));
|
||||
let mut default_subagent_usage_hint_text = Some(default_multi_agent_v2_usage_hint_text(
|
||||
DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT,
|
||||
max_concurrent_threads_per_session,
|
||||
default_wait_agent_usage_hint_text,
|
||||
));
|
||||
if expose_spawn_agent_model_overrides {
|
||||
default_root_agent_usage_hint_text = Some(append_usage_hint_text(
|
||||
default_root_agent_usage_hint_text.as_deref(),
|
||||
|
||||
@@ -18,6 +18,7 @@ mod legacy_model_mismatch_warning;
|
||||
mod legacy_unified_exec_process_limit_warning;
|
||||
mod model_switch_instructions;
|
||||
mod multi_agent_mode_instructions;
|
||||
mod multi_agent_usage_hint;
|
||||
mod network_rule_saved;
|
||||
mod permissions_instructions;
|
||||
mod personality_spec_instructions;
|
||||
@@ -62,6 +63,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_usage_hint::MultiAgentUsageHint;
|
||||
pub(crate) use network_rule_saved::NetworkRuleSaved;
|
||||
pub use permissions_instructions::ApprovalPromptContext;
|
||||
pub use permissions_instructions::PermissionsInstructions;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
use super::ContextualUserFragment;
|
||||
|
||||
/// Configured multi-agent instructions emitted as a standalone developer message.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct MultiAgentUsageHint {
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl MultiAgentUsageHint {
|
||||
pub(crate) fn new(text: &str) -> Self {
|
||||
Self {
|
||||
text: text.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextualUserFragment for MultiAgentUsageHint {
|
||||
fn role(&self) -> &'static str {
|
||||
"developer"
|
||||
}
|
||||
|
||||
fn requires_separate_message(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn markers(&self) -> (&'static str, &'static str) {
|
||||
Self::type_markers()
|
||||
}
|
||||
|
||||
fn type_markers() -> (&'static str, &'static str) {
|
||||
("", "")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
self.text.clone()
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod environment;
|
||||
mod environments_instructions;
|
||||
mod model;
|
||||
mod multi_agent_mode;
|
||||
mod multi_agent_usage_hint;
|
||||
mod permissions;
|
||||
mod personality;
|
||||
mod plugins_instructions;
|
||||
@@ -40,6 +41,7 @@ pub(crate) use environment::EnvironmentsState;
|
||||
pub(crate) use environments_instructions::EnvironmentsInstructionsState;
|
||||
pub(crate) use model::ModelInstructionsState;
|
||||
pub(crate) use multi_agent_mode::MultiAgentModeState;
|
||||
pub(crate) use multi_agent_usage_hint::MultiAgentUsageHintState;
|
||||
pub(crate) use permissions::PermissionsState;
|
||||
pub(crate) use personality::PersonalityState;
|
||||
pub(crate) use plugins_instructions::PluginsInstructionsState;
|
||||
@@ -89,7 +91,7 @@ impl<S: WorldStateSection> ErasedWorldStateSection for S {
|
||||
}
|
||||
|
||||
fn matches_legacy_fragment(&self, role: &str, text: &str) -> bool {
|
||||
S::matches_legacy_fragment(role, text)
|
||||
WorldStateSection::matches_current_legacy_fragment(self, role, text)
|
||||
}
|
||||
|
||||
fn has_retained_fragment_matcher(&self) -> bool {
|
||||
@@ -219,6 +221,11 @@ pub(crate) trait WorldStateSection: Send + Sync + 'static {
|
||||
false
|
||||
}
|
||||
|
||||
/// Recognizes legacy fragments whose identity depends on this section's current value.
|
||||
fn matches_current_legacy_fragment(&self, role: &str, text: &str) -> bool {
|
||||
Self::matches_legacy_fragment(role, text)
|
||||
}
|
||||
|
||||
/// Whether retained history must still contain this section's rendered fragment.
|
||||
fn has_retained_fragment_matcher() -> bool {
|
||||
false
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::PreviousSectionState;
|
||||
use super::WorldStateHash;
|
||||
use super::WorldStateSection;
|
||||
use super::multi_agent_usage_hint::MultiAgentUsageHintState;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::multi_agent_mode_instructions::MultiAgentModeInstructions;
|
||||
use codex_protocol::config_types::MultiAgentMode;
|
||||
@@ -14,6 +16,8 @@ const MULTI_AGENT_MODE_MAX_TOKENS: usize = 400;
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub(crate) struct MultiAgentModeState {
|
||||
mode: Option<MultiAgentMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
usage_hint_hash: Option<WorldStateHash>,
|
||||
}
|
||||
|
||||
impl MultiAgentModeState {
|
||||
@@ -26,8 +30,14 @@ impl MultiAgentModeState {
|
||||
)),
|
||||
mode @ (MultiAgentMode::ExplicitRequestOnly | MultiAgentMode::Proactive) => mode,
|
||||
}),
|
||||
usage_hint_hash: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_usage_hint(mut self, usage_hint: &MultiAgentUsageHintState) -> Self {
|
||||
self.usage_hint_hash = Some(usage_hint.snapshot());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl WorldStateSection for MultiAgentModeState {
|
||||
@@ -56,7 +66,8 @@ impl WorldStateSection for MultiAgentModeState {
|
||||
) -> Option<Box<dyn ContextualUserFragment>> {
|
||||
let mode = match (&self.mode, previous) {
|
||||
(Some(mode), PreviousSectionState::Known(previous))
|
||||
if previous.mode.as_ref() == Some(mode) =>
|
||||
if previous.mode.as_ref() == Some(mode)
|
||||
&& previous.usage_hint_hash == self.usage_hint_hash =>
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,26 @@ fn persisted_mode_is_restored_only_when_missing_from_history() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Active mode instructions must follow a newly migrated multi-agent usage hint.
|
||||
#[test]
|
||||
fn unchanged_mode_is_reemitted_after_usage_hint_migration() {
|
||||
let previous = state(Some(MultiAgentMode::Proactive));
|
||||
let current = MultiAgentModeState::new(Some(MultiAgentMode::Proactive)).with_usage_hint(
|
||||
&MultiAgentUsageHintState::new("Current usage instructions."),
|
||||
);
|
||||
|
||||
let instructions = current
|
||||
.render_diff(PreviousSectionState::Known(&previous))
|
||||
.expect("unchanged mode should follow migrated usage instructions");
|
||||
|
||||
assert_eq!(
|
||||
instructions.render(),
|
||||
MultiAgentModeInstructions::from_mode(MultiAgentMode::Proactive)
|
||||
.expect("proactive mode should render")
|
||||
.render()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_mode_is_bounded_before_snapshot_and_rendering() {
|
||||
let state = state(Some(MultiAgentMode::Custom("custom mode ".repeat(1_000))));
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
use super::PreviousSectionState;
|
||||
use super::WorldStateHash;
|
||||
use super::WorldStateSection;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::MultiAgentUsageHint;
|
||||
|
||||
/// Configured multi-agent instructions currently visible to the model.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct MultiAgentUsageHintState {
|
||||
instructions: MultiAgentUsageHint,
|
||||
}
|
||||
|
||||
impl MultiAgentUsageHintState {
|
||||
pub(crate) fn new(text: &str) -> Self {
|
||||
Self {
|
||||
instructions: MultiAgentUsageHint::new(text),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WorldStateSection for MultiAgentUsageHintState {
|
||||
const ID: &'static str = "multi_agent_usage_hint";
|
||||
type Snapshot = WorldStateHash;
|
||||
|
||||
fn snapshot(&self) -> Self::Snapshot {
|
||||
WorldStateHash::from_fragment(&self.instructions)
|
||||
}
|
||||
|
||||
fn matches_current_legacy_fragment(&self, role: &str, text: &str) -> bool {
|
||||
role == self.instructions.role() && text == self.instructions.render()
|
||||
}
|
||||
|
||||
fn render_diff(
|
||||
&self,
|
||||
previous: PreviousSectionState<'_, Self::Snapshot>,
|
||||
) -> Option<Box<dyn ContextualUserFragment>> {
|
||||
match previous {
|
||||
PreviousSectionState::Known(previous) if previous == &self.snapshot() => None,
|
||||
PreviousSectionState::Unknown => None,
|
||||
PreviousSectionState::Known(_) | PreviousSectionState::Absent => {
|
||||
Some(Box::new(self.instructions.clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3549,15 +3549,17 @@ impl Session {
|
||||
"developer" if fragment.markers().0 == MULTI_AGENT_MODE_OPEN_TAG => {
|
||||
initial_multi_agent_mode = Some(fragment);
|
||||
}
|
||||
"developer"
|
||||
if fragment.requires_separate_message() && fragment.markers().0.is_empty() =>
|
||||
{
|
||||
separate_developer_sections.push(fragment.render());
|
||||
}
|
||||
"developer" => developer_sections.push(fragment.render()),
|
||||
"user" => contextual_user_sections.push(fragment.render()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let multi_agent_v2_usage_hint_text =
|
||||
multi_agents::usage_hint_text(turn_context, &session_source);
|
||||
|
||||
let mut items = Vec::with_capacity(4);
|
||||
if let Some(developer_message) =
|
||||
crate::context_manager::updates::build_developer_update_item(developer_sections)
|
||||
@@ -3571,14 +3573,6 @@ impl Session {
|
||||
items.push(developer_message);
|
||||
}
|
||||
}
|
||||
if let Some(usage_hint_text) = multi_agent_v2_usage_hint_text
|
||||
&& let Some(usage_hint_message) =
|
||||
crate::context_manager::updates::build_developer_update_item(vec![
|
||||
usage_hint_text.to_string(),
|
||||
])
|
||||
{
|
||||
items.push(usage_hint_message);
|
||||
}
|
||||
if let Some(initial_multi_agent_mode) = initial_multi_agent_mode {
|
||||
items.push(initial_multi_agent_mode.into_boxed_response_item());
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::context::world_state::EnvironmentsInstructionsState;
|
||||
use crate::context::world_state::EnvironmentsState;
|
||||
use crate::context::world_state::ModelInstructionsState;
|
||||
use crate::context::world_state::MultiAgentModeState;
|
||||
use crate::context::world_state::MultiAgentUsageHintState;
|
||||
use crate::context::world_state::PermissionsState;
|
||||
use crate::context::world_state::PersonalityState;
|
||||
use crate::context::world_state::PluginsInstructionsState;
|
||||
@@ -252,9 +253,17 @@ impl Session {
|
||||
world_state.add_extension_section(section);
|
||||
}
|
||||
}
|
||||
world_state.add_section(MultiAgentModeState::new(
|
||||
let mut multi_agent_mode = MultiAgentModeState::new(
|
||||
super::multi_agents::effective_multi_agent_mode(turn_context),
|
||||
));
|
||||
);
|
||||
if let Some(usage_hint_text) =
|
||||
super::multi_agents::usage_hint_text(turn_context, &turn_context.session_source)
|
||||
{
|
||||
let usage_hint = MultiAgentUsageHintState::new(usage_hint_text);
|
||||
multi_agent_mode = multi_agent_mode.with_usage_hint(&usage_hint);
|
||||
world_state.add_section(usage_hint);
|
||||
}
|
||||
world_state.add_section(multi_agent_mode);
|
||||
Ok(world_state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -877,7 +877,7 @@ fn wait_agent_tool_parameters_v2(options: WaitAgentTimeoutOptions) -> JsonSchema
|
||||
let properties = BTreeMap::from([(
|
||||
"timeout_ms".to_string(),
|
||||
JsonSchema::number(Some(format!(
|
||||
"Timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.",
|
||||
"Timeout in milliseconds. Defaults to {}, min {}, max {}.",
|
||||
options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms,
|
||||
))),
|
||||
)]);
|
||||
|
||||
@@ -421,9 +421,7 @@ fn wait_agent_tool_v2_uses_timeout_only_summary_output() {
|
||||
properties
|
||||
.get("timeout_ms")
|
||||
.and_then(|schema| schema.description.as_deref()),
|
||||
Some(
|
||||
"Timeout in milliseconds. Defaults to 30000, min 10000, max 3600000. Prefer longer waits (minutes) to avoid busy polling."
|
||||
)
|
||||
Some("Timeout in milliseconds. Defaults to 30000, min 10000, max 3600000.")
|
||||
);
|
||||
assert_eq!(parameters.required.as_ref(), None);
|
||||
assert_eq!(
|
||||
|
||||
@@ -17,10 +17,14 @@ use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
use codex_protocol::openai_models::TruncationPolicyConfig;
|
||||
use codex_protocol::openai_models::default_input_modalities;
|
||||
use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_models_once;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::namespace_child_tool;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
@@ -308,6 +312,378 @@ async fn configured_agent_roles_control_spawn_agent_type(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait guidance belongs to overridable developer instructions, never the tool schema.
|
||||
#[test_case(None, true; "default developer instructions include wait guidance")]
|
||||
#[test_case(Some("Custom root instructions."), false; "custom developer instructions replace wait guidance")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn multi_agent_v2_wait_guidance_uses_overridable_developer_instructions(
|
||||
root_agent_usage_hint_text: Option<&str>,
|
||||
expected_wait_guidance: bool,
|
||||
) -> Result<()> {
|
||||
let root_agent_usage_hint_text = root_agent_usage_hint_text.map(str::to_string);
|
||||
let server = start_mock_server().await;
|
||||
let response = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
|
||||
)
|
||||
.await;
|
||||
let test = test_codex()
|
||||
.with_config(move |config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::MultiAgentV2)
|
||||
.expect("test config should allow feature update");
|
||||
if let Some(root_agent_usage_hint_text) = root_agent_usage_hint_text {
|
||||
config.multi_agent_v2.root_agent_usage_hint_text = Some(root_agent_usage_hint_text);
|
||||
}
|
||||
})
|
||||
.build_with_auto_env(&server)
|
||||
.await?;
|
||||
|
||||
test.submit_turn("hello").await?;
|
||||
|
||||
let request = response.single_request();
|
||||
let developer_messages = request.message_input_texts("developer");
|
||||
let has_wait_guidance = developer_messages.iter().any(|message| {
|
||||
message.contains(
|
||||
"When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.",
|
||||
)
|
||||
});
|
||||
assert_eq!(has_wait_guidance, expected_wait_guidance);
|
||||
|
||||
let body = request.body_json();
|
||||
let wait_agent_tool = namespace_child_tool(&body, MULTI_AGENT_V2_NAMESPACE, "wait_agent")
|
||||
.expect("wait_agent should be exposed");
|
||||
assert_eq!(
|
||||
wait_agent_tool
|
||||
.pointer("/parameters/properties/timeout_ms/description")
|
||||
.and_then(Value::as_str),
|
||||
Some("Timeout in milliseconds. Defaults to 30000, min 10000, max 3600000.")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resumed legacy threads receive current usage hints once, before their active mode.
|
||||
/// Configured hint overrides and disabled wait tools must retain their existing semantics.
|
||||
#[test_case(true, None, true; "legacy resume restores default wait guidance")]
|
||||
#[test_case(true, Some("Custom root instructions."), false; "legacy resume preserves custom usage hints")]
|
||||
#[test_case(true, Some("Legacy root instructions."), false; "legacy resume preserves unchanged custom usage hints")]
|
||||
#[test_case(false, None, false; "legacy resume omits disabled wait guidance")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn multi_agent_v2_cold_resume_refreshes_legacy_usage_hints_once(
|
||||
wait_agent_enabled: bool,
|
||||
resumed_root_agent_usage_hint_text: Option<&str>,
|
||||
expected_wait_guidance: bool,
|
||||
) -> Result<()> {
|
||||
let resumed_root_agent_usage_hint_text = resumed_root_agent_usage_hint_text.map(str::to_string);
|
||||
let legacy_root_agent_usage_hint_text = "Legacy root instructions.";
|
||||
let wait_guidance =
|
||||
"When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.";
|
||||
let config_toml = format!(
|
||||
"[features.multi_agent_v2]\nenabled = true\nwait_agent_enabled = {wait_agent_enabled}\n"
|
||||
);
|
||||
let server = start_mock_server().await;
|
||||
let initial_response = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-initial"),
|
||||
ev_completed("resp-initial"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let initial = test_codex()
|
||||
.with_pre_build_hook(move |home| {
|
||||
std::fs::write(home.join("config.toml"), &config_toml)
|
||||
.expect("write multi-agent configuration");
|
||||
})
|
||||
.with_config(move |config| {
|
||||
config.multi_agent_v2.root_agent_usage_hint_text =
|
||||
Some(legacy_root_agent_usage_hint_text.to_string());
|
||||
})
|
||||
.build_with_auto_env(&server)
|
||||
.await?;
|
||||
|
||||
initial.submit_turn("before upgrading").await?;
|
||||
|
||||
let initial_request = initial_response.single_request();
|
||||
assert!(
|
||||
initial_request
|
||||
.message_input_text_groups("developer")
|
||||
.iter()
|
||||
.any(|group| { group.len() == 1 && group[0] == legacy_root_agent_usage_hint_text }),
|
||||
"legacy usage hint should be its own developer message"
|
||||
);
|
||||
assert!(
|
||||
!initial_request
|
||||
.message_input_texts("developer")
|
||||
.iter()
|
||||
.any(|message| message.contains(wait_guidance)),
|
||||
"legacy rollout should not already contain wait guidance"
|
||||
);
|
||||
|
||||
let home = initial.home.clone();
|
||||
let rollout_path = initial
|
||||
.session_configured
|
||||
.rollout_path
|
||||
.clone()
|
||||
.expect("initial session should have a rollout path");
|
||||
initial.codex.shutdown_and_wait().await?;
|
||||
|
||||
let mut removed_recorded_usage_hint = false;
|
||||
let mut removed_usage_hint_presence = false;
|
||||
let legacy_rollout = std::fs::read_to_string(&rollout_path)?
|
||||
.lines()
|
||||
.map(serde_json::from_str::<RolloutLine>)
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.map(|mut line| {
|
||||
if let RolloutItem::WorldState(world_state) = &mut line.item
|
||||
&& let Some(state) = world_state.state.as_object_mut()
|
||||
{
|
||||
removed_recorded_usage_hint |= state.remove("multi_agent_usage_hint").is_some();
|
||||
if let Some(mode) = state
|
||||
.get_mut("multi_agent_mode")
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
removed_usage_hint_presence |= mode.remove("usage_hint_hash").is_some();
|
||||
}
|
||||
}
|
||||
serde_json::to_string(&line)
|
||||
})
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
.join("\n");
|
||||
assert!(
|
||||
removed_recorded_usage_hint,
|
||||
"initial rollout should contain a usage-hint world-state section"
|
||||
);
|
||||
assert!(
|
||||
removed_usage_hint_presence,
|
||||
"initial rollout should record usage-hint presence on its active mode"
|
||||
);
|
||||
std::fs::write(&rollout_path, format!("{legacy_rollout}\n"))?;
|
||||
|
||||
let resumed_responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-resumed-first"),
|
||||
ev_completed("resp-resumed-first"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-resumed-second"),
|
||||
ev_completed("resp-resumed-second"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let mut resumed_builder = test_codex().with_config(move |config| {
|
||||
if let Some(root_agent_usage_hint_text) = resumed_root_agent_usage_hint_text {
|
||||
config.multi_agent_v2.root_agent_usage_hint_text = Some(root_agent_usage_hint_text);
|
||||
}
|
||||
});
|
||||
let resumed = resumed_builder.resume(&server, home, rollout_path).await?;
|
||||
let current_usage_hint = resumed
|
||||
.config
|
||||
.multi_agent_v2
|
||||
.root_agent_usage_hint_text
|
||||
.clone()
|
||||
.expect("current root usage hint should be configured");
|
||||
|
||||
resumed.submit_turn("first turn after upgrading").await?;
|
||||
resumed.submit_turn("second turn after upgrading").await?;
|
||||
|
||||
let requests = resumed_responses.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
for request in &requests {
|
||||
let developer_messages = request.message_input_text_groups("developer");
|
||||
let current_usage_hint_positions = developer_messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, group)| {
|
||||
(group.len() == 1 && group[0] == current_usage_hint).then_some(index)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
current_usage_hint_positions.len(),
|
||||
1,
|
||||
"current usage hint should appear exactly once as a standalone developer message: \
|
||||
{developer_messages:?}"
|
||||
);
|
||||
let active_mode_position = developer_messages
|
||||
.iter()
|
||||
.rposition(|group| {
|
||||
group
|
||||
.iter()
|
||||
.any(|message| message.contains(MULTI_AGENT_MODE_OPEN_TAG))
|
||||
})
|
||||
.expect("resumed context should include an active multi-agent mode");
|
||||
assert!(
|
||||
current_usage_hint_positions[0] < active_mode_position,
|
||||
"active mode must follow the current usage hint: {developer_messages:?}"
|
||||
);
|
||||
let wait_guidance_count = developer_messages
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|message| message.contains(wait_guidance))
|
||||
.count();
|
||||
assert_eq!(
|
||||
wait_guidance_count,
|
||||
usize::from(expected_wait_guidance),
|
||||
"wait guidance must follow configured tool availability and usage-hint overrides"
|
||||
);
|
||||
|
||||
let body = request.body_json();
|
||||
let wait_agent_tool = namespace_child_tool(&body, MULTI_AGENT_V2_NAMESPACE, "wait_agent");
|
||||
assert_eq!(wait_agent_tool.is_some(), wait_agent_enabled);
|
||||
if let Some(wait_agent_tool) = wait_agent_tool {
|
||||
assert_eq!(
|
||||
wait_agent_tool
|
||||
.pointer("/parameters/properties/timeout_ms/description")
|
||||
.and_then(Value::as_str),
|
||||
Some("Timeout in milliseconds. Defaults to 30000, min 10000, max 3600000.")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resuming after wait-tool availability changes must refresh stale usage guidance once.
|
||||
/// The active multi-agent mode must remain after the updated instructions.
|
||||
#[test_case(true, false; "disabled wait agent invalidates prior guidance")]
|
||||
#[test_case(false, true; "enabled wait agent adds current guidance")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn multi_agent_v2_resume_refreshes_changed_wait_guidance(
|
||||
initial_wait_agent_enabled: bool,
|
||||
resumed_wait_agent_enabled: bool,
|
||||
) -> Result<()> {
|
||||
let wait_guidance =
|
||||
"When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.";
|
||||
let initial_config_toml = format!(
|
||||
"[features.multi_agent_v2]\nenabled = true\nwait_agent_enabled = {initial_wait_agent_enabled}\n"
|
||||
);
|
||||
let server = start_mock_server().await;
|
||||
let initial_response = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-initial"),
|
||||
ev_completed("resp-initial"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let initial = test_codex()
|
||||
.with_pre_build_hook(move |home| {
|
||||
std::fs::write(home.join("config.toml"), &initial_config_toml)
|
||||
.expect("write initial multi-agent configuration");
|
||||
})
|
||||
.build_with_auto_env(&server)
|
||||
.await?;
|
||||
|
||||
initial
|
||||
.submit_turn("before changing wait-agent availability")
|
||||
.await?;
|
||||
|
||||
let initial_request = initial_response.single_request();
|
||||
assert_eq!(
|
||||
namespace_child_tool(
|
||||
&initial_request.body_json(),
|
||||
MULTI_AGENT_V2_NAMESPACE,
|
||||
"wait_agent"
|
||||
)
|
||||
.is_some(),
|
||||
initial_wait_agent_enabled
|
||||
);
|
||||
|
||||
let home = initial.home.clone();
|
||||
let rollout_path = initial
|
||||
.session_configured
|
||||
.rollout_path
|
||||
.clone()
|
||||
.expect("initial session should have a rollout path");
|
||||
initial.codex.shutdown_and_wait().await?;
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
format!(
|
||||
"[features.multi_agent_v2]\nenabled = true\nwait_agent_enabled = {resumed_wait_agent_enabled}\n"
|
||||
),
|
||||
)?;
|
||||
|
||||
let resumed_responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-resumed-first"),
|
||||
ev_completed("resp-resumed-first"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-resumed-second"),
|
||||
ev_completed("resp-resumed-second"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let resumed = test_codex().resume(&server, home, rollout_path).await?;
|
||||
let current_usage_hint = resumed
|
||||
.config
|
||||
.multi_agent_v2
|
||||
.root_agent_usage_hint_text
|
||||
.clone()
|
||||
.expect("current root usage hint should be configured");
|
||||
|
||||
resumed
|
||||
.submit_turn("first turn with updated wait-agent availability")
|
||||
.await?;
|
||||
resumed
|
||||
.submit_turn("second turn with updated wait-agent availability")
|
||||
.await?;
|
||||
|
||||
let requests = resumed_responses.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
for request in &requests {
|
||||
let developer_messages = request.message_input_text_groups("developer");
|
||||
let current_usage_hint_positions = developer_messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, group)| {
|
||||
(group.len() == 1 && group[0] == current_usage_hint).then_some(index)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
current_usage_hint_positions.len(),
|
||||
1,
|
||||
"current usage hint should refresh earlier guidance exactly once: \
|
||||
{developer_messages:?}"
|
||||
);
|
||||
let current_usage_hint_position = current_usage_hint_positions[0];
|
||||
let current_usage_message = &developer_messages[current_usage_hint_position][0];
|
||||
assert_eq!(
|
||||
current_usage_message.contains(wait_guidance),
|
||||
resumed_wait_agent_enabled
|
||||
);
|
||||
let active_mode_position = developer_messages
|
||||
.iter()
|
||||
.rposition(|group| {
|
||||
group
|
||||
.iter()
|
||||
.any(|message| message.contains(MULTI_AGENT_MODE_OPEN_TAG))
|
||||
})
|
||||
.expect("resumed context should include an active multi-agent mode");
|
||||
assert!(
|
||||
current_usage_hint_position < active_mode_position,
|
||||
"active mode must follow updated usage instructions: {developer_messages:?}"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
namespace_child_tool(&request.body_json(), MULTI_AGENT_V2_NAMESPACE, "wait_agent")
|
||||
.is_some(),
|
||||
resumed_wait_agent_enabled
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case(true, false; "wait agent remains available without clock sleep")]
|
||||
#[test_case(true, true; "wait agent remains available with clock sleep")]
|
||||
#[test_case(false, false; "wait agent can be disabled without clock sleep")]
|
||||
@@ -360,6 +736,17 @@ wait_agent_enabled = {wait_agent_enabled}
|
||||
namespace_child_tool(&body, "clock", "sleep").is_some(),
|
||||
sleep_tool_enabled
|
||||
);
|
||||
assert_eq!(
|
||||
request
|
||||
.message_input_texts("developer")
|
||||
.iter()
|
||||
.any(|message| {
|
||||
message.contains(
|
||||
"When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.",
|
||||
)
|
||||
}),
|
||||
wait_agent_enabled
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user