feat(core): conversation history with bounded length
split llm.rs into a module with separate client and history submodules. ConversationHistory holds an optional system prompt plus a FIFO of user/ assistant turns capped at max_turns; oldest turns evict on overflow, system prompt is preserved across truncation and clear().
This commit is contained in:
parent
23c57b855b
commit
40f2f8b5f9
3 changed files with 123 additions and 0 deletions
115
crates/jarvis-core/src/llm/history.rs
Normal file
115
crates/jarvis-core/src/llm/history.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
use super::client::ChatMessage;
|
||||||
|
|
||||||
|
pub struct ConversationHistory {
|
||||||
|
system: Option<ChatMessage>,
|
||||||
|
turns: Vec<ChatMessage>,
|
||||||
|
max_turns: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConversationHistory {
|
||||||
|
pub fn new(system_prompt: impl Into<String>, max_turns: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
system: Some(ChatMessage::system(system_prompt)),
|
||||||
|
turns: Vec::new(),
|
||||||
|
max_turns: max_turns.max(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn without_system(max_turns: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
system: None,
|
||||||
|
turns: Vec::new(),
|
||||||
|
max_turns: max_turns.max(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_user(&mut self, content: impl Into<String>) {
|
||||||
|
self.turns.push(ChatMessage::user(content));
|
||||||
|
self.truncate();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_assistant(&mut self, content: impl Into<String>) {
|
||||||
|
self.turns.push(ChatMessage::assistant(content));
|
||||||
|
self.truncate();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> Vec<ChatMessage> {
|
||||||
|
let mut out = Vec::with_capacity(self.turns.len() + 1);
|
||||||
|
if let Some(s) = &self.system {
|
||||||
|
out.push(s.clone());
|
||||||
|
}
|
||||||
|
out.extend(self.turns.iter().cloned());
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn turns(&self) -> &[ChatMessage] {
|
||||||
|
&self.turns
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.turns.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(&mut self) {
|
||||||
|
if self.turns.len() > self.max_turns {
|
||||||
|
let drop = self.turns.len() - self.max_turns;
|
||||||
|
self.turns.drain(0..drop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn evicts_oldest_turns_but_keeps_system_prompt() {
|
||||||
|
let mut h = ConversationHistory::new("you are jarvis", 4);
|
||||||
|
h.push_user("u1");
|
||||||
|
h.push_assistant("a1");
|
||||||
|
h.push_user("u2");
|
||||||
|
h.push_assistant("a2");
|
||||||
|
h.push_user("u3");
|
||||||
|
|
||||||
|
let snap = h.snapshot();
|
||||||
|
assert_eq!(snap[0].role, "system");
|
||||||
|
assert_eq!(snap[0].content, "you are jarvis");
|
||||||
|
assert_eq!(snap.len(), 1 + 4);
|
||||||
|
|
||||||
|
let contents: Vec<&str> = snap.iter().skip(1).map(|m| m.content.as_str()).collect();
|
||||||
|
assert_eq!(contents, vec!["a1", "u2", "a2", "u3"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_with_no_system_returns_only_turns() {
|
||||||
|
let mut h = ConversationHistory::without_system(3);
|
||||||
|
h.push_user("hi");
|
||||||
|
h.push_assistant("hello");
|
||||||
|
let snap = h.snapshot();
|
||||||
|
assert_eq!(snap.len(), 2);
|
||||||
|
assert_eq!(snap[0].role, "user");
|
||||||
|
assert_eq!(snap[1].role, "assistant");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cap_of_zero_is_clamped_to_one() {
|
||||||
|
let mut h = ConversationHistory::new("sys", 0);
|
||||||
|
h.push_user("a");
|
||||||
|
h.push_user("b");
|
||||||
|
let snap = h.snapshot();
|
||||||
|
assert_eq!(snap.len(), 2);
|
||||||
|
assert_eq!(snap[0].role, "system");
|
||||||
|
assert_eq!(snap[1].content, "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_removes_turns_but_not_system() {
|
||||||
|
let mut h = ConversationHistory::new("sys", 4);
|
||||||
|
h.push_user("u");
|
||||||
|
h.push_assistant("a");
|
||||||
|
h.clear();
|
||||||
|
let snap = h.snapshot();
|
||||||
|
assert_eq!(snap.len(), 1);
|
||||||
|
assert_eq!(snap[0].role, "system");
|
||||||
|
}
|
||||||
|
}
|
||||||
8
crates/jarvis-core/src/llm/mod.rs
Normal file
8
crates/jarvis-core/src/llm/mod.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
mod client;
|
||||||
|
mod history;
|
||||||
|
|
||||||
|
pub use client::{
|
||||||
|
ChatMessage, ConfigError, LlmClient, LlmError,
|
||||||
|
DEFAULT_BASE_URL, DEFAULT_MODEL, ENV_BASE_URL, ENV_MODEL, ENV_TOKEN,
|
||||||
|
};
|
||||||
|
pub use history::ConversationHistory;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue