Skip to content

Commit b1ac450

Browse files
committed
fix(/context): gracefully handle huge context files + ux
1 parent dd6ca31 commit b1ac450

File tree

6 files changed

+236
-76
lines changed

6 files changed

+236
-76
lines changed

crates/chat-cli/src/cli/chat/consts.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,6 @@ pub const MAX_USER_MESSAGE_SIZE: usize = 600_000;
1616
/// In tokens
1717
pub const CONTEXT_WINDOW_SIZE: usize = 200_000;
1818

19+
pub const CONTEXT_FILES_MAX_SIZE: usize = 150_000;
20+
1921
pub const MAX_CHARS: usize = TokenCounter::token_to_chars(CONTEXT_WINDOW_SIZE); // Character-based warning threshold

crates/chat-cli/src/cli/chat/context.rs

Lines changed: 74 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ use serde::{
1818
};
1919
use tracing::debug;
2020

21+
use super::consts::CONTEXT_FILES_MAX_SIZE;
2122
use super::hooks::{
2223
Hook,
2324
HookExecutor,
2425
};
26+
use super::util::drop_matched_context_files;
2527
use crate::platform::Context;
2628
use crate::util::directories;
2729

@@ -44,6 +46,8 @@ pub struct ContextConfig {
4446
pub struct ContextManager {
4547
ctx: Arc<Context>,
4648

49+
max_context_files_size: usize,
50+
4751
/// Global context configuration that applies to all profiles.
4852
pub global_config: ContextConfig,
4953

@@ -65,9 +69,16 @@ impl ContextManager {
6569
/// 2. Load the global configuration
6670
/// 3. Load the default profile configuration
6771
///
72+
/// # Arguments
73+
/// * `ctx` - The context to use
74+
/// * `max_context_files_size` - Optional maximum token size for context files. If not provided,
75+
/// defaults to `CONTEXT_FILES_MAX_SIZE`.
76+
///
6877
/// # Returns
6978
/// A Result containing the new ContextManager or an error
70-
pub async fn new(ctx: Arc<Context>) -> Result<Self> {
79+
pub async fn new(ctx: Arc<Context>, max_context_files_size: Option<usize>) -> Result<Self> {
80+
let max_context_files_size = max_context_files_size.unwrap_or(CONTEXT_FILES_MAX_SIZE);
81+
7182
let profiles_dir = directories::chat_profiles_dir(&ctx)?;
7283

7384
ctx.fs().create_dir_all(&profiles_dir).await?;
@@ -78,6 +89,7 @@ impl ContextManager {
7889

7990
Ok(Self {
8091
ctx,
92+
max_context_files_size,
8193
global_config,
8294
current_profile,
8395
profile_config,
@@ -136,7 +148,7 @@ impl ContextManager {
136148
for path in &paths {
137149
// We're using a temporary context_files vector just for validation
138150
// Pass is_validation=true to ensure we error if glob patterns don't match any files
139-
match process_path(&self.ctx, path, &mut context_files, false, true).await {
151+
match process_path(&self.ctx, path, &mut context_files, true).await {
140152
Ok(_) => {}, // Path is valid
141153
Err(e) => return Err(eyre!("Invalid path '{}': {}. Use --force to add anyway.", path, e)),
142154
}
@@ -425,17 +437,15 @@ impl ContextManager {
425437
/// 3. Reads the content of each file
426438
/// 4. Returns a vector of (filename, content) pairs
427439
///
428-
/// # Arguments
429-
/// * `force` - If true, include paths that don't exist yet
430440
///
431441
/// # Returns
432442
/// A Result containing a vector of (filename, content) pairs or an error
433-
pub async fn get_context_files(&self, force: bool) -> Result<Vec<(String, String)>> {
443+
pub async fn get_context_files(&self) -> Result<Vec<(String, String)>> {
434444
let mut context_files = Vec::new();
435445

436-
self.collect_context_files(&self.global_config.paths, &mut context_files, force)
446+
self.collect_context_files(&self.global_config.paths, &mut context_files)
437447
.await?;
438-
self.collect_context_files(&self.profile_config.paths, &mut context_files, force)
448+
self.collect_context_files(&self.profile_config.paths, &mut context_files)
439449
.await?;
440450

441451
context_files.sort_by(|a, b| a.0.cmp(&b.0));
@@ -444,41 +454,49 @@ impl ContextManager {
444454
Ok(context_files)
445455
}
446456

447-
pub async fn get_context_files_by_path(&self, force: bool, path: &str) -> Result<Vec<(String, String)>> {
457+
pub async fn get_context_files_by_path(&self, path: &str) -> Result<Vec<(String, String)>> {
448458
let mut context_files = Vec::new();
449-
process_path(&self.ctx, path, &mut context_files, force, true).await?;
459+
process_path(&self.ctx, path, &mut context_files, true).await?;
450460
Ok(context_files)
451461
}
452462

453463
/// Get all context files from the global configuration.
454-
pub async fn get_global_context_files(&self, force: bool) -> Result<Vec<(String, String)>> {
464+
pub async fn get_global_context_files(&self) -> Result<Vec<(String, String)>> {
455465
let mut context_files = Vec::new();
456466

457-
self.collect_context_files(&self.global_config.paths, &mut context_files, force)
467+
self.collect_context_files(&self.global_config.paths, &mut context_files)
458468
.await?;
459469

460470
Ok(context_files)
461471
}
462472

463473
/// Get all context files from the current profile configuration.
464-
pub async fn get_current_profile_context_files(&self, force: bool) -> Result<Vec<(String, String)>> {
474+
pub async fn get_current_profile_context_files(&self) -> Result<Vec<(String, String)>> {
465475
let mut context_files = Vec::new();
466476

467-
self.collect_context_files(&self.profile_config.paths, &mut context_files, force)
477+
self.collect_context_files(&self.profile_config.paths, &mut context_files)
468478
.await?;
469479

470480
Ok(context_files)
471481
}
472482

473-
async fn collect_context_files(
474-
&self,
475-
paths: &[String],
476-
context_files: &mut Vec<(String, String)>,
477-
force: bool,
478-
) -> Result<()> {
483+
/// Collects context files and optionally drops files if the total size exceeds the limit.
484+
/// Returns (files_to_use, dropped_files)
485+
pub async fn collect_context_files_with_limit(&self) -> Result<(Vec<(String, String)>, Vec<(String, String)>)> {
486+
let mut files = self.get_context_files().await?;
487+
488+
let dropped_files = drop_matched_context_files(&mut files, self.max_context_files_size).unwrap_or_default();
489+
490+
// remove dropped files from files
491+
files.retain(|file| !dropped_files.iter().any(|dropped| dropped.0 == file.0));
492+
493+
Ok((files, dropped_files))
494+
}
495+
496+
async fn collect_context_files(&self, paths: &[String], context_files: &mut Vec<(String, String)>) -> Result<()> {
479497
for path in paths {
480498
// Use is_validation=false to handle non-matching globs gracefully
481-
process_path(&self.ctx, path, context_files, force, false).await?;
499+
process_path(&self.ctx, path, context_files, false).await?;
482500
}
483501
Ok(())
484502
}
@@ -647,7 +665,6 @@ async fn load_profile_config(ctx: &Context, profile_name: &str) -> Result<Contex
647665
/// # Arguments
648666
/// * `path` - The path to process
649667
/// * `context_files` - The collection to add files to
650-
/// * `force` - If true, include paths that don't exist yet
651668
/// * `is_validation` - If true, error when glob patterns don't match; if false, silently skip
652669
///
653670
/// # Returns
@@ -656,7 +673,6 @@ async fn process_path(
656673
ctx: &Context,
657674
path: &str,
658675
context_files: &mut Vec<(String, String)>,
659-
force: bool,
660676
is_validation: bool,
661677
) -> Result<()> {
662678
// Expand ~ to home directory
@@ -703,7 +719,7 @@ async fn process_path(
703719
}
704720
}
705721

706-
if !found_any && !force && is_validation {
722+
if !found_any && is_validation {
707723
// When validating paths (e.g., for /context add), error if no files match
708724
return Err(eyre!("No files found matching glob pattern '{}'", full_path));
709725
}
@@ -728,16 +744,10 @@ async fn process_path(
728744
}
729745
}
730746
}
731-
} else if !force && is_validation {
747+
} else if is_validation {
732748
// When validating paths (e.g., for /context add), error if the path doesn't exist
733749
return Err(eyre!("Path '{}' does not exist", full_path));
734-
} else if force {
735-
// When using --force, we'll add the path even though it doesn't exist
736-
// This allows users to add paths that will exist in the future
737-
context_files.push((full_path.clone(), format!("(Path '{}' does not exist yet)", full_path)));
738750
}
739-
// When just showing expanded files (e.g., for /context show --expand),
740-
// silently skip non-existent paths if is_validation is false
741751
}
742752

743753
Ok(())
@@ -796,9 +806,10 @@ mod tests {
796806
use super::*;
797807

798808
// Helper function to create a test ContextManager with Context
799-
pub async fn create_test_context_manager() -> Result<ContextManager> {
809+
pub async fn create_test_context_manager(context_file_size: Option<usize>) -> Result<ContextManager> {
810+
let context_file_size = context_file_size.unwrap_or(CONTEXT_FILES_MAX_SIZE);
800811
let ctx = Context::builder().with_test_home().await.unwrap().build_fake();
801-
let manager = ContextManager::new(ctx).await?;
812+
let manager = ContextManager::new(ctx, Some(context_file_size)).await?;
802813
Ok(manager)
803814
}
804815

@@ -823,7 +834,7 @@ mod tests {
823834

824835
#[tokio::test]
825836
async fn test_profile_ops() -> Result<()> {
826-
let mut manager = create_test_context_manager().await?;
837+
let mut manager = create_test_context_manager(None).await?;
827838
let ctx = Arc::clone(&manager.ctx);
828839

829840
assert_eq!(manager.current_profile, "default");
@@ -860,28 +871,43 @@ mod tests {
860871
Ok(())
861872
}
862873

874+
#[tokio::test]
875+
async fn test_collect_exceeds_limit() -> Result<()> {
876+
let mut manager = create_test_context_manager(Some(2)).await?;
877+
let ctx: Arc<Context> = Arc::clone(&manager.ctx);
878+
879+
ctx.fs().create_dir_all("test").await?;
880+
ctx.fs().write("test/to-include.md", "ha").await?;
881+
ctx.fs()
882+
.write("test/to-drop.md", "long content that exceed limit")
883+
.await?;
884+
manager.add_paths(vec!["test/*.md".to_string()], false, false).await?;
885+
886+
let (used, dropped) = manager.collect_context_files_with_limit().await.unwrap();
887+
888+
assert!(used.len() + dropped.len() == 2);
889+
assert!(used.len() == 1);
890+
assert!(dropped.len() == 1);
891+
Ok(())
892+
}
893+
863894
#[tokio::test]
864895
async fn test_path_ops() -> Result<()> {
865-
let mut manager = create_test_context_manager().await?;
866-
let ctx = Arc::clone(&manager.ctx);
896+
let mut manager = create_test_context_manager(None).await?;
897+
let ctx: Arc<Context> = Arc::clone(&manager.ctx);
867898

868899
// Create some test files for matching.
869900
ctx.fs().create_dir_all("test").await?;
870901
ctx.fs().write("test/p1.md", "p1").await?;
871902
ctx.fs().write("test/p2.md", "p2").await?;
872903

873904
assert!(
874-
manager.get_context_files(false).await?.is_empty(),
905+
manager.get_context_files().await?.is_empty(),
875906
"no files should be returned for an empty profile when force is false"
876907
);
877-
assert_eq!(
878-
manager.get_context_files(true).await?.len(),
879-
2,
880-
"default non-glob global files should be included when force is true"
881-
);
882908

883909
manager.add_paths(vec!["test/*.md".to_string()], false, false).await?;
884-
let files = manager.get_context_files(false).await?;
910+
let files = manager.get_context_files().await?;
885911
assert!(files[0].0.ends_with("p1.md"));
886912
assert_eq!(files[0].1, "p1");
887913
assert!(files[1].0.ends_with("p2.md"));
@@ -900,7 +926,7 @@ mod tests {
900926

901927
#[tokio::test]
902928
async fn test_add_hook() -> Result<()> {
903-
let mut manager = create_test_context_manager().await?;
929+
let mut manager = create_test_context_manager(None).await?;
904930
let hook = Hook::new_inline_hook(HookTrigger::ConversationStart, "echo test".to_string());
905931

906932
// Test adding hook to profile config
@@ -919,7 +945,7 @@ mod tests {
919945

920946
#[tokio::test]
921947
async fn test_remove_hook() -> Result<()> {
922-
let mut manager = create_test_context_manager().await?;
948+
let mut manager = create_test_context_manager(None).await?;
923949
let hook = Hook::new_inline_hook(HookTrigger::ConversationStart, "echo test".to_string());
924950

925951
manager.add_hook("test_hook".to_string(), hook, false).await?;
@@ -936,7 +962,7 @@ mod tests {
936962

937963
#[tokio::test]
938964
async fn test_set_hook_disabled() -> Result<()> {
939-
let mut manager = create_test_context_manager().await?;
965+
let mut manager = create_test_context_manager(None).await?;
940966
let hook = Hook::new_inline_hook(HookTrigger::ConversationStart, "echo test".to_string());
941967

942968
manager.add_hook("test_hook".to_string(), hook, false).await?;
@@ -957,7 +983,7 @@ mod tests {
957983

958984
#[tokio::test]
959985
async fn test_set_all_hooks_disabled() -> Result<()> {
960-
let mut manager = create_test_context_manager().await?;
986+
let mut manager = create_test_context_manager(None).await?;
961987
let hook1 = Hook::new_inline_hook(HookTrigger::ConversationStart, "echo test".to_string());
962988
let hook2 = Hook::new_inline_hook(HookTrigger::ConversationStart, "echo test".to_string());
963989

@@ -977,7 +1003,7 @@ mod tests {
9771003

9781004
#[tokio::test]
9791005
async fn test_run_hooks() -> Result<()> {
980-
let mut manager = create_test_context_manager().await?;
1006+
let mut manager = create_test_context_manager(None).await?;
9811007
let hook1 = Hook::new_inline_hook(HookTrigger::ConversationStart, "echo test".to_string());
9821008
let hook2 = Hook::new_inline_hook(HookTrigger::ConversationStart, "echo test".to_string());
9831009

@@ -993,7 +1019,7 @@ mod tests {
9931019

9941020
#[tokio::test]
9951021
async fn test_hooks_across_profiles() -> Result<()> {
996-
let mut manager = create_test_context_manager().await?;
1022+
let mut manager = create_test_context_manager(None).await?;
9971023
let hook1 = Hook::new_inline_hook(HookTrigger::ConversationStart, "echo test".to_string());
9981024
let hook2 = Hook::new_inline_hook(HookTrigger::ConversationStart, "echo test".to_string());
9991025

0 commit comments

Comments
 (0)