Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions crates/loopal-context/src/middleware/config_refresh.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
use std::sync::Mutex;

use async_trait::async_trait;
use loopal_error::LoopalError;
use loopal_message::{ContentBlock, Message, MessageRole};
use loopal_provider_api::{Middleware, MiddlewareContext};
use tracing::debug;

use super::file_snapshot::FileSnapshot;

pub struct ConfigRefreshMiddleware {
snapshots: Mutex<Vec<FileSnapshot>>,
}

impl ConfigRefreshMiddleware {
pub fn new(snapshots: Vec<FileSnapshot>) -> Self {
Self {
snapshots: Mutex::new(snapshots),
}
}
}

#[async_trait]
impl Middleware for ConfigRefreshMiddleware {
fn name(&self) -> &str {
"config_refresh"
}

async fn process(&self, ctx: &mut MiddlewareContext) -> Result<(), LoopalError> {
// Recover from poison — a panic in a previous holder shouldn't block future checks,
// since the lock only protects file-snapshot metadata (mtime + content cache).
let mut snapshots = self.snapshots.lock().unwrap_or_else(|e| e.into_inner());
let mut reminders = Vec::new();

for snap in snapshots.iter_mut() {
if let Some(reminder) = snap.check_and_refresh() {
debug!(label = snap.label(), "config file changed");
reminders.push(reminder);
}
}
drop(snapshots);

if reminders.is_empty() {
return Ok(());
}

let reminder_text = format!(
"<system-reminder>\n{}\n</system-reminder>",
reminders.join("\n\n")
);
debug!(
count = reminders.len(),
"injecting config refresh reminders"
);
// User role (not System) — modifying the system prompt would invalidate
// Anthropic's prefix cache. system-reminder XML tags are the established
// convention for injecting context updates as user messages.
ctx.messages.push(Message {
id: None,
role: MessageRole::User,
content: vec![ContentBlock::Text {
text: reminder_text,
}],
});
Ok(())
}
}
136 changes: 136 additions & 0 deletions crates/loopal-context/src/middleware/file_snapshot.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::time::SystemTime;

pub struct FileSnapshot {
path: PathBuf,
label: String,
content: String,
mtime: Option<SystemTime>,
}

impl FileSnapshot {
pub fn load(path: PathBuf, label: impl Into<String>) -> Self {
let (content, mtime) = read_with_mtime(&path);
Self {
path,
label: label.into(),
content,
mtime,
}
}

pub fn label(&self) -> &str {
&self.label
}

/// Check for changes and return a formatted reminder if the file content differs.
/// Combines mtime check + content read + diff in a single call to avoid TOCTOU races.
pub fn check_and_refresh(&mut self) -> Option<String> {
let current_mtime = fs::metadata(&self.path)
.ok()
.and_then(|m| m.modified().ok());
if current_mtime == self.mtime {
return None;
}
// Stat first (above), then read content. If a concurrent write happens between
// stat and read, we capture newer content with an older mtime. The next check
// sees a newer mtime and re-reads — no writes are permanently missed.
let new_content = fs::read_to_string(&self.path).unwrap_or_default();

if new_content == self.content {
self.mtime = current_mtime;
return None;
}
let (added, removed) = line_diff(&self.content, &new_content);
self.content = new_content;
self.mtime = current_mtime;
if added.is_empty() && removed.is_empty() {
return None;
}
let added_refs: Vec<&str> = added.iter().map(String::as_str).collect();
let removed_refs: Vec<&str> = removed.iter().map(String::as_str).collect();
Some(format_file_change(&self.label, &added_refs, &removed_refs))
}
}

fn read_with_mtime(path: &Path) -> (String, Option<SystemTime>) {
let mtime = fs::metadata(path).ok().and_then(|m| m.modified().ok());
let content = fs::read_to_string(path).unwrap_or_default();
(content, mtime)
}

