feat: fork pattern v2 (#15771)

Adds this:
```
properties.insert(
            "fork_turns".to_string(),
            JsonSchema::String {
                description: Some(
                    "Optional MultiAgentV2 fork mode. Use `none`, `all`, or a positive integer string such as `3` to fork only the most recent turns."
                        .to_string(),
                ),
            },
        );
        ```

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
jif-oai
2026-03-31 13:06:08 +02:00
committed by GitHub
co-authored by Codex
parent 2b8d29ac0d
commit 1fc8aa0e16
10 changed files with 702 additions and 82 deletions
@@ -1,4 +1,5 @@
use super::*;
use crate::agent::control::SpawnAgentForkMode;
use crate::agent::control::SpawnAgentOptions;
use crate::agent::control::render_input_preview;
use crate::agent::role::DEFAULT_ROLE_NAME;
@@ -90,6 +91,7 @@ impl ToolHandler for Handler {
)?),
SpawnAgentOptions {
fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()),
fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory),
},
)
.await
@@ -480,6 +480,123 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat
}));
}
#[tokio::test]
async fn multi_agent_v2_spawn_rejects_legacy_fork_context() {
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).clone();
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
turn.config = Arc::new(config);
let err = SpawnAgentHandlerV2
.handle(invocation(
Arc::new(session),
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"message": "inspect this repo",
"task_name": "worker",
"fork_context": true
})),
))
.await
.expect_err("legacy fork_context should be rejected");
assert_eq!(
err,
FunctionCallError::RespondToModel(
"fork_context is not supported in MultiAgentV2; use fork_turns instead".to_string()
)
);
}
#[tokio::test]
async fn multi_agent_v2_spawn_rejects_invalid_fork_turns_string() {
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).clone();
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
turn.config = Arc::new(config);
let err = SpawnAgentHandlerV2
.handle(invocation(
Arc::new(session),
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"message": "inspect this repo",
"task_name": "worker",
"fork_turns": "banana"
})),
))
.await
.expect_err("invalid fork_turns should be rejected");
assert_eq!(
err,
FunctionCallError::RespondToModel(
"fork_turns must be `none`, `all`, or a positive integer string".to_string()
)
);
}
#[tokio::test]
async fn multi_agent_v2_spawn_rejects_zero_fork_turns() {
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).clone();
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
turn.config = Arc::new(config);
let err = SpawnAgentHandlerV2
.handle(invocation(
Arc::new(session),
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"message": "inspect this repo",
"task_name": "worker",
"fork_turns": "0"
})),
))
.await
.expect_err("zero turn count should be rejected");
assert_eq!(
err,
FunctionCallError::RespondToModel(
"fork_turns must be `none`, `all`, or a positive integer string".to_string()
)
);
}
#[tokio::test]
async fn multi_agent_v2_send_message_accepts_root_target_from_child() {
let (mut session, mut turn) = make_session_and_context().await;
@@ -1,4 +1,5 @@
use super::*;
use crate::agent::control::SpawnAgentForkMode;
use crate::agent::control::SpawnAgentOptions;
use crate::agent::control::render_input_preview;
use crate::agent::next_thread_spawn_depth;
@@ -32,6 +33,7 @@ impl ToolHandler for Handler {
} = invocation;
let arguments = function_arguments(payload)?;
let args: SpawnAgentArgs = parse_arguments(&arguments)?;
let fork_mode = args.fork_mode()?;
let role_name = args
.agent_type
.as_deref()
@@ -112,7 +114,8 @@ impl ToolHandler for Handler {
},
Some(spawn_source),
SpawnAgentOptions {
fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()),
fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()),
fork_mode,
},
)
.await
@@ -204,8 +207,47 @@ struct SpawnAgentArgs {
agent_type: Option<String>,
model: Option<String>,
reasoning_effort: Option<ReasoningEffort>,
#[serde(default)]
fork_context: bool,
fork_turns: Option<String>,
fork_context: Option<bool>,
}
impl SpawnAgentArgs {
fn fork_mode(&self) -> Result<Option<SpawnAgentForkMode>, FunctionCallError> {
if self.fork_context.is_some() {
return Err(FunctionCallError::RespondToModel(
"fork_context is not supported in MultiAgentV2; use fork_turns instead".to_string(),
));
}
let Some(fork_turns) = self
.fork_turns
.as_deref()
.map(str::trim)
.filter(|fork_turns| !fork_turns.is_empty())
else {
return Ok(None);
};
if fork_turns.eq_ignore_ascii_case("none") {
return Ok(None);
}
if fork_turns.eq_ignore_ascii_case("all") {
return Ok(Some(SpawnAgentForkMode::FullHistory));
}
let last_n_turns = fork_turns.parse::<usize>().map_err(|_| {
FunctionCallError::RespondToModel(
"fork_turns must be `none`, `all`, or a positive integer string".to_string(),
)
})?;
if last_n_turns == 0 {
return Err(FunctionCallError::RespondToModel(
"fork_turns must be `none`, `all`, or a positive integer string".to_string(),
));
}
Ok(Some(SpawnAgentForkMode::LastNTurns(last_n_turns)))
}
}
#[derive(Debug, Serialize)]
+12
View File
@@ -431,6 +431,16 @@ fn test_build_specs_collab_tools_enabled() {
);
assert_lacks_tool_name(&tools, "spawn_agents_on_csv");
assert_lacks_tool_name(&tools, "list_agents");
let spawn_agent = find_tool(&tools, "spawn_agent");
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &spawn_agent.spec else {
panic!("spawn_agent should be a function tool");
};
let JsonSchema::Object { properties, .. } = parameters else {
panic!("spawn_agent should use object params");
};
assert!(properties.contains_key("fork_context"));
assert!(!properties.contains_key("fork_turns"));
}
#[test]
@@ -487,6 +497,8 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
panic!("spawn_agent should use object params");
};
assert!(properties.contains_key("task_name"));
assert!(properties.contains_key("fork_turns"));
assert!(!properties.contains_key("fork_context"));
assert_eq!(required.as_ref(), Some(&vec!["task_name".to_string()]));
let output_schema = output_schema
.as_ref()