@@ -1153,6 +1153,53 @@ struct ReasoningSpawnArgs {
11531153struct ActiveRunHandle {
11541154 run_id : String ,
11551155 token : Arc < tokio_util:: sync:: CancellationToken > ,
1156+ steering : Arc < Mutex < SteeringInbox > > ,
1157+ }
1158+
1159+ pub ( crate ) struct SteeringInbox {
1160+ accepting : bool ,
1161+ pending : VecDeque < String > ,
1162+ }
1163+
1164+ impl SteeringInbox {
1165+ pub ( crate ) fn open ( ) -> Self {
1166+ Self {
1167+ accepting : true ,
1168+ pending : VecDeque :: new ( ) ,
1169+ }
1170+ }
1171+
1172+ fn push ( & mut self , content : String ) -> bool {
1173+ if !self . accepting {
1174+ return false ;
1175+ }
1176+ self . pending . push_back ( content) ;
1177+ true
1178+ }
1179+
1180+ fn drain ( & mut self ) -> Vec < String > {
1181+ self . pending . drain ( ..) . collect ( )
1182+ }
1183+
1184+ fn close ( & mut self ) {
1185+ self . accepting = false ;
1186+ self . pending . clear ( ) ;
1187+ }
1188+
1189+ fn close_or_drain ( & mut self ) -> Vec < String > {
1190+ if self . pending . is_empty ( ) {
1191+ self . accepting = false ;
1192+ Vec :: new ( )
1193+ } else {
1194+ self . drain ( )
1195+ }
1196+ }
1197+ }
1198+
1199+ fn steering_guard ( inbox : & Mutex < SteeringInbox > ) -> std:: sync:: MutexGuard < ' _ , SteeringInbox > {
1200+ inbox
1201+ . lock ( )
1202+ . unwrap_or_else ( |poisoned| poisoned. into_inner ( ) )
11561203}
11571204
11581205fn send_background_job_notification (
@@ -1317,11 +1364,13 @@ fn spawn_main_chat_reasoning_turn(
13171364) {
13181365 let chat_id = inbound. chat_id . clone ( ) ;
13191366 let cancel_token = Arc :: new ( tokio_util:: sync:: CancellationToken :: new ( ) ) ;
1367+ let steering = Arc :: new ( Mutex :: new ( SteeringInbox :: open ( ) ) ) ;
13201368 args. cancellation_tokens . insert (
13211369 chat_id. clone ( ) ,
13221370 ActiveRunHandle {
13231371 run_id : run_id. clone ( ) ,
13241372 token : cancel_token. clone ( ) ,
1373+ steering : steering. clone ( ) ,
13251374 } ,
13261375 ) ;
13271376
@@ -1435,6 +1484,7 @@ fn spawn_main_chat_reasoning_turn(
14351484 logger_tx : logger_tx. clone ( ) ,
14361485 inbound,
14371486 run_id : run_id. clone ( ) ,
1487+ steering,
14381488 cancel_token : task_token_arc. as_ref ( ) . clone ( ) ,
14391489 clarification_hub,
14401490 tool_exec_ctx,
@@ -1667,6 +1717,7 @@ pub(crate) struct ReasoningLoopCtx {
16671717 pub ( crate ) logger_tx : LoggerHandle ,
16681718 pub ( crate ) inbound : crate :: bus:: InboundMessage ,
16691719 pub ( crate ) run_id : String ,
1720+ pub ( crate ) steering : Arc < Mutex < SteeringInbox > > ,
16701721 pub ( crate ) cancel_token : tokio_util:: sync:: CancellationToken ,
16711722 pub ( crate ) clarification_hub : Arc < ClarificationHub > ,
16721723 pub ( crate ) tool_exec_ctx : ToolExecCtx ,
@@ -2009,6 +2060,7 @@ impl AgentLogic {
20092060 harness. cancel_children_for_parent ( chat_id) ;
20102061 }
20112062 }
2063+ steering_guard ( & active. steering ) . close ( ) ;
20122064 active. token . cancel ( ) ;
20132065 let _ = self . logger_tx . send ( BusMessage :: Log (
20142066 LogEvent :: info (
@@ -2046,6 +2098,21 @@ impl ActorLogic<BusMessage> for AgentLogic {
20462098 self . cancel_active_run ( & chat_id, Some ( & run_id) ) ;
20472099 return Ok ( None ) ;
20482100 }
2101+ BusMessage :: Steer {
2102+ chat_id,
2103+ run_id,
2104+ content,
2105+ } => {
2106+ if content. trim ( ) . is_empty ( ) {
2107+ return Ok ( None ) ;
2108+ }
2109+ if let Some ( active) = self . cancellation_tokens . get ( & chat_id) {
2110+ if active. run_id == run_id {
2111+ steering_guard ( & active. steering ) . push ( content) ;
2112+ }
2113+ }
2114+ return Ok ( None ) ;
2115+ }
20492116 BusMessage :: SwitchModel {
20502117 provider_name,
20512118 model_name,
@@ -2932,6 +2999,7 @@ impl AgentLogic {
29322999 logger_tx,
29333000 inbound,
29343001 run_id : _run_id,
3002+ steering,
29353003 cancel_token,
29363004 clarification_hub,
29373005 tool_exec_ctx,
@@ -3125,6 +3193,20 @@ impl AgentLogic {
31253193 }
31263194 iterations += 1 ;
31273195
3196+ // Tool paths return to the loop only after their result has been
3197+ // persisted. Consume steering before this next iteration performs
3198+ // compaction, calls another tool, or calls the provider.
3199+ let pending_steering = steering_guard ( & steering) . drain ( ) ;
3200+ if !pending_steering. is_empty ( ) {
3201+ for content in pending_steering {
3202+ mem. add_message ( crate :: utils:: ChatMessage :: user ( & content) )
3203+ . await
3204+ . map_err ( ReasoningLoopError :: persistence) ?;
3205+ }
3206+ consecutive_doom_detections = 0 ;
3207+ forbid_final_nudges = 0 ;
3208+ }
3209+
31283210 let _ = logger_tx. send ( BusMessage :: Log (
31293211 LogEvent :: debug (
31303212 & name,
@@ -3539,6 +3621,21 @@ impl AgentLogic {
35393621 LogEvent :: debug ( & name, "Provider responded." ) . with_chat_id ( & inbound. chat_id ) ,
35403622 ) ) ;
35413623
3624+ // A steering request is consumed only at a safe boundary: the
3625+ // provider has returned, but its proposed response has not yet
3626+ // been persisted or allowed to start another tool call.
3627+ let pending_steering = steering_guard ( & steering) . drain ( ) ;
3628+ if !pending_steering. is_empty ( ) {
3629+ for content in pending_steering {
3630+ mem. add_message ( crate :: utils:: ChatMessage :: user ( & content) )
3631+ . await
3632+ . map_err ( ReasoningLoopError :: persistence) ?;
3633+ }
3634+ consecutive_doom_detections = 0 ;
3635+ forbid_final_nudges = 0 ;
3636+ continue ;
3637+ }
3638+
35423639 // Log USAGE telemetry
35433640 if let Some ( usage) = & response. usage {
35443641 // Remember the exact server-counted input size for the compaction trigger.
@@ -3906,6 +4003,22 @@ impl AgentLogic {
39064003 . map_err ( ReasoningLoopError :: persistence) ?;
39074004 continue ;
39084005 }
4006+
4007+ // Atomically close steering acceptance before committing the
4008+ // final response. A request racing this boundary is therefore
4009+ // either drained and incorporated, or rejected by `push`; it
4010+ // can never be acknowledged into a stale next-run inbox.
4011+ let final_steering = steering_guard ( & steering) . close_or_drain ( ) ;
4012+ if !final_steering. is_empty ( ) {
4013+ for content in final_steering {
4014+ mem. add_message ( crate :: utils:: ChatMessage :: user ( & content) )
4015+ . await
4016+ . map_err ( ReasoningLoopError :: persistence) ?;
4017+ }
4018+ consecutive_doom_detections = 0 ;
4019+ forbid_final_nudges = 0 ;
4020+ continue ;
4021+ }
39094022 // Final outbound text
39104023 let final_response = thinking_strip_re
39114024 . replace_all ( & response_text, "" )
@@ -4155,7 +4268,10 @@ impl Tool for LoadSkillTool {
41554268
41564269#[ cfg( test) ]
41574270mod tests {
4158- use super :: { AgentLogic , AgentLogicParams , ReasoningLoopCtx , ReasoningLoopExit } ;
4271+ use super :: {
4272+ steering_guard, AgentLogic , AgentLogicParams , ReasoningLoopCtx , ReasoningLoopExit ,
4273+ SteeringInbox ,
4274+ } ;
41594275 use async_trait:: async_trait;
41604276 use axum:: {
41614277 body:: Body ,
@@ -4719,6 +4835,7 @@ mod tests {
47194835 logger_tx,
47204836 inbound,
47214837 run_id : "test-run-id" . to_string ( ) ,
4838+ steering : Arc :: new ( Mutex :: new ( SteeringInbox :: open ( ) ) ) ,
47224839 cancel_token : cancel_token. clone ( ) ,
47234840 clarification_hub : ClarificationHub :: shared ( ) ,
47244841 tool_exec_ctx : ToolExecCtx :: new ( "terminal" , "loop-test-chat" , None )
@@ -5508,6 +5625,146 @@ mod tests {
55085625 ) ) ;
55095626 }
55105627
5628+ #[ tokio:: test]
5629+ async fn steer_is_accepted_only_for_the_exact_active_run ( ) {
5630+ let ( mut agent, mut outbound_rx) = build_agent_with_provider ( Box :: new ( LongSleepProvider {
5631+ calls : Arc :: new ( AtomicUsize :: new ( 0 ) ) ,
5632+ } ) ) ;
5633+ let chat_id = "steer-run-chat" ;
5634+ agent
5635+ . process ( BusMessage :: Inbound ( test_inbound ( chat_id, "first" ) ) )
5636+ . await
5637+ . expect ( "start run" ) ;
5638+ let run_id = loop {
5639+ match outbound_rx. recv ( ) . await {
5640+ Some ( BusMessage :: RunLifecycle ( RunLifecycleEvent :: Started { run_id, .. } ) ) => {
5641+ break run_id
5642+ }
5643+ Some ( _) => continue ,
5644+ None => panic ! ( "outbound channel closed before start" ) ,
5645+ }
5646+ } ;
5647+
5648+ agent
5649+ . process ( BusMessage :: Steer {
5650+ chat_id : chat_id. to_string ( ) ,
5651+ run_id : "stale-run" . to_string ( ) ,
5652+ content : "ignore this" . to_string ( ) ,
5653+ } )
5654+ . await
5655+ . expect ( "stale steer is handled" ) ;
5656+ {
5657+ let active = agent. cancellation_tokens . get ( chat_id) . expect ( "active run" ) ;
5658+ assert ! ( steering_guard( & active. steering) . pending. is_empty( ) ) ;
5659+ }
5660+
5661+ agent
5662+ . process ( BusMessage :: Steer {
5663+ chat_id : chat_id. to_string ( ) ,
5664+ run_id : run_id. clone ( ) ,
5665+ content : "change direction" . to_string ( ) ,
5666+ } )
5667+ . await
5668+ . expect ( "exact steer is handled" ) ;
5669+ {
5670+ let active = agent. cancellation_tokens . get ( chat_id) . expect ( "active run" ) ;
5671+ let inbox = steering_guard ( & active. steering ) ;
5672+ assert_eq ! (
5673+ inbox. pending. front( ) . map( String :: as_str) ,
5674+ Some ( "change direction" )
5675+ ) ;
5676+ }
5677+
5678+ agent
5679+ . process ( BusMessage :: CancelRun {
5680+ chat_id : chat_id. to_string ( ) ,
5681+ run_id,
5682+ } )
5683+ . await
5684+ . expect ( "cancel test run" ) ;
5685+ }
5686+
5687+ #[ test]
5688+ fn steering_final_boundary_is_atomic_and_never_leaks_to_a_later_run ( ) {
5689+ let mut inbox = SteeringInbox :: open ( ) ;
5690+ assert ! ( inbox. push( "first revision" . to_string( ) ) ) ;
5691+ assert_eq ! ( inbox. close_or_drain( ) , vec![ "first revision" ] ) ;
5692+ assert ! ( inbox. accepting, "draining a revision keeps this run open" ) ;
5693+
5694+ assert ! ( inbox. close_or_drain( ) . is_empty( ) ) ;
5695+ assert ! ( !inbox. accepting, "empty final boundary closes acceptance" ) ;
5696+ assert ! ( !inbox. push( "too late" . to_string( ) ) ) ;
5697+ assert ! ( inbox. pending. is_empty( ) ) ;
5698+ }
5699+
5700+ #[ tokio:: test]
5701+ async fn steering_at_provider_boundary_is_persisted_before_the_next_response ( ) {
5702+ let ( unblock_tx, unblock_rx) = tokio:: sync:: oneshot:: channel ( ) ;
5703+ let calls = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
5704+ let provider = GateFirstChatProvider {
5705+ calls : calls. clone ( ) ,
5706+ first_unblock : Arc :: new ( tokio:: sync:: Mutex :: new ( Some ( unblock_rx) ) ) ,
5707+ } ;
5708+ let ( mut agent, mut outbound_rx) = build_agent_with_provider ( Box :: new ( provider) ) ;
5709+ let chat_id = "steer-provider-boundary" ;
5710+ agent
5711+ . process ( BusMessage :: Inbound ( test_inbound (
5712+ chat_id,
5713+ "original request" ,
5714+ ) ) )
5715+ . await
5716+ . expect ( "start run" ) ;
5717+ let run_id = loop {
5718+ match outbound_rx. recv ( ) . await {
5719+ Some ( BusMessage :: RunLifecycle ( RunLifecycleEvent :: Started { run_id, .. } ) ) => {
5720+ break run_id
5721+ }
5722+ Some ( _) => continue ,
5723+ None => panic ! ( "outbound channel closed before start" ) ,
5724+ }
5725+ } ;
5726+ agent
5727+ . process ( BusMessage :: Steer {
5728+ chat_id : chat_id. to_string ( ) ,
5729+ run_id,
5730+ content : "use the revised direction" . to_string ( ) ,
5731+ } )
5732+ . await
5733+ . expect ( "queue steering" ) ;
5734+ unblock_tx
5735+ . send ( ( ) )
5736+ . expect ( "release first provider response" ) ;
5737+ loop {
5738+ match outbound_rx. recv ( ) . await {
5739+ Some ( BusMessage :: RunLifecycle ( RunLifecycleEvent :: Terminated { .. } ) ) => break ,
5740+ Some ( _) => continue ,
5741+ None => panic ! ( "outbound channel closed before terminal" ) ,
5742+ }
5743+ }
5744+ assert_eq ! ( calls. load( Ordering :: SeqCst ) , 2 ) ;
5745+ let session_key = clarification_session_key ( "terminal" , chat_id, None ) ;
5746+ let session = agent
5747+ . session_manager
5748+ . get_session ( & session_key)
5749+ . await
5750+ . expect ( "session" ) ;
5751+ let context = session. get_context ( ) . await . expect ( "context" ) ;
5752+ let text: Vec < _ > = context
5753+ . iter ( )
5754+ . map ( |m| {
5755+ m. content
5756+ . as_ref ( )
5757+ . map ( |content| content. text_content ( ) )
5758+ . unwrap_or_default ( )
5759+ } )
5760+ . collect ( ) ;
5761+ assert ! ( text
5762+ . iter( )
5763+ . any( |value| value == "use the revised direction" ) ) ;
5764+ assert ! ( text. iter( ) . any( |value| value == "ok-1" ) ) ;
5765+ assert ! ( !text. iter( ) . any( |value| value == "ok-0" ) ) ;
5766+ }
5767+
55115768 #[ tokio:: test]
55125769 async fn clarification_inbound_routes_via_hub_before_reasoning_spawn ( ) {
55135770 let hub = Arc :: new ( ClarificationHub :: new ( ) ) ;
0 commit comments