/// Ordered line diff that preserves duplicates.
/// Returns (lines only in new, lines only in old) maintaining original order.
pub fn line_diff(old: &str, new: &str) -> (Vec<String>, Vec<String>) {
let old_lines: Vec<&str> = old.lines().filter(|l| !l.trim().is_empty()).collect();
let new_lines: Vec<&str> = new.lines().filter(|l| !l.trim().is_empty()).collect();

let mut old_counts = std::collections::HashMap::<&str, usize>::new();
for l in &old_lines {
*old_counts.entry(l).or_default() += 1;
}
let mut new_counts = std::collections::HashMap::<&str, usize>::new();
for l in &new_lines {
*new_counts.entry(l).or_default() += 1;
}

let mut added = Vec::new();
let mut add_budget = std::collections::HashMap::<&str, usize>::new();
for (&line, &new_n) in &new_counts {
let old_n = old_counts.get(line).copied().unwrap_or(0);
if new_n > old_n {
add_budget.insert(line, new_n - old_n);
}
}
for l in &new_lines {
if let Some(n) = add_budget.get_mut(l)
&& *n > 0
{
added.push(l.to_string());
*n -= 1;
}
}

let mut removed = Vec::new();
let mut rem_budget = std::collections::HashMap::<&str, usize>::new();
for (&line, &old_n) in &old_counts {
let new_n = new_counts.get(line).copied().unwrap_or(0);
if old_n > new_n {
rem_budget.insert(line, old_n - new_n);
}
}
for l in &old_lines {
if let Some(n) = rem_budget.get_mut(l)
&& *n > 0
{
removed.push(l.to_string());
*n -= 1;
}
}
(added, removed)
}

pub fn format_file_change(label: &str, added: &[&str], removed: &[&str]) -> String {
let limit = 15;
let mut parts = vec![format!("[Config Update] {label} changed:")];
if !added.is_empty() {
parts.push(" Added:".to_string());
for line in added.iter().take(limit) {
parts.push(format!(" + {line}"));
}
if added.len() > limit {
parts.push(format!(" ... and {} more lines", added.len() - limit));
}
}
if !removed.is_empty() {
parts.push(" Removed:".to_string());
for line in removed.iter().take(limit) {
parts.push(format!(" - {line}"));
}
if removed.len() > limit {
parts.push(format!(" ... and {} more lines", removed.len() - limit));
}
}
parts.join("\n")
}
2 changes: 2 additions & 0 deletions crates/loopal-context/src/middleware/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
pub mod config_refresh;
pub mod file_snapshot;
pub mod smart_compact;
mod smart_compact_llm;
4 changes: 4 additions & 0 deletions crates/loopal-context/tests/suite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
mod compaction_pair_test;
#[path = "suite/compaction_test.rs"]
mod compaction_test;
#[path = "suite/config_refresh_test.rs"]
mod config_refresh_test;
#[path = "suite/degradation_test.rs"]
mod degradation_test;
#[path = "suite/file_snapshot_test.rs"]
mod file_snapshot_test;
#[path = "suite/fork_test.rs"]
mod fork_test;
#[path = "suite/ingestion_test.rs"]
Expand Down
151 changes: 151 additions & 0 deletions crates/loopal-context/tests/suite/config_refresh_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
use std::fs;
use std::thread::sleep;
use std::time::Duration;

use loopal_context::middleware::config_refresh::ConfigRefreshMiddleware;
use loopal_context::middleware::file_snapshot::FileSnapshot;
use loopal_message::{Message, MessageRole};
use loopal_provider_api::{Middleware, MiddlewareContext};

fn wait_for_mtime() {
sleep(Duration::from_millis(1100));
}

fn make_ctx(messages: Vec<Message>) -> MiddlewareContext {
MiddlewareContext {
messages,
system_prompt: "test".to_string(),
model: "test-model".to_string(),
total_input_tokens: 0,
total_output_tokens: 0,
max_context_tokens: 200_000,
summarization_provider: None,
}
}

