mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
Accept agent follow-ups through native turn input delivery (#58829)
## Why `start_or_steer_turn` rejected agent messages, preventing callers from submitting follow-up work through the turn input API. ## What changed Route `TurnInput::InterAgentCommunication` through native agent message delivery and return `TurnInputSubmission::Accepted`, without implying an immediate turn ID. These submissions request work regardless of `trigger_turn`, retain the recipient's settings, and forward turn-start options. Wrap plaintext messages with agent context while preserving encrypted content. Reject submissions to idle recipients when the host is draining. ## Testing Add scenario tests and snapshots covering queued-message ordering, encrypted follow-ups, preemption at reasoning boundaries, and follow-ups arriving before or after a final answer. Extend drain-gate coverage to agent submissions with parent-turn lineage. GitOrigin-RevId: 92d49465f97bb366babbf1b1bc18a1e8a6c6ed1c
This commit is contained in:
@@ -130,7 +130,11 @@ impl ThreadRequestProcessor {
|
||||
Ok(TurnInputSubmission::NotSubmitted { reason }) => {
|
||||
tracing::debug!(%thread_id, ?reason, "recovery continuation was not started");
|
||||
}
|
||||
Ok(TurnInputSubmission::Steered { .. }) => unreachable!("continuation cannot steer"),
|
||||
Ok(TurnInputSubmission::Steered { .. } | TurnInputSubmission::Accepted) => {
|
||||
// continue_turn_if_idle uses start_if_idle, which cannot steer or
|
||||
// enter the agent-message path that returns Accepted.
|
||||
unreachable!("continuation must start or decline")
|
||||
}
|
||||
Err(err) => tracing::warn!(%thread_id, %err, "recovery continuation failed"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,6 +715,11 @@ impl TurnRequestProcessor {
|
||||
error
|
||||
})?;
|
||||
let (turn_id, root_turn_id, started) = match submission {
|
||||
TurnInputSubmission::Accepted => {
|
||||
return Err(internal_error(
|
||||
"user input unexpectedly returned agent acceptance".to_string(),
|
||||
));
|
||||
}
|
||||
TurnInputSubmission::Started {
|
||||
turn_id,
|
||||
root_turn_id,
|
||||
|
||||
@@ -148,6 +148,7 @@ impl LocalAgentControl {
|
||||
.await
|
||||
{
|
||||
Ok(TurnInputSubmission::Started { turn_id, .. }) => Ok(turn_id),
|
||||
Ok(TurnInputSubmission::Accepted) => unreachable!("user input returns a turn outcome"),
|
||||
Ok(TurnInputSubmission::Steered { .. }) => {
|
||||
// MAv1 exposes an opaque `submission_id` to the model. The legacy
|
||||
// `Op::UserInput` path returned a fresh ID for every steer, while the
|
||||
|
||||
@@ -477,9 +477,16 @@ impl CodexThread {
|
||||
|
||||
/// Submits turn input without requiring the caller to inspect thread state.
|
||||
///
|
||||
/// The result describes whether Core started a turn, steered an active
|
||||
/// turn, or declined it without recording or enqueueing the input.
|
||||
/// User input and named standalone function-call outputs are accepted.
|
||||
/// User input reports whether Core started or steered a turn. Agent messages
|
||||
/// report acceptance into native delivery, which can wait for a later turn.
|
||||
/// Declined input is neither recorded nor enqueued.
|
||||
/// User input, agent messages, and named standalone function-call outputs are accepted.
|
||||
/// Agent messages contain unformatted plaintext or encrypted content. Core adds
|
||||
/// the agent-message context wrapper; callers must not add it themselves.
|
||||
/// Agent messages request work here regardless of their `trigger_turn` flag.
|
||||
/// Agent messages retain the recipient's settings. Only the message and `start`
|
||||
/// options are passed to native delivery; `thread_settings`, `turn_extension_init`,
|
||||
/// `additional_context`, and `responsesapi_client_metadata` are ignored.
|
||||
pub async fn start_or_steer_turn(
|
||||
&self,
|
||||
request: impl Into<WithTurnExtensionData<TurnInputRequest>>,
|
||||
@@ -510,8 +517,8 @@ impl CodexThread {
|
||||
TurnInputSubmission::NotSubmitted { reason } => {
|
||||
Ok(StartIfIdleSubmission::NotSubmitted { reason })
|
||||
}
|
||||
TurnInputSubmission::Steered { .. } => {
|
||||
unreachable!("start-if-idle submission cannot steer")
|
||||
TurnInputSubmission::Steered { .. } | TurnInputSubmission::Accepted => {
|
||||
unreachable!("start-if-idle submission must start or decline")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -578,8 +585,8 @@ impl CodexThread {
|
||||
TurnInputSubmission::NotSubmitted { reason } => {
|
||||
Ok(StartIfIdleSubmission::NotSubmitted { reason })
|
||||
}
|
||||
TurnInputSubmission::Steered { .. } => {
|
||||
unreachable!("recovered turn submission cannot steer")
|
||||
TurnInputSubmission::Steered { .. } | TurnInputSubmission::Accepted => {
|
||||
unreachable!("recovered turn submission must start or decline")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -645,8 +652,8 @@ impl CodexThread {
|
||||
TurnInputSubmission::NotSubmitted { reason } => {
|
||||
Ok(SteerSubmission::NotSubmitted { reason })
|
||||
}
|
||||
TurnInputSubmission::Started { .. } => {
|
||||
unreachable!("steer-only submission cannot start a turn")
|
||||
TurnInputSubmission::Started { .. } | TurnInputSubmission::Accepted => {
|
||||
unreachable!("steer-only submission must steer or decline")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,9 @@ use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[path = "turn_input_agent.rs"]
|
||||
mod agent;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "turn_input_tests.rs"]
|
||||
mod tests;
|
||||
@@ -244,12 +247,20 @@ pub(super) async fn handle(
|
||||
{
|
||||
validate_injected_response_item(item)?;
|
||||
}
|
||||
request
|
||||
.request
|
||||
.additional_context
|
||||
.values()
|
||||
.try_for_each(AdditionalContextEntry::validate)
|
||||
.map_err(|message| CodexErr::InvalidRequest(message.to_owned()))?;
|
||||
// Native agent follow-ups do not use the request's additional context.
|
||||
if !(matches!(mode, TurnInputMode::StartOrSteer)
|
||||
&& matches!(
|
||||
request.request.input,
|
||||
SubmittedTurnInput::InterAgentCommunication(_)
|
||||
))
|
||||
{
|
||||
request
|
||||
.request
|
||||
.additional_context
|
||||
.values()
|
||||
.try_for_each(AdditionalContextEntry::validate)
|
||||
.map_err(|message| CodexErr::InvalidRequest(message.to_owned()))?;
|
||||
}
|
||||
let result = match mode {
|
||||
TurnInputMode::StartOrSteer => start_or_steer(session, request, submission_id).await,
|
||||
TurnInputMode::StartIfIdle => {
|
||||
@@ -354,6 +365,12 @@ async fn start_or_steer(
|
||||
request: WithTurnExtensionData<TurnInputRequest>,
|
||||
submission_id: String,
|
||||
) -> CodexResult<TurnInputSubmission> {
|
||||
if matches!(
|
||||
request.request.input,
|
||||
SubmittedTurnInput::InterAgentCommunication(_)
|
||||
) {
|
||||
return agent::submit(session, request, submission_id).await;
|
||||
}
|
||||
let WithTurnExtensionData {
|
||||
request,
|
||||
turn_extension_init,
|
||||
@@ -379,7 +396,7 @@ async fn start_or_steer(
|
||||
}) => true,
|
||||
_ => {
|
||||
return Err(CodexErr::InvalidRequest(
|
||||
"only user input or standalone function-call outputs can start or steer a turn"
|
||||
"only user input, agent messages, or standalone function-call outputs can start or steer a turn"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
@@ -816,6 +833,9 @@ impl Session {
|
||||
)
|
||||
.await;
|
||||
match submission {
|
||||
Ok(TurnInputSubmission::Accepted) => {
|
||||
unreachable!("realtime user input returns a turn outcome")
|
||||
}
|
||||
Ok(TurnInputSubmission::Started { .. } | TurnInputSubmission::Steered { .. }) => {}
|
||||
Ok(TurnInputSubmission::NotSubmitted {
|
||||
reason: NotSubmittedReason::ServerDraining,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Admits reply-bearing agent follow-ups through native message delivery.
|
||||
//! Acceptance means queued by Core; the recipient can consume the input in a later turn.
|
||||
//! Follow-ups retain the recipient's settings and pass turn-start metadata to native delivery.
|
||||
|
||||
use crate::PendingAgentMessage;
|
||||
use crate::WithTurnExtensionData;
|
||||
use crate::agent::types::AgentMessage;
|
||||
use crate::agent::types::MessageDeliveryMode;
|
||||
use crate::session::handlers;
|
||||
use crate::session::session::Session;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
use codex_protocol::turn_input::NotSubmittedReason;
|
||||
use codex_protocol::turn_input::TurnInput;
|
||||
use codex_protocol::turn_input::TurnInputRequest;
|
||||
use codex_protocol::turn_input::TurnInputSubmission;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) async fn submit(
|
||||
session: &Arc<Session>,
|
||||
request: WithTurnExtensionData<TurnInputRequest>,
|
||||
submission_id: String,
|
||||
) -> CodexResult<TurnInputSubmission> {
|
||||
let WithTurnExtensionData { request, .. } = request;
|
||||
let TurnInputRequest { input, start, .. } = request;
|
||||
let TurnInput::InterAgentCommunication(mut communication) = input else {
|
||||
unreachable!("agent submission requires an agent message");
|
||||
};
|
||||
// CodexThread already checks local agent capacity before submitting this operation.
|
||||
// Preserve the host's drain gate when accepting work for an idle recipient.
|
||||
let _admission = if session.active_turn.lock().await.is_none() {
|
||||
let Some(admission) = session.services.extensions.admit_turn_start() else {
|
||||
return Ok(TurnInputSubmission::NotSubmitted {
|
||||
reason: NotSubmittedReason::ServerDraining,
|
||||
});
|
||||
};
|
||||
Some(admission)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if communication.encrypted_content.is_none() {
|
||||
communication.content = AgentMessage::Plaintext(std::mem::take(&mut communication.content))
|
||||
.into_communication(
|
||||
communication.author.clone(),
|
||||
communication.recipient.clone(),
|
||||
MessageDeliveryMode::TriggerTurn,
|
||||
)
|
||||
.content;
|
||||
}
|
||||
// Calling start-or-steer requests work.
|
||||
communication.trigger_turn = true;
|
||||
let pending = PendingAgentMessage::from(communication);
|
||||
// Bound the future layout depth of this additional native-delivery path.
|
||||
Box::pin(handlers::inter_agent_communication(
|
||||
session,
|
||||
submission_id,
|
||||
pending,
|
||||
start,
|
||||
))
|
||||
.await;
|
||||
Ok(TurnInputSubmission::Accepted)
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
//! Multi-turn Astra scenarios snapshot the model-visible request history of shipped features.
|
||||
|
||||
#[path = "scenarios_turn_input_agent_message.rs"]
|
||||
mod turn_input_agent_message;
|
||||
|
||||
use super::image_rollout::RecordingFileAttachmentStore;
|
||||
use super::permissions_messages::PermissionPathScenario;
|
||||
use codex_attachment_store::AttachmentStoreErrorKind;
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
//! Follow-ups use native agent-mail sampling and final-answer boundaries.
|
||||
|
||||
use codex_core::StartThreadOptions;
|
||||
use codex_core::TurnInput;
|
||||
use codex_core::TurnInputRequest;
|
||||
use codex_core::TurnInputSubmission;
|
||||
use codex_core::TurnStartOptions;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use core_test_support::context_snapshot;
|
||||
use core_test_support::context_snapshot::ContextSnapshotOptions;
|
||||
use core_test_support::context_snapshot::SnapshotEntry;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::streaming_sse::StreamingSseChunk;
|
||||
use core_test_support::streaming_sse::start_streaming_sse_server;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use test_case::test_case;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum FollowupArrival {
|
||||
BeforeFinalAnswer,
|
||||
AfterFinalAnswer,
|
||||
}
|
||||
|
||||
#[test_case(FollowupArrival::BeforeFinalAnswer; "before_final_answer")]
|
||||
#[test_case(FollowupArrival::AfterFinalAnswer; "after_final_answer")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn agent_followup_preserves_native_boundaries(
|
||||
arrival: FollowupArrival,
|
||||
) -> anyhow::Result<()> {
|
||||
let (release_reasoning, reasoning_gate) = oneshot::channel();
|
||||
let (release_final, final_gate) = oneshot::channel();
|
||||
let (release_completion, completion_gate) = oneshot::channel();
|
||||
let mut final_answer = responses::ev_assistant_message("final", "The first task is finished.");
|
||||
final_answer["item"]["phase"] = json!("final_answer");
|
||||
let mut final_added = responses::ev_message_item_added("final", "");
|
||||
final_added["item"]["phase"] = json!("final_answer");
|
||||
let (streaming, _) = start_streaming_sse_server(vec![
|
||||
vec![
|
||||
StreamingSseChunk {
|
||||
gate: None,
|
||||
body: responses::sse(vec![
|
||||
responses::ev_response_created("first"),
|
||||
responses::ev_reasoning_item_added("reasoning", &["Checking the task"]),
|
||||
]),
|
||||
},
|
||||
StreamingSseChunk {
|
||||
gate: Some(reasoning_gate),
|
||||
body: responses::sse(vec![
|
||||
responses::ev_reasoning_item("reasoning", &["Checking the task"], &[]),
|
||||
responses::ev_function_call(
|
||||
"must-not-run",
|
||||
"update_plan",
|
||||
r#"{"plan":[{"step":"This should be preempted","status":"in_progress"}]}"#,
|
||||
),
|
||||
responses::ev_completed("first"),
|
||||
]),
|
||||
},
|
||||
],
|
||||
vec![
|
||||
StreamingSseChunk {
|
||||
gate: None,
|
||||
body: responses::sse(vec![responses::ev_response_created("second"), final_added]),
|
||||
},
|
||||
StreamingSseChunk {
|
||||
gate: Some(final_gate),
|
||||
body: responses::sse(vec![final_answer]),
|
||||
},
|
||||
StreamingSseChunk {
|
||||
gate: Some(completion_gate),
|
||||
body: responses::sse(vec![responses::ev_completed("second")]),
|
||||
},
|
||||
],
|
||||
vec![StreamingSseChunk {
|
||||
gate: None,
|
||||
body: responses::sse(vec![
|
||||
responses::ev_response_created("third"),
|
||||
responses::ev_assistant_message("next-final", "The next task is finished."),
|
||||
responses::ev_completed("third"),
|
||||
]),
|
||||
}],
|
||||
])
|
||||
.await;
|
||||
let config_server = responses::start_mock_server().await;
|
||||
let base_url = format!("{}/v1", streaming.uri());
|
||||
let test = test_codex()
|
||||
.with_model("gpt-5.4")
|
||||
.with_config(move |config| {
|
||||
config.model_provider.base_url = Some(base_url);
|
||||
config.update_plan_enabled = true;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::MultiAgentV2)
|
||||
.expect("enable native multi-agent delivery");
|
||||
config
|
||||
.features
|
||||
.disable(Feature::WakeOnAgentMessages)
|
||||
.expect("disable queue-only message wakeups");
|
||||
config
|
||||
.features
|
||||
.disable(Feature::DeferMailboxPreemption)
|
||||
.expect("enable immediate mailbox preemption");
|
||||
config
|
||||
.features
|
||||
.disable(Feature::EnableRequestCompression)
|
||||
.expect("disable request compression for the mock server");
|
||||
})
|
||||
.build_with_auto_env(&config_server)
|
||||
.await?;
|
||||
let child_path = AgentPath::try_from("/root/worker").expect("child path");
|
||||
let child = test
|
||||
.thread_manager
|
||||
.start_thread(StartThreadOptions {
|
||||
session_source: Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: test.session_configured.thread_id,
|
||||
depth: 1,
|
||||
agent_path: Some(child_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
..test.start_thread_options().await
|
||||
})
|
||||
.await?
|
||||
.thread;
|
||||
// An idle recipient must see its older queue-only mail before the new task.
|
||||
child
|
||||
.submit(Op::InterAgentCommunication {
|
||||
communication: InterAgentCommunication::new(
|
||||
AgentPath::root(),
|
||||
child_path.clone(),
|
||||
Vec::new(),
|
||||
"Older queued mail".to_owned(),
|
||||
/*trigger_turn*/ false,
|
||||
),
|
||||
start_options: Default::default(),
|
||||
})
|
||||
.await?;
|
||||
child.submit(Op::RealtimeConversationListVoices).await?;
|
||||
wait_for_event(&child, |event| {
|
||||
matches!(event, EventMsg::RealtimeConversationListVoicesResponse(_))
|
||||
})
|
||||
.await;
|
||||
let message = |text: &str| {
|
||||
TurnInputRequest::new(TurnInput::InterAgentCommunication(
|
||||
InterAgentCommunication::new(
|
||||
AgentPath::root(),
|
||||
child_path.clone(),
|
||||
Vec::new(),
|
||||
text.to_owned(),
|
||||
/*trigger_turn*/ false,
|
||||
),
|
||||
))
|
||||
.on_start(TurnStartOptions {
|
||||
parent_turn_id: Some("parent-turn".to_owned()),
|
||||
root_turn_id: Some("root-turn".to_owned()),
|
||||
initiating_agent_path: Some(AgentPath::root()),
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
assert_eq!(
|
||||
child
|
||||
.start_or_steer_turn(message("Find the failing test"))
|
||||
.await?,
|
||||
TurnInputSubmission::Accepted
|
||||
);
|
||||
wait_for_event(
|
||||
&child,
|
||||
|event| matches!(event, EventMsg::ItemStarted(item) if item.item.id() == "reasoning"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
child
|
||||
.start_or_steer_turn(TurnInputRequest::new(TurnInput::InterAgentCommunication(
|
||||
InterAgentCommunication::new_encrypted(
|
||||
AgentPath::root(),
|
||||
child_path.clone(),
|
||||
Vec::new(),
|
||||
"opaque-agent-message".to_owned(),
|
||||
/*trigger_turn*/ false,
|
||||
),
|
||||
)))
|
||||
.await?,
|
||||
TurnInputSubmission::Accepted
|
||||
);
|
||||
release_reasoning.send(()).expect("release reasoning");
|
||||
// The queued follow-up must preempt the remaining tool call at this reasoning boundary.
|
||||
wait_for_event(&child, |event| {
|
||||
assert!(
|
||||
!matches!(
|
||||
event,
|
||||
EventMsg::PlanUpdate(_) | EventMsg::TurnComplete(_) | EventMsg::Error(_)
|
||||
),
|
||||
"{event:?}"
|
||||
);
|
||||
matches!(event, EventMsg::ItemStarted(item) if item.item.id() == "final")
|
||||
})
|
||||
.await;
|
||||
match arrival {
|
||||
FollowupArrival::BeforeFinalAnswer => {
|
||||
assert_eq!(
|
||||
child
|
||||
.start_or_steer_turn(message("Now check the fix"))
|
||||
.await?,
|
||||
TurnInputSubmission::Accepted
|
||||
);
|
||||
release_final.send(()).expect("release final answer");
|
||||
}
|
||||
FollowupArrival::AfterFinalAnswer => {
|
||||
release_final.send(()).expect("release final answer");
|
||||
wait_for_event(
|
||||
&child,
|
||||
|event| matches!(event, EventMsg::ItemCompleted(item) if item.item.id() == "final"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
child
|
||||
.start_or_steer_turn(message("Now check the fix"))
|
||||
.await?,
|
||||
TurnInputSubmission::Accepted
|
||||
);
|
||||
}
|
||||
}
|
||||
release_completion.send(()).expect("release completion");
|
||||
// Neither arrival may keep the first turn open. The follow-up gets a new turn.
|
||||
let mut completed_turns = Vec::new();
|
||||
wait_for_event(&child, |event| {
|
||||
assert!(
|
||||
!matches!(event, EventMsg::PlanUpdate(_) | EventMsg::Error(_)),
|
||||
"{event:?}"
|
||||
);
|
||||
if let EventMsg::TurnComplete(done) = event {
|
||||
completed_turns.push(done.turn_id.clone());
|
||||
}
|
||||
completed_turns.len() == 2
|
||||
})
|
||||
.await;
|
||||
assert_ne!(completed_turns[0], completed_turns[1]);
|
||||
let requests = streaming
|
||||
.requests()
|
||||
.await
|
||||
.iter()
|
||||
.map(|body| serde_json::from_slice::<Value>(body))
|
||||
.collect::<serde_json::Result<Vec<_>>>()?;
|
||||
assert_eq!(requests.len(), 3);
|
||||
let first_mail = requests[0]["input"]
|
||||
.as_array()
|
||||
.expect("input array")
|
||||
.iter()
|
||||
.filter(|item| item["type"] == "agent_message")
|
||||
.map(|item| {
|
||||
item["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("plaintext message")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
first_mail,
|
||||
vec![
|
||||
"Older queued mail",
|
||||
"Message Type: NEW_TASK\nTask name: /root/worker\nSender: /root\nPayload:\nFind the failing test",
|
||||
]
|
||||
);
|
||||
let later_mail = requests[1..]
|
||||
.iter()
|
||||
.map(|request| {
|
||||
request["input"]
|
||||
.as_array()
|
||||
.expect("input array")
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|item| item["type"] == "agent_message")
|
||||
.expect("follow-up message")["content"]
|
||||
.clone()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
later_mail,
|
||||
vec![
|
||||
json!([
|
||||
{"type": "input_text", "text": "Message Type: NEW_TASK\nTask name: /root/worker\nSender: /root\nPayload:\n"},
|
||||
{"type": "encrypted_content", "encrypted_content": "opaque-agent-message"},
|
||||
]),
|
||||
json!([{"type": "input_text", "text": "Message Type: NEW_TASK\nTask name: /root/worker\nSender: /root\nPayload:\nNow check the fix"}]),
|
||||
]
|
||||
);
|
||||
let entries = requests.iter().map(SnapshotEntry::body).collect::<Vec<_>>();
|
||||
insta::assert_snapshot!(
|
||||
format!("agent_followup_{arrival:?}"),
|
||||
context_snapshot::format_context_snapshot(
|
||||
"Agent follow-ups interrupt at a reasoning boundary, preserve encrypted input, and start a new turn when not consumed before the final answer completes.",
|
||||
&entries,
|
||||
&ContextSnapshotOptions::default().rewrite_known_segments(),
|
||||
)
|
||||
);
|
||||
child.shutdown_and_wait().await?;
|
||||
streaming.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
---
|
||||
source: core/tests/suite/scenarios_turn_input_agent_message.rs
|
||||
assertion_line: 198
|
||||
expression: "context_snapshot::format_context_snapshot(\"Agent follow-ups interrupt at a reasoning boundary, preserve encrypted input, and start a new turn when not consumed before the final answer completes.\",\n&entries, &ContextSnapshotOptions::default().rewrite_known_segments(),)"
|
||||
---
|
||||
Scenario: Agent follow-ups interrupt at a reasoning boundary, preserve encrypted input, and start a new turn when not consumed before the final answer completes.
|
||||
|
||||
## Window 1
|
||||
-- request 1 (request) --
|
||||
00:message/developer:
|
||||
<MODEL_INSTRUCTIONS>
|
||||
01:message/developer:
|
||||
<PERMISSIONS_INSTRUCTIONS>
|
||||
02:message/developer:
|
||||
You are an agent in a team of agents collaborating to complete a task.
|
||||
|
||||
You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. All ag...nd capable, and have access to the same set of tools. [hash=876a93c54d3f61d6]
|
||||
|
||||
You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task an... `send_message` to pass a message to a running agent. [hash=824c12425c7626dc]
|
||||
Child agents can also spawn their own sub-agents.
|
||||
|
||||
When you provide a response in the final channel, that content is immediately delivered back to your parent agent.
|
||||
<OMITTED 15 LINES; ~225 TOKENS; hash=fa366829bdb214be>
|
||||
- All agents use the same current working directory.
|
||||
- As a result, edits made by one agent are immediately visible to all other agents.
|
||||
|
||||
When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.
|
||||
|
||||
There are 4 available concurrency slots, meaning that up to 4 agents can be active at once, including you.
|
||||
|
||||
Full-history forks (`fork_turns` omitted or `"all"`) inherit the parent model and reasoning effort and d...uctions; when doing so, set `fork_turns` to `"none"`. [hash=18ccf7e93ecabab9]
|
||||
03:message/developer:
|
||||
<MULTI_AGENT_MODE>
|
||||
04:message/user:
|
||||
<ENVIRONMENT_CONTEXT>
|
||||
05:agent_message
|
||||
06:agent_message
|
||||
-- request 2 (request) --
|
||||
07:reasoning:summary=Checking the task:encrypted=true
|
||||
08:agent_message
|
||||
-- request 3 (request) --
|
||||
09:message/assistant:
|
||||
The first task is finished.
|
||||
10:agent_message
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
---
|
||||
source: core/tests/suite/scenarios_turn_input_agent_message.rs
|
||||
assertion_line: 198
|
||||
expression: "context_snapshot::format_context_snapshot(\"Agent follow-ups interrupt at a reasoning boundary, preserve encrypted input, and start a new turn when not consumed before the final answer completes.\",\n&entries, &ContextSnapshotOptions::default().rewrite_known_segments(),)"
|
||||
---
|
||||
Scenario: Agent follow-ups interrupt at a reasoning boundary, preserve encrypted input, and start a new turn when not consumed before the final answer completes.
|
||||
|
||||
## Window 1
|
||||
-- request 1 (request) --
|
||||
00:message/developer:
|
||||
<MODEL_INSTRUCTIONS>
|
||||
01:message/developer:
|
||||
<PERMISSIONS_INSTRUCTIONS>
|
||||
02:message/developer:
|
||||
You are an agent in a team of agents collaborating to complete a task.
|
||||
|
||||
You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. All ag...nd capable, and have access to the same set of tools. [hash=876a93c54d3f61d6]
|
||||
|
||||
You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task an... `send_message` to pass a message to a running agent. [hash=824c12425c7626dc]
|
||||
Child agents can also spawn their own sub-agents.
|
||||
|
||||
When you provide a response in the final channel, that content is immediately delivered back to your parent agent.
|
||||
<OMITTED 15 LINES; ~225 TOKENS; hash=fa366829bdb214be>
|
||||
- All agents use the same current working directory.
|
||||
- As a result, edits made by one agent are immediately visible to all other agents.
|
||||
|
||||
When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.
|
||||
|
||||
There are 4 available concurrency slots, meaning that up to 4 agents can be active at once, including you.
|
||||
|
||||
Full-history forks (`fork_turns` omitted or `"all"`) inherit the parent model and reasoning effort and d...uctions; when doing so, set `fork_turns` to `"none"`. [hash=18ccf7e93ecabab9]
|
||||
03:message/developer:
|
||||
<MULTI_AGENT_MODE>
|
||||
04:message/user:
|
||||
<ENVIRONMENT_CONTEXT>
|
||||
05:agent_message
|
||||
06:agent_message
|
||||
-- request 2 (request) --
|
||||
07:reasoning:summary=Checking the task:encrypted=true
|
||||
08:agent_message
|
||||
-- request 3 (request) --
|
||||
09:message/assistant:
|
||||
The first task is finished.
|
||||
10:agent_message
|
||||
@@ -311,6 +311,30 @@ async fn host_drain_allows_spawned_agent_input_but_not_automatic_work() -> anyho
|
||||
reason: NotSubmittedReason::ServerDraining
|
||||
},
|
||||
);
|
||||
// A remote agent submission has not already been admitted by this host,
|
||||
// even when its caller supplies parent lineage.
|
||||
assert_eq!(
|
||||
child
|
||||
.start_or_steer_turn(
|
||||
TurnInputRequest::new(TurnInput::InterAgentCommunication(
|
||||
InterAgentCommunication::new(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("valid agent path"),
|
||||
Vec::new(),
|
||||
"agent input while draining".to_string(),
|
||||
/*trigger_turn*/ true,
|
||||
),
|
||||
))
|
||||
.on_start(TurnStartOptions {
|
||||
parent_turn_id: Some("parent-turn".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await?,
|
||||
TurnInputSubmission::NotSubmitted {
|
||||
reason: NotSubmittedReason::ServerDraining
|
||||
},
|
||||
);
|
||||
assert!(matches!(
|
||||
child
|
||||
.start_or_steer_turn(user_message_request("delegated input").on_start(
|
||||
|
||||
@@ -247,6 +247,9 @@ pub struct TurnStartOptions {
|
||||
/// resolved causal root, including when steering preserves an existing turn.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum TurnInputSubmission {
|
||||
/// Core accepted an agent follow-up through native message delivery. The
|
||||
/// receiving turn may still be running or finishing, so no turn ID is implied.
|
||||
Accepted,
|
||||
/// Core started a turn. Persistent thread settings and start options were applied.
|
||||
Started {
|
||||
turn_id: String,
|
||||
|
||||
Reference in New Issue
Block a user