Skip to content

Commit e406066

Browse files
refactor: agentic chat code cleanup, fixes for appropriately handling history trimming (#708)
* refactor: agentic chat code cleanup, fixes for appropriately handling history trimming * fix: include tool uses on sigint
1 parent 1fc2151 commit e406066

File tree

10 files changed

+712
-607
lines changed

10 files changed

+712
-607
lines changed

crates/q_cli/src/cli/chat/conversation_state.rs

Lines changed: 139 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,10 @@ use rand::distributions::{
3535
};
3636
use regex::Regex;
3737
use 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;
6769
static 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)]
426502
mod 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
}

crates/q_cli/src/cli/chat/error.rs

Lines changed: 0 additions & 36 deletions
This file was deleted.

crates/q_cli/src/cli/chat/input_source.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ impl InputSource {
3333
Self(inner::Inner::Mock { index: 0, lines })
3434
}
3535

36-
pub fn read_line(&mut self, prompt: Option<&str>) -> Result<Option<String>> {
36+
pub fn read_line(&mut self, prompt: Option<&str>) -> Result<Option<String>, ReadlineError> {
3737
match &mut self.0 {
3838
inner::Inner::Readline(rl) => loop {
3939
let line = rl.readline(prompt.unwrap_or(""));
@@ -49,7 +49,7 @@ impl InputSource {
4949
return Ok(None);
5050
},
5151
Err(err) => {
52-
return Err(err.into());
52+
return Err(err);
5353
},
5454
}
5555
},

0 commit comments

Comments
 (0)