#[tokio::test]
async fn no_change_no_injection() {
let dir = std::env::temp_dir().join("loopal_cr_nochange_v1");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("mem.md");
fs::write(&path, "stable content").unwrap();

let snap = FileSnapshot::load(path, "Test");
let mw = ConfigRefreshMiddleware::new(vec![snap]);
let mut ctx = make_ctx(vec![Message::user("hello")]);

mw.process(&mut ctx).await.unwrap();
assert_eq!(ctx.messages.len(), 1);

let _ = fs::remove_dir_all(&dir);
}

#[tokio::test]
async fn change_injects_reminder() {
let dir = std::env::temp_dir().join("loopal_cr_change_v1");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("mem.md");
fs::write(&path, "original").unwrap();

let snap = FileSnapshot::load(path.clone(), "Project Memory");
let mw = ConfigRefreshMiddleware::new(vec![snap]);

wait_for_mtime();
fs::write(&path, "updated line").unwrap();

let mut ctx = make_ctx(vec![Message::user("hello")]);
mw.process(&mut ctx).await.unwrap();

assert_eq!(ctx.messages.len(), 2);
let injected = &ctx.messages[1];
assert_eq!(
injected.role,
MessageRole::User,
"must be User to preserve prefix cache"
);
let text = injected.text_content();
assert!(text.contains("system-reminder"));
assert!(text.contains("Project Memory"));
assert!(text.contains("updated line"));

let _ = fs::remove_dir_all(&dir);
}

#[tokio::test]
async fn second_call_no_duplicate() {
let dir = std::env::temp_dir().join("loopal_cr_nodup_v1");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("mem.md");
fs::write(&path, "v1").unwrap();

let snap = FileSnapshot::load(path.clone(), "Test");
let mw = ConfigRefreshMiddleware::new(vec![snap]);

wait_for_mtime();
fs::write(&path, "v2").unwrap();

let mut ctx1 = make_ctx(vec![Message::user("a")]);
mw.process(&mut ctx1).await.unwrap();
assert_eq!(ctx1.messages.len(), 2);

let mut ctx2 = make_ctx(vec![Message::user("b")]);
mw.process(&mut ctx2).await.unwrap();
assert_eq!(ctx2.messages.len(), 1);

let _ = fs::remove_dir_all(&dir);
}

#[tokio::test]
async fn system_prompt_unchanged() {
let dir = std::env::temp_dir().join("loopal_cr_sysprompt_v1");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("mem.md");
fs::write(&path, "old").unwrap();

let snap = FileSnapshot::load(path.clone(), "Test");
let mw = ConfigRefreshMiddleware::new(vec![snap]);

wait_for_mtime();
fs::write(&path, "new").unwrap();

let mut ctx = make_ctx(vec![Message::user("hi")]);
let original_prompt = ctx.system_prompt.clone();
mw.process(&mut ctx).await.unwrap();
assert_eq!(ctx.system_prompt, original_prompt);

let _ = fs::remove_dir_all(&dir);
}

#[tokio::test]
async fn multiple_files_single_reminder() {
let dir = std::env::temp_dir().join("loopal_cr_multi_v1");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let p1 = dir.join("mem.md");
let p2 = dir.join("instr.md");
fs::write(&p1, "mem_old").unwrap();
fs::write(&p2, "instr_old").unwrap();

let snaps = vec![
FileSnapshot::load(p1.clone(), "Memory"),
FileSnapshot::load(p2.clone(), "Instructions"),
];
let mw = ConfigRefreshMiddleware::new(snaps);

wait_for_mtime();
fs::write(&p1, "mem_new").unwrap();
fs::write(&p2, "instr_new").unwrap();

let mut ctx = make_ctx(vec![Message::user("hi")]);
mw.process(&mut ctx).await.unwrap();
assert_eq!(ctx.messages.len(), 2);
let text = ctx.messages[1].text_content();
assert!(text.contains("Memory"));
assert!(text.contains("Instructions"));

let _ = fs::remove_dir_all(&dir);
}
Loading
Loading