@@ -35,8 +35,10 @@ use rand::distributions::{
3535} ;
3636use regex:: Regex ;
3737use tracing:: {
38+ debug,
3839 error,
3940 info,
41+ trace,
4042 warn,
4143} ;
4244
@@ -67,7 +69,7 @@ const MAX_SHELL_HISTORY_DIRECTORY_LEN: usize = 256;
6769static CONTEXT_MODIFIER_REGEX : LazyLock < Regex > = LazyLock :: new ( || Regex :: new ( r"@(git|env|history) ?" ) . unwrap ( ) ) ;
6870
6971/// Limit to send the number of messages as part of chat.
70- const MAX_CONVERSATION_STATE_HISTORY_LEN : usize = 25 ;
72+ const MAX_CONVERSATION_STATE_HISTORY_LEN : usize = 100 ;
7173
7274/// Tracks state related to an ongoing conversation.
7375#[ derive( Debug , Clone ) ]
@@ -77,7 +79,7 @@ pub struct ConversationState {
7779 /// The next user message to be sent as part of the conversation. Required to be [Some] before
7880 /// calling [Self::as_sendable_conversation_state].
7981 pub next_message : Option < UserInputMessage > ,
80- pub history : VecDeque < ChatMessage > ,
82+ history : VecDeque < ChatMessage > ,
8183 tools : Vec < Tool > ,
8284}
8385
@@ -114,6 +116,7 @@ impl ConversationState {
114116 if let Some ( next_message) = self . next_message . as_ref ( ) {
115117 warn ! ( ?next_message, "next_message should not exist" ) ;
116118 }
119+
117120 let ( ctx, input) = input_to_modifiers ( input) ;
118121 let history = History :: new ( ) ;
119122
@@ -143,6 +146,8 @@ impl ConversationState {
143146 self . next_message = Some ( msg) ;
144147 }
145148
149+ /// This should be called sometime after [Self::as_sendable_conversation_state], and before the
150+ /// next user message is set.
146151 pub fn push_assistant_message ( & mut self , message : AssistantResponseMessage ) {
147152 debug_assert ! ( self . next_message. is_none( ) , "next_message should not exist" ) ;
148153 if let Some ( next_message) = self . next_message . as_ref ( ) {
@@ -166,7 +171,80 @@ impl ConversationState {
166171 } )
167172 }
168173
174+ /// Updates the history so that, when non-empty, the following invariants are in place:
175+ /// 1. The history length is <= MAX_CONVERSATION_STATE_HISTORY_LEN if the next user message does
176+ /// not contain tool results. Oldest messages are dropped.
177+ /// 2. The first message is from the user, and does not contain tool results. Oldest messages
178+ /// are dropped.
179+ /// 3. The last message is from the assistant. The last message is dropped if it is from the
180+ /// user.
181+ pub fn fix_history ( & mut self ) {
182+ if self . history . is_empty ( ) {
183+ return ;
184+ }
185+
186+ // Invariant (3).
187+ if let Some ( ChatMessage :: UserInputMessage ( msg) ) = self . history . iter ( ) . last ( ) {
188+ debug ! ( ?msg, "last message in history is from the user, dropping" ) ;
189+ self . history . pop_back ( ) ;
190+ }
191+
192+ // Check if the next message contains tool results - if it does, then return early.
193+ // Required in the case that the entire history consists of tool results; every message is
194+ // therefore required to avoid validation errors in the backend.
195+ match self . next_message . as_ref ( ) {
196+ Some ( UserInputMessage {
197+ user_input_message_context : Some ( ctx) ,
198+ ..
199+ } ) if ctx. tool_results . as_ref ( ) . is_none_or ( |r| r. is_empty ( ) ) => {
200+ debug ! (
201+ curr_history_len = self . history. len( ) ,
202+ max_history_len = MAX_CONVERSATION_STATE_HISTORY_LEN ,
203+ "next user message does not contain tool results, removing messages if required"
204+ ) ;
205+ } ,
206+ _ => {
207+ debug ! ( "next user message contains tool results, not modifying the history" ) ;
208+ return ;
209+ } ,
210+ }
211+
212+ // Invariant (1).
213+ while self . history . len ( ) > MAX_CONVERSATION_STATE_HISTORY_LEN {
214+ self . history . pop_front ( ) ;
215+ }
216+
217+ // Invariant (2).
218+ match self
219+ . history
220+ . iter ( )
221+ . enumerate ( )
222+ . find ( |( _, m) | -> bool {
223+ match m {
224+ ChatMessage :: UserInputMessage ( m) => {
225+ matches ! (
226+ m. user_input_message_context. as_ref( ) ,
227+ Some ( ctx) if ctx. tool_results. as_ref( ) . is_none_or( |v| v. is_empty( ) )
228+ )
229+ } ,
230+ ChatMessage :: AssistantResponseMessage ( _) => false ,
231+ }
232+ } )
233+ . map ( |v| v. 0 )
234+ {
235+ Some ( i) => {
236+ trace ! ( "removing the first {i} elements in the history" ) ;
237+ self . history . drain ( ..i) ;
238+ } ,
239+ None => {
240+ trace ! ( "no valid starting user message found in the history, clearing" ) ;
241+ self . history . clear ( ) ;
242+ } ,
243+ }
244+ }
245+
169246 pub fn add_tool_results ( & mut self , tool_results : Vec < ToolResult > ) {
247+ debug_assert ! ( self . next_message. is_none( ) ) ;
170248 let user_input_message_context = UserInputMessageContext {
171249 shell_state : None ,
172250 env_state : Some ( build_env_state ( None ) ) ,
@@ -187,6 +265,7 @@ impl ConversationState {
187265 }
188266
189267 pub fn abandon_tool_use ( & mut self , tools_to_be_abandoned : Vec < ( String , super :: tools:: Tool ) > , deny_input : String ) {
268+ debug_assert ! ( self . next_message. is_none( ) ) ;
190269 let tool_results = tools_to_be_abandoned
191270 . into_iter ( )
192271 . map ( |( tool_use_id, _) | ToolResult {
@@ -220,11 +299,8 @@ impl ConversationState {
220299 /// [fig_api_client::StreamingClient] while preparing the current conversation state to be sent
221300 /// in the next message.
222301 pub fn as_sendable_conversation_state ( & mut self ) -> FigConversationState {
223- assert ! ( self . next_message. is_some( ) ) ;
224- while self . history . len ( ) > MAX_CONVERSATION_STATE_HISTORY_LEN {
225- self . history . pop_front ( ) ;
226- self . history . pop_front ( ) ;
227- }
302+ debug_assert ! ( self . next_message. is_some( ) ) ;
303+ self . fix_history ( ) ;
228304
229305 // The current state we want to send
230306 let curr_state = self . clone ( ) ;
@@ -424,7 +500,10 @@ fn truncate_safe(s: &str, max_bytes: usize) -> &str {
424500
425501#[ cfg( test) ]
426502mod tests {
427- use fig_api_client:: model:: AssistantResponseMessage ;
503+ use fig_api_client:: model:: {
504+ AssistantResponseMessage ,
505+ ToolResultStatus ,
506+ } ;
428507 use fig_settings:: history:: CommandInfo ;
429508
430509 use super :: * ;
@@ -557,7 +636,19 @@ mod tests {
557636 // User -> Assistant -> User -> Assistant ...and so on.
558637 conversation_state. append_new_user_message ( "start" . to_string ( ) ) . await ;
559638 for i in 0 ..=100 {
560- let _ = conversation_state. as_sendable_conversation_state ( ) ;
639+ let s = conversation_state. as_sendable_conversation_state ( ) ;
640+ assert ! (
641+ s. history
642+ . as_ref( )
643+ . is_none_or( |h| h. first( ) . is_none_or( |m| matches!( m, ChatMessage :: UserInputMessage ( _) ) ) ) ,
644+ "First message in the history must be from the user"
645+ ) ;
646+ assert ! (
647+ s. history. as_ref( ) . is_none_or( |h| h
648+ . last( )
649+ . is_none_or( |m| matches!( m, ChatMessage :: AssistantResponseMessage ( _) ) ) ) ,
650+ "Last message in the history must be from the assistant"
651+ ) ;
561652 conversation_state. push_assistant_message ( AssistantResponseMessage {
562653 message_id : None ,
563654 content : i. to_string ( ) ,
@@ -569,10 +660,17 @@ mod tests {
569660 let s = conversation_state. as_sendable_conversation_state ( ) ;
570661 assert_eq ! (
571662 s. history. as_ref( ) . unwrap( ) . len( ) ,
572- MAX_CONVERSATION_STATE_HISTORY_LEN - 1 ,
573- "history should be capped at {}, and we would only see 24 after truncating because we would be removing 2 at a time " ,
663+ MAX_CONVERSATION_STATE_HISTORY_LEN ,
664+ "history should be capped at {}" ,
574665 MAX_CONVERSATION_STATE_HISTORY_LEN
575666 ) ;
667+ let first_msg = s. history . as_ref ( ) . unwrap ( ) . first ( ) . unwrap ( ) ;
668+ match first_msg {
669+ ChatMessage :: UserInputMessage ( _) => { } ,
670+ other @ ChatMessage :: AssistantResponseMessage ( _) => {
671+ panic ! ( "First message should be from the user, instead found {:?}" , other)
672+ } ,
673+ }
576674 let last_msg = s. history . as_ref ( ) . unwrap ( ) . iter ( ) . last ( ) . unwrap ( ) ;
577675 match last_msg {
578676 ChatMessage :: AssistantResponseMessage ( assistant_response_message) => {
@@ -583,4 +681,34 @@ mod tests {
583681 } ,
584682 }
585683 }
684+
685+ #[ tokio:: test]
686+ async fn test_conversation_state_history_handling_with_tool_results ( ) {
687+ let mut conversation_state = ConversationState :: new ( load_tools ( ) . unwrap ( ) ) ;
688+
689+ // Build a long conversation history of tool use results.
690+ conversation_state. append_new_user_message ( "start" . to_string ( ) ) . await ;
691+ for i in 0 ..=( MAX_CONVERSATION_STATE_HISTORY_LEN + 100 ) {
692+ let _ = conversation_state. as_sendable_conversation_state ( ) ;
693+ conversation_state. push_assistant_message ( AssistantResponseMessage {
694+ message_id : None ,
695+ content : i. to_string ( ) ,
696+ tool_uses : None ,
697+ } ) ;
698+ conversation_state. add_tool_results ( vec ! [ ToolResult {
699+ tool_use_id: "tool_id" . to_string( ) ,
700+ content: vec![ ] ,
701+ status: ToolResultStatus :: Success ,
702+ } ] ) ;
703+ }
704+
705+ let s = conversation_state. as_sendable_conversation_state ( ) ;
706+ let actual_history_len = s. history . as_ref ( ) . unwrap ( ) . len ( ) ;
707+ assert ! (
708+ actual_history_len > MAX_CONVERSATION_STATE_HISTORY_LEN ,
709+ "history should extend past the max limit of {}, instead found length {}" ,
710+ MAX_CONVERSATION_STATE_HISTORY_LEN ,
711+ actual_history_len
712+ ) ;
713+ }
586714}
0 commit comments