@@ -699,6 +699,22 @@ impl SubSessionRunner {
699699 self . runner = runner;
700700 self . runner . accumulate ( & carried_usage) ;
701701 self . runner . accumulate_tools_usage ( & carried_tools_usage) ;
702+ // Compaction is real work: refresh the activity clock so the idle-timeout check on the
703+ // turn that follows does not mistake the session for stale.
704+ self . session . active_at . store ( unix_ms ( ) , Ordering :: SeqCst ) ;
705+ Ok ( ( ) )
706+ }
707+
708+ /// Compacts the idle context when the committed history — or the batch about to be attached —
709+ /// would exceed the window. `pending_tokens` is the estimated size of follow-up/steer content
710+ /// queued in this run() call but not yet attached: compaction drains queued follow-ups into its
711+ /// own request, so it must run *before* the content is attached or the compaction request would
712+ /// overflow too.
713+ async fn compact_if_needed ( & mut self , pending_tokens : u64 ) -> Result < ( ) , BoxError > {
714+ if self . runner . is_idle ( ) && needs_compaction_with_pending ( & self . runner , pending_tokens) {
715+ self . compact ( ) . await ?;
716+ }
717+
702718 Ok ( ( ) )
703719 }
704720
@@ -710,6 +726,15 @@ impl SubSessionRunner {
710726 self . session . active_at . store ( unix_ms ( ) , Ordering :: SeqCst ) ;
711727 }
712728
729+ // Accumulate all follow-up/steer content for this batch instead of queueing it
730+ // input-by-input. Background results arrive as separate inputs and are drained into a
731+ // single run() call, so a batch can be far larger than any single input. Queueing each one
732+ // immediately defeated compaction: only the first input was size-checked, because attaching
733+ // it made the runner report not-idle and the rest bypassed the check. Sizing the whole
734+ // batch up front lets idle compaction run before the content is attached.
735+ let mut follow_up_batch: Vec < ContentPart > = Vec :: new ( ) ;
736+ let mut steer_batch: Vec < ContentPart > = Vec :: new ( ) ;
737+
713738 for mut input in inputs {
714739 // 累计来自于后台任务的工具使用情况
715740 self . runner . accumulate ( & input. usage ) ;
@@ -750,33 +775,30 @@ impl SubSessionRunner {
750775
751776 match input. command {
752777 PromptCommand :: Ping => {
753- let content =
754- prompt_and_resources_into_content ( String :: new ( ) , & mut input. resources ) ;
755- if !content. is_empty ( ) {
756- self . runner . follow_up_content ( content) ;
757- }
778+ follow_up_batch. extend ( prompt_and_resources_into_content (
779+ String :: new ( ) ,
780+ & mut input. resources ,
781+ ) ) ;
758782 continue ;
759783 }
760784 PromptCommand :: Plain { prompt } => {
761- let content = prompt_and_resources_into_content ( prompt , & mut input . resources ) ;
762- if !content . is_empty ( ) {
763- self . runner . follow_up_content ( content ) ;
764- }
785+ follow_up_batch . extend ( prompt_and_resources_into_content (
786+ prompt ,
787+ & mut input . resources ,
788+ ) ) ;
765789 }
766790 PromptCommand :: Command { command, prompt } => match command. as_str ( ) {
767791 "steer" => {
768- let content =
769- prompt_and_resources_into_content ( prompt, & mut input. resources ) ;
770- if !content. is_empty ( ) {
771- self . runner . steer_content ( content) ;
772- }
792+ steer_batch. extend ( prompt_and_resources_into_content (
793+ prompt,
794+ & mut input. resources ,
795+ ) ) ;
773796 }
774797 _ => {
775- let content =
776- prompt_and_resources_into_content ( prompt, & mut input. resources ) ;
777- if !content. is_empty ( ) {
778- self . runner . follow_up_content ( content) ;
779- }
798+ follow_up_batch. extend ( prompt_and_resources_into_content (
799+ prompt,
800+ & mut input. resources ,
801+ ) ) ;
780802 }
781803 } ,
782804 }
@@ -792,6 +814,19 @@ impl SubSessionRunner {
792814 return Ok ( true ) ;
793815 }
794816
817+ // Compact (if needed) before attaching the batch, accounting for its estimated size, then
818+ // queue it. Running unconditionally also covers the case where the committed history grew
819+ // over the threshold without any new input this round.
820+ let pending_tokens = estimated_content_tokens ( & follow_up_batch)
821+ . saturating_add ( estimated_content_tokens ( & steer_batch) ) ;
822+ self . compact_if_needed ( pending_tokens) . await ?;
823+ if !follow_up_batch. is_empty ( ) {
824+ self . runner . follow_up_content ( follow_up_batch) ;
825+ }
826+ if !steer_batch. is_empty ( ) {
827+ self . runner . steer_content ( steer_batch) ;
828+ }
829+
795830 match self . runner . next ( ) . await {
796831 Ok ( None ) => {
797832 if self . closing || self . runner . is_done ( ) {
@@ -1934,15 +1969,96 @@ impl SubAgentSet for SubAgentSetManager {
19341969
19351970/// Returns true when a session runner should compact its conversation history.
19361971pub fn needs_compaction ( runner : & CompletionRunner ) -> bool {
1937- let current_usage = runner. current_usage ( ) ;
1972+ needs_compaction_with_pending ( runner, 0 )
1973+ }
1974+
1975+ /// Like [`needs_compaction`], but also accounts for `pending_tokens` — the estimated size of
1976+ /// follow-up/steer content about to be attached. This lets compaction run before a large batch of
1977+ /// background results is queued, instead of after the next request has already overflowed.
1978+ fn needs_compaction_with_pending ( runner : & CompletionRunner , pending_tokens : u64 ) -> bool {
1979+ let threshold = compaction_threshold ( runner) ;
1980+
1981+ // Cheap signals first: the model-reported size of the last request and the turn count.
1982+ if runner. current_usage ( ) . input_tokens >= threshold || runner. turns ( ) >= MAX_TURNS_TO_COMPACT {
1983+ return true ;
1984+ }
1985+
1986+ if pending_tokens == 0 {
1987+ return false ;
1988+ }
1989+
1990+ // Use the larger of the model-reported size of the current context (which already counts the
1991+ // system prompt and tool schemas) and a char-based history estimate (a fallback before the
1992+ // first turn reports usage).
1993+ let current = runner
1994+ . current_usage ( )
1995+ . input_tokens
1996+ . max ( estimated_history_tokens ( runner) ) ;
1997+ current. saturating_add ( pending_tokens) >= threshold
1998+ }
1999+
2000+ fn compaction_threshold ( runner : & CompletionRunner ) -> u64 {
19382001 let context_window = runner. model ( ) . context_window as u64 ;
1939- let threshold = if context_window == 0 {
2002+ if context_window == 0 {
19402003 100_000
19412004 } else {
19422005 context_window. saturating_mul ( 8 ) . saturating_div ( 10 ) . max ( 1 )
1943- } ;
2006+ }
2007+ }
2008+
2009+ /// Rough char-based token estimate of the committed history. Used only as a pre-queue safety check
2010+ /// for a batch about to be attached; `current_usage` is the authoritative size once the first turn
2011+ /// has reported it.
2012+ fn estimated_history_tokens ( runner : & CompletionRunner ) -> u64 {
2013+ runner
2014+ . chat_history ( )
2015+ . iter ( )
2016+ . map ( estimated_message_tokens)
2017+ . sum ( )
2018+ }
19442019
1945- current_usage. input_tokens >= threshold || runner. turns ( ) >= MAX_TURNS_TO_COMPACT
2020+ fn estimated_message_tokens ( message : & Message ) -> u64 {
2021+ 8u64 . saturating_add ( estimated_content_tokens ( & message. content ) )
2022+ }
2023+
2024+ fn estimated_content_tokens ( content : & [ ContentPart ] ) -> u64 {
2025+ content. iter ( ) . map ( estimated_content_part_tokens) . sum ( )
2026+ }
2027+
2028+ fn estimated_content_part_tokens ( content : & ContentPart ) -> u64 {
2029+ match content {
2030+ ContentPart :: Text { text } | ContentPart :: Reasoning { text } => estimated_text_tokens ( text) ,
2031+ ContentPart :: FileData {
2032+ file_uri,
2033+ mime_type,
2034+ } => estimated_text_tokens ( file_uri)
2035+ . saturating_add ( mime_type. as_deref ( ) . map_or ( 0 , estimated_text_tokens) ) ,
2036+ ContentPart :: InlineData { mime_type, data } => estimated_text_tokens ( mime_type)
2037+ . saturating_add ( ( data. len ( ) as u64 ) . saturating_add ( 3 ) / 4 ) ,
2038+ ContentPart :: ToolCall {
2039+ name,
2040+ args,
2041+ call_id,
2042+ } => estimated_text_tokens ( name)
2043+ . saturating_add ( estimated_text_tokens ( & args. to_string ( ) ) )
2044+ . saturating_add ( call_id. as_deref ( ) . map_or ( 0 , estimated_text_tokens) ) ,
2045+ ContentPart :: ToolOutput {
2046+ name,
2047+ output,
2048+ call_id,
2049+ ..
2050+ } => estimated_text_tokens ( name)
2051+ . saturating_add ( estimated_text_tokens ( & output. to_string ( ) ) )
2052+ . saturating_add ( call_id. as_deref ( ) . map_or ( 0 , estimated_text_tokens) ) ,
2053+ ContentPart :: Action { name, payload, .. } => {
2054+ estimated_text_tokens ( name) . saturating_add ( estimated_text_tokens ( & payload. to_string ( ) ) )
2055+ }
2056+ ContentPart :: Any ( value) => estimated_text_tokens ( & value. to_string ( ) ) ,
2057+ }
2058+ }
2059+
2060+ fn estimated_text_tokens ( text : & str ) -> u64 {
2061+ ( text. chars ( ) . count ( ) as u64 ) . saturating_add ( 3 ) / 4
19462062}
19472063
19482064#[ cfg( test) ]
@@ -2052,6 +2168,48 @@ mod tests {
20522168 }
20532169 }
20542170
2171+ #[ derive( Clone , Debug ) ]
2172+ struct RecordingLowUsageCompactionCompleter {
2173+ requests : Arc < Mutex < Vec < CompletionRequest > > > ,
2174+ }
2175+
2176+ impl CompletionFeaturesDyn for RecordingLowUsageCompactionCompleter {
2177+ fn model_name ( & self ) -> String {
2178+ "recording-low-usage-compaction" . to_string ( )
2179+ }
2180+
2181+ fn completion ( & self , req : CompletionRequest ) -> BoxPinFut < Result < AgentOutput , BoxError > > {
2182+ self . requests . lock ( ) . push ( req. clone ( ) ) ;
2183+
2184+ let prompt = request_text ( & req) ;
2185+ let history = req
2186+ . chat_history
2187+ . iter ( )
2188+ . filter_map ( Message :: text)
2189+ . collect :: < Vec < _ > > ( )
2190+ . join ( " | " ) ;
2191+
2192+ let content = if prompt. trim ( ) == COMPACTION_PROMPT . trim ( ) {
2193+ "compacted handoff" . to_string ( )
2194+ } else if history. is_empty ( ) {
2195+ prompt. clone ( )
2196+ } else {
2197+ format ! ( "history={history}; input={prompt}" )
2198+ } ;
2199+
2200+ Box :: pin ( futures:: future:: ready ( Ok ( AgentOutput {
2201+ content,
2202+ usage : Usage {
2203+ input_tokens : 1 ,
2204+ output_tokens : 1 ,
2205+ cached_tokens : 0 ,
2206+ requests : 1 ,
2207+ } ,
2208+ ..Default :: default ( )
2209+ } ) ) )
2210+ }
2211+ }
2212+
20552213 #[ derive( Clone , Debug ) ]
20562214 struct EmptyCompactionCompleter ;
20572215
@@ -2934,6 +3092,79 @@ mod tests {
29343092 assert_eq ! ( request_text( & recorded[ 2 ] ) , "continue after compaction" ) ;
29353093 }
29363094
3095+ #[ tokio:: test( flavor = "current_thread" ) ]
3096+ async fn subsession_runner_compacts_oversized_input_batch_before_queueing ( ) {
3097+ let requests = Arc :: new ( Mutex :: new ( Vec :: new ( ) ) ) ;
3098+ let mut model = Model :: with_completer ( Arc :: new ( RecordingLowUsageCompactionCompleter {
3099+ requests : requests. clone ( ) ,
3100+ } ) ) ;
3101+ model. context_window = 1_000 ; // compaction threshold = 800 tokens
3102+ let ctx = EngineBuilder :: new ( ) . with_model ( model) . mock_ctx ( ) ;
3103+
3104+ let ( sender, _rx) = tokio:: sync:: mpsc:: channel ( 4 ) ;
3105+ let session = Arc :: new ( SubSession {
3106+ id : "session-batch" . to_string ( ) ,
3107+ agent : "compactor" . to_string ( ) ,
3108+ sender,
3109+ background_tasks : Arc :: new ( RwLock :: new ( HashMap :: new ( ) ) ) ,
3110+ active_at : AtomicU64 :: new ( unix_ms ( ) ) ,
3111+ idle_timeout_ms : 0 ,
3112+ } ) ;
3113+
3114+ let mut runner = SubSessionRunner {
3115+ session,
3116+ agent_hook : None ,
3117+ runner : ctx
3118+ . completion_iter ( CompletionRequest :: default ( ) , Vec :: new ( ) )
3119+ . unbound ( ) ,
3120+ last_output : None ,
3121+ carried_artifacts : Vec :: new ( ) ,
3122+ closing : false ,
3123+ } ;
3124+
3125+ // Each input carries no usage, so only the batch content estimate can trigger compaction.
3126+ // Each chunk is ~400 tokens (under the 800 threshold); the three batched together are
3127+ // ~1200 (over it). The per-input check missed this: queueing the first follow-up made the
3128+ // runner report not-idle, so the rest bypassed the size check.
3129+ let chunk = "y" . repeat ( 1_600 ) ;
3130+ let plain = |prompt : String | SubAgentInput {
3131+ command : PromptCommand :: Plain { prompt } ,
3132+ resources : Vec :: new ( ) ,
3133+ usage : Usage :: default ( ) ,
3134+ model : None ,
3135+ effort : None ,
3136+ } ;
3137+
3138+ assert ! (
3139+ runner
3140+ . run( vec![
3141+ plain( chunk. clone( ) ) ,
3142+ plain( chunk. clone( ) ) ,
3143+ plain( chunk. clone( ) ) ,
3144+ ] )
3145+ . await
3146+ . unwrap( )
3147+ ) ;
3148+
3149+ let recorded = requests. lock ( ) . clone ( ) ;
3150+ // Compaction runs once up front, then the whole batch is queued on top of the compacted
3151+ // handoff in a single follow-up request.
3152+ assert_eq ! ( recorded. len( ) , 2 ) ;
3153+ assert_eq ! ( request_text( & recorded[ 0 ] ) . trim( ) , COMPACTION_PROMPT . trim( ) ) ;
3154+ assert_eq ! (
3155+ recorded[ 1 ]
3156+ . chat_history
3157+ . iter( )
3158+ . filter_map( Message :: text)
3159+ . collect:: <Vec <_>>( ) ,
3160+ vec![ "compacted handoff" . to_string( ) ]
3161+ ) ;
3162+ assert_eq ! (
3163+ request_text( & recorded[ 1 ] ) . matches( chunk. as_str( ) ) . count( ) ,
3164+ 3
3165+ ) ;
3166+ }
3167+
29373168 #[ tokio:: test( flavor = "current_thread" ) ]
29383169 async fn subsession_runner_fails_when_compaction_summary_is_empty ( ) {
29393170 let model = Model :: with_completer ( Arc :: new ( EmptyCompactionCompleter ) ) ;
@@ -2959,8 +3190,7 @@ mod tests {
29593190 session,
29603191 agent_hook : None ,
29613192 runner : ctx. completion_iter ( req, Vec :: new ( ) ) . unbound ( ) ,
2962- last_output : None ,
2963- // A previously rescued artifact must survive even when compaction fails.
3193+ last_output : None , // A previously rescued artifact must survive even when compaction fails.
29643194 carried_artifacts : vec ! [ resource( 5 , & [ "artifact" ] ) ] ,
29653195 closing : false ,
29663196 } ;
0 commit comments