Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 87 additions & 2 deletions src/crates/assembly/core/src/agentic/coordination/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3050,7 +3050,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
}

if persistence_succeeded {
let status = if execution_result.success && execution_result.has_final_response {
let status = if execution_result.success
&& (execution_result.has_final_response
|| execution_result.effective_finish_reason == "user_steering")
{
AgentTurnSettlementStatus::Completed
} else {
AgentTurnSettlementStatus::Failed
Expand All @@ -3060,7 +3063,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
turn_id,
AgentTurnSettlementResult {
status,
final_response: (status == AgentTurnSettlementStatus::Completed)
final_response: (status == AgentTurnSettlementStatus::Completed
&& execution_result.has_final_response)
.then_some(final_response.clone()),
finish_reason: Some(execution_result.effective_finish_reason.clone()),
},
Expand Down Expand Up @@ -17448,6 +17452,87 @@ mod tests {
.expect("clean up persisted test session");
}

#[tokio::test]
async fn steering_handoff_settles_without_fabricating_a_final_response() {
let workspace = tempfile::tempdir().expect("workspace");
crate::service::workspace::legacy_compat::register_local_fixture_blocking(workspace.path());
let (coordinator, session_manager) = test_persistent_coordinator();
let session = session_manager
.create_session(
"Durable completion".to_string(),
"Standard".to_string(),
SessionConfig {
workspace_path: Some(workspace.path().to_string_lossy().into_owned()),
..Default::default()
},
)
.await
.expect("create session");
let turn_id = session_manager
.start_dialog_turn(
&session.session_id,
"Standard".to_string(),
"finish".to_string(),
Some("turn-durable-fence".to_string()),
None,
None,
)
.await
.expect("start turn");
let message = Message::assistant("complete response".to_string())
.with_turn_id(turn_id.clone())
.with_round_id("round-final".to_string());

ConversationCoordinator::persist_completed_dialog_turn(
coordinator.event_queue.as_ref(),
session_manager.as_ref(),
None,
&session.session_id,
&turn_id,
&ExecutionResult {
final_message: message.clone(),
total_rounds: 1,
success: true,
new_messages: vec![message],
finish_reason: FinishReason::Complete,
total_tools: 0,
duration_ms: 1,
partial_recovery_reason: None,
effective_finish_reason: "user_steering".to_string(),
has_final_response: false,
},
None,
)
.await;

assert_eq!(
session_manager
.turn_settlement_result(&session.session_id, &turn_id)
.and_then(|result| result.final_response),
None
);

assert_eq!(
session_manager
.turn_settlement_result(&session.session_id, &turn_id)
.unwrap()
.status,
AgentTurnSettlementStatus::Completed
);
let events = coordinator.event_queue.dequeue_batch(10).await;
assert!(events.iter().any(|envelope| matches!(
&envelope.event,
AgenticEvent::SessionHistoryChanged {
session_id,
settled_turn_id: Some(settled_turn_id),
} if session_id == &session.session_id && settled_turn_id == &turn_id
)));
session_manager
.delete_session_by_id(&session.session_id)
.await
.expect("clean up persisted test session");
}

#[tokio::test]
async fn load_relay_session_turns_reads_history_after_the_session_is_unloaded() {
let workspace = tempfile::tempdir().expect("workspace");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ fn fingerprint(value: &impl serde::Serialize) -> PortResult<String> {
Ok(format!("{:x}", Sha256::digest(bytes)))
}
impl HostQueueState {
pub(super) fn pending_steering_ids(
&self,
session: &str,
target: &str,
) -> std::collections::HashSet<String> {
self.sessions
.get(session)
.into_iter()
.flat_map(|queue| queue.entries.values())
.filter(|entry| {
entry.view.status == DialogQueueStatus::SteeringPending
&& entry.view.target_turn_id.as_deref() == Some(target)
})
.filter_map(|entry| entry.view.steering_id.clone())
.collect()
}
pub(super) fn contains(&self, session: &str, turn: &str) -> bool {
self.sessions
.get(session)
Expand Down Expand Up @@ -205,6 +221,54 @@ impl HostQueueState {
}
}
impl DialogScheduler {
/// Transfer accepted inputs in acceptance order, preserving their original
/// turn IDs, images, metadata, routing and exactly-once queue receipts.
pub(super) fn release_steering_turns(&self, session: &str, target: &str) -> bool {
let managed = self.queue_state().pending_steering_ids(session, target);
let injections =
self.round_injection_buffer
.drain_matching_for_turn(session, target, |message| managed.contains(&message.id));
let mut turns = Vec::new();
{
let mut state = self.queue_state();
if let Some(s) = state.sessions.get_mut(session) {
for injection in injections {
let entry = s.entries.values_mut().find(|entry| {
entry.view.status == DialogQueueStatus::SteeringPending
&& entry.view.target_turn_id.as_deref() == Some(target)
&& entry.view.steering_id.as_deref() == Some(&injection.id)
});
if let Some(entry) = entry {
if let Some(turn) = entry.held.take() {
entry.view.status = DialogQueueStatus::Queued;
entry.view.target_turn_id = None;
entry.view.steering_id = None;
entry.view.reason = None;
s.revision += 1;
turns.push(turn);
}
}
}
}
}
let released = !turns.is_empty();
for turn in turns.into_iter().rev() {
self.requeue_front(session, turn);
}
released
}
pub(super) fn has_queued_host_message(&self, session: &str) -> bool {
self.queue_state()
.sessions
.get(session)
.is_some_and(|queue| {
queue
.entries
.values()
.any(|entry| entry.view.status == DialogQueueStatus::Queued)
})
}

pub(super) fn hold_managed_queue(&self, session: &str, reason: &str) {
self.hold_managed_queue_for_outcome(session, reason, None);
}
Expand Down Expand Up @@ -580,14 +644,7 @@ impl DialogScheduler {
steering_id.clone(),
SystemTime::now(),
);
if let DialogSteeringAction::Buffer { mut injection, .. } = decision {
if let Err(reason) = self
.prepare_goal_steering(session, target, &mut injection)
.await
{
self.queue_state().hold(session, &turn, &reason);
return Err(error(&reason));
}
if let DialogSteeringAction::Buffer { injection, .. } = decision {
{
let mut state = self.queue_state();
let e = state
Expand Down
Loading
Loading