diff --git a/src/config/mod.rs b/src/config/mod.rs index be06846..c503094 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -672,6 +672,8 @@ bitflags::bitflags! { const RAG = 1 << 3; const AGENT = 1 << 4; const FUNCTION_CALLING = 1 << 5; + const AUTO_CONTINUE = 1 << 6; + const SKILLS_ENABLED = 1 << 7; } } diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 93ab13a..bc55ba2 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -374,9 +374,29 @@ impl RequestContext { if self.app.config.function_calling_support { flags |= StateFlags::FUNCTION_CALLING; } + if self.auto_continue_config().enabled { + flags |= StateFlags::AUTO_CONTINUE; + } + if self.resolved_skills_enabled() { + flags |= StateFlags::SKILLS_ENABLED; + } flags } + pub fn resolved_skills_enabled(&self) -> bool { + if let Some(agent) = &self.agent + && let Some(value) = agent.skills_enabled() + { + return value; + } + let app = &self.app.config; + self.session + .as_ref() + .and_then(|s| s.skills_enabled()) + .or_else(|| self.role.as_ref().and_then(|r| r.skills_enabled())) + .unwrap_or(app.skills_enabled) + } + pub fn messages_file(&self) -> PathBuf { match &self.agent { None => match env::var(get_env_name("messages_file")) { @@ -453,6 +473,22 @@ impl RequestContext { } } + pub fn todo_info(&self) -> Result { + if !self.auto_continue_config().enabled { + bail!( + "Auto-continuation is disabled. Enable it by setting `auto_continue: true` in your config or running `.set auto_continue true`." + ); + } + + if self.todo_list.is_empty() { + return Ok("No todos in the running list.\n".to_string()); + } + + let mut out = self.todo_list.render_for_model(); + out.push('\n'); + Ok(out) + } + pub fn tools_info(&self) -> Result { if !self.app.config.function_calling_support { bail!( @@ -2239,11 +2275,6 @@ impl RequestContext { super::map_completion_values(values) } ".macro" => super::map_completion_values(paths::list_macros()), - ".skill" => super::map_completion_values(vec![ - "loaded".to_string(), - "load".to_string(), - "unload".to_string(), - ]), ".starter" => match &self.agent { Some(agent) => agent .conversation_starters() @@ -4151,6 +4182,43 @@ mod tests { assert!(ctx.state().contains(StateFlags::FUNCTION_CALLING)); } + #[test] + fn state_includes_skills_enabled_when_app_enables_it() { + let ctx = create_test_ctx(); + + assert!(ctx.state().contains(StateFlags::SKILLS_ENABLED)); + } + + #[test] + fn state_omits_skills_enabled_when_app_disables_it() { + let mut ctx = create_test_ctx(); + + ctx.update_app_config(|app| app.skills_enabled = false); + + assert!(!ctx.state().contains(StateFlags::SKILLS_ENABLED)); + } + + #[test] + fn state_skills_enabled_respects_session_override() { + let mut ctx = create_test_ctx(); + let mut session = Session::default(); + session.set_skills_enabled(Some(false)); + + ctx.session = Some(session); + + assert!(!ctx.state().contains(StateFlags::SKILLS_ENABLED)); + } + + #[test] + fn state_skills_enabled_respects_role_override() { + let mut ctx = create_test_ctx(); + let role = Role::new("r", "---\nskills_enabled: false\n---\nbody"); + + ctx.role = Some(role); + + assert!(!ctx.state().contains(StateFlags::SKILLS_ENABLED)); + } + #[test] fn state_omits_function_calling_when_app_disables_it() { let app_state = { @@ -4200,6 +4268,61 @@ mod tests { assert!(state.contains(StateFlags::SESSION_EMPTY)); } + #[test] + fn todo_info_errors_when_auto_continue_disabled() { + let ctx = create_test_ctx(); + let err = ctx.todo_info().unwrap_err(); + + let msg = err.to_string(); + + assert!( + msg.contains("Auto-continuation is disabled"), + "expected error to mention auto-continuation, got: {msg}" + ); + } + + #[test] + fn todo_info_returns_empty_message_when_list_is_empty() { + let mut ctx = create_test_ctx(); + + ctx.update_app_config(|app| app.auto_continue = true); + + let info = ctx.todo_info().unwrap(); + assert!( + info.contains("No todos in the running list"), + "expected 'No todos' message, got: {info}" + ); + } + + #[test] + fn todo_info_renders_running_list() { + let mut ctx = create_test_ctx(); + ctx.update_app_config(|app| app.auto_continue = true); + ctx.init_todo_list("Map Labs"); + ctx.add_todo("Discover columns"); + ctx.add_todo("Write report"); + + ctx.mark_todo_done(1); + + let info = ctx.todo_info().unwrap(); + assert!( + info.contains("Goal: Map Labs"), + "expected goal in output, got: {info}" + ); + assert!( + info.contains("Progress: 1/2 completed"), + "expected progress line, got: {info}" + ); + assert!( + info.contains("Discover columns"), + "expected first task, got: {info}" + ); + assert!( + info.contains("Write report"), + "expected second task, got: {info}" + ); + } + #[test] fn tools_info_returns_message_when_no_tools_enabled() { let ctx = create_test_ctx(); diff --git a/src/repl/mod.rs b/src/repl/mod.rs index 0dd4c26..07aeab7 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -49,7 +49,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {" 4. Continue with the next pending item now. Call tools immediately." }; -static REPL_COMMANDS: LazyLock<[ReplCommand; 45]> = LazyLock::new(|| { +static REPL_COMMANDS: LazyLock<[ReplCommand; 49]> = LazyLock::new(|| { [ ReplCommand::new(".help", "Show this help guide", AssertState::pass()), ReplCommand::new(".info", "Show system info", AssertState::pass()), @@ -168,6 +168,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 45]> = LazyLock::new(|| { "Clear the todo list and stop auto-continuation", AssertState::pass(), ), + ReplCommand::new( + ".info todo", + "Show the current todo list driving auto-continuation", + AssertState::True(StateFlags::AUTO_CONTINUE), + ), ReplCommand::new( ".rag", "Initialize or access RAG", @@ -201,13 +206,28 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 45]> = LazyLock::new(|| { ReplCommand::new(".macro", "Execute a macro", AssertState::pass()), ReplCommand::new( ".skill", - "List, load, unload, or create skills", - AssertState::pass(), + "Create a new skill", + AssertState::True(StateFlags::SKILLS_ENABLED), + ), + ReplCommand::new( + ".skill load", + "Load a skill into the current context", + AssertState::True(StateFlags::SKILLS_ENABLED), + ), + ReplCommand::new( + ".skill loaded", + "List currently-loaded skills", + AssertState::True(StateFlags::SKILLS_ENABLED), + ), + ReplCommand::new( + ".skill unload", + "Unload a skill from the current context", + AssertState::True(StateFlags::SKILLS_ENABLED), ), ReplCommand::new( ".edit skill", "Modify an existing skill by name", - AssertState::pass(), + AssertState::True(StateFlags::SKILLS_ENABLED), ), ReplCommand::new( ".file", @@ -489,6 +509,10 @@ pub async fn run_repl_command( let info = ctx.tools_info()?; print!("{info}"); } + Some("todo") => { + let info = ctx.todo_info()?; + print!("{info}"); + } Some(_) => unknown_command()?, None => { let app = Arc::clone(&ctx.app.config); @@ -1391,8 +1415,8 @@ mod tests { } #[test] - fn repl_commands_has_45_entries() { - assert_eq!(REPL_COMMANDS.len(), 45); + fn repl_commands_has_49_entries() { + assert_eq!(REPL_COMMANDS.len(), 49); } #[test]