feat: add auto-continue support to all contexts

This commit is contained in:
2026-05-08 12:02:10 -06:00
parent 462f136596
commit 70a251a7e2
11 changed files with 397 additions and 60 deletions
+5 -3
View File
@@ -17,9 +17,11 @@ agent_session: null # Set a session to use when starting the agent.
name: <agent-name> # Name of the agent, used in the UI and logs name: <agent-name> # Name of the agent, used in the UI and logs
description: <description> # Description of the agent, used in the UI description: <description> # Description of the agent, used in the UI
version: 1 # Version of the agent version: 1 # Version of the agent
# Todo System & Auto-Continuation # Auto-Continue (Todo System)
# These settings help smaller models handle multi-step tasks more reliably. # The auto-continue system provides built-in task tracking for improved reliability.
# See docs/TODO-SYSTEM.md for detailed documentation. # When enabled, the model can create todo lists and the system will automatically
# prompt it to continue when incomplete tasks remain.
# See the [Todo System documentation](https://github.com/Dark-Alex-17/loki/wiki/TODO-System) for more information
auto_continue: false # Enable automatic continuation when incomplete todos remain auto_continue: false # Enable automatic continuation when incomplete todos remain
max_auto_continues: 10 # Maximum number of automatic continuations before stopping max_auto_continues: 10 # Maximum number of automatic continuations before stopping
inject_todo_instructions: true # Inject the default todo tool usage instructions into the agent's system prompt inject_todo_instructions: true # Inject the default todo tool usage instructions into the agent's system prompt
+10
View File
@@ -81,6 +81,16 @@ mapping_mcp_servers: # Alias for an MCP server or set of servers
git: github,gitmcp git: github,gitmcp
enabled_mcp_servers: null # Which MCP servers to enable by default (e.g. 'github,slack,ddg-search') enabled_mcp_servers: null # Which MCP servers to enable by default (e.g. 'github,slack,ddg-search')
# ---- Auto-Continue (Todo System) ----
# The auto-continue system provides built-in task tracking for improved reliability.
# When enabled, the model can create todo lists and the system will automatically
# prompt it to continue when incomplete tasks remain.
# See the [Todo System documentation](https://github.com/Dark-Alex-17/loki/wiki/TODO-System) for more information
auto_continue: false # Enable automatic continuation when incomplete todos remain (default: false)
max_auto_continues: 10 # Maximum number of automatic continuations before stopping (default: 10)
inject_todo_instructions: true # Inject default todo usage instructions into the system prompt (default: true)
continuation_prompt: null # Custom prompt used when auto-continuing. If null, uses built-in default
# ---- Session ---- # ---- Session ----
# See the [Session documentation](./docs/SESSIONS.md) for more information # See the [Session documentation](./docs/SESSIONS.md) for more information
save_session: null # Controls the persistence of the session. If true, auto save; if false, don't auto-save save; if null, ask the user what to do save_session: null # Controls the persistence of the session. If true, auto save; if false, don't auto-save save; if null, ask the user what to do
+14 -1
View File
@@ -1,5 +1,9 @@
--- ---
# Everything in this section is optional ############################################
## Everything in this section is optional ##
############################################
# Role Configuration
name: <role-name> # The name of the role name: <role-name> # The name of the role
model: openai:gpt-4o # The model to use for this role model: openai:gpt-4o # The model to use for this role
temperature: 0.2 # The temperature to use for this role when querying the model temperature: 0.2 # The temperature to use for this role when querying the model
@@ -8,5 +12,14 @@ enabled_tools: fs_ls,fs_cat # A comma-separated list of tools to enabl
enabled_mcp_servers: github,gitmcp # A comma-separated list of MCP servers to enable for this role enabled_mcp_servers: github,gitmcp # A comma-separated list of MCP servers to enable for this role
prompt: null # A custom prompt to use for this role that will immediately query prompt: null # A custom prompt to use for this role that will immediately query
# the model for output instead of using the instructions below # the model for output instead of using the instructions below
# Auto-Continue (Todo System)
# The auto-continue system provides built-in task tracking for improved reliability.
# When enabled, the model can create todo lists and the system will automatically
# prompt it to continue when incomplete tasks remain.
# See the [Todo System documentation](https://github.com/Dark-Alex-17/loki/wiki/TODO-System) for more information
auto_continue: false # Enable automatic continuation when incomplete todos remain (default: false)
max_auto_continues: 10 # Maximum number of automatic continuations before stopping (default: 10)
inject_todo_instructions: true # Inject default todo tool usage instructions into the system prompt (default: true)
continuation_prompt: null # Custom prompt used when auto-continuing. If null, uses built-in default
--- ---
You are an expert at doing things. This is where you write the instructions for the role. You are an expert at doing things. This is where you write the instructions for the role.
+8 -12
View File
@@ -415,6 +415,14 @@ impl Agent {
self.config.max_auto_continues self.config.max_auto_continues
} }
pub fn inject_todo_instructions(&self) -> bool {
self.config.inject_todo_instructions
}
pub fn continuation_prompt_value(&self) -> Option<String> {
self.config.continuation_prompt.clone()
}
pub fn can_spawn_agents(&self) -> bool { pub fn can_spawn_agents(&self) -> bool {
self.config.can_spawn_agents self.config.can_spawn_agents
} }
@@ -439,18 +447,6 @@ impl Agent {
self.config.escalation_timeout self.config.escalation_timeout
} }
pub fn continuation_prompt(&self) -> String {
self.config.continuation_prompt.clone().unwrap_or_else(|| {
formatdoc! {"
[SYSTEM REMINDER - TODO CONTINUATION]
You have incomplete tasks. Rules:
1. BEFORE marking a todo done: verify the work compiles/works. No premature completion.
2. If a todo is broad (e.g. \"implement X and implement Y\"): break it into specific subtasks FIRST using todo__add, then work on those.\n\
3. Each todo should be atomic and be \"single responsibility\" - completable in one focused action.
4. Continue with the next pending item now. Call tools immediately."}
})
}
pub fn compression_threshold(&self) -> Option<usize> { pub fn compression_threshold(&self) -> Option<usize> {
self.config.compression_threshold self.config.compression_threshold
} }
+15
View File
@@ -39,6 +39,11 @@ pub struct AppConfig {
pub mapping_mcp_servers: IndexMap<String, String>, pub mapping_mcp_servers: IndexMap<String, String>,
pub enabled_mcp_servers: Option<String>, pub enabled_mcp_servers: Option<String>,
pub auto_continue: bool,
pub max_auto_continues: usize,
pub inject_todo_instructions: bool,
pub continuation_prompt: Option<String>,
pub repl_prelude: Option<String>, pub repl_prelude: Option<String>,
pub cmd_prelude: Option<String>, pub cmd_prelude: Option<String>,
pub agent_session: Option<String>, pub agent_session: Option<String>,
@@ -95,6 +100,11 @@ impl Default for AppConfig {
mapping_mcp_servers: Default::default(), mapping_mcp_servers: Default::default(),
enabled_mcp_servers: None, enabled_mcp_servers: None,
auto_continue: false,
max_auto_continues: 10,
inject_todo_instructions: true,
continuation_prompt: None,
repl_prelude: None, repl_prelude: None,
cmd_prelude: None, cmd_prelude: None,
agent_session: None, agent_session: None,
@@ -152,6 +162,11 @@ impl AppConfig {
mapping_mcp_servers: config.mapping_mcp_servers, mapping_mcp_servers: config.mapping_mcp_servers,
enabled_mcp_servers: config.enabled_mcp_servers, enabled_mcp_servers: config.enabled_mcp_servers,
auto_continue: config.auto_continue,
max_auto_continues: config.max_auto_continues,
inject_todo_instructions: config.inject_todo_instructions,
continuation_prompt: config.continuation_prompt,
repl_prelude: config.repl_prelude, repl_prelude: config.repl_prelude,
cmd_prelude: config.cmd_prelude, cmd_prelude: config.cmd_prelude,
agent_session: config.agent_session, agent_session: config.agent_session,
+10
View File
@@ -121,6 +121,11 @@ pub struct Config {
pub mapping_mcp_servers: IndexMap<String, String>, pub mapping_mcp_servers: IndexMap<String, String>,
pub enabled_mcp_servers: Option<String>, pub enabled_mcp_servers: Option<String>,
pub auto_continue: bool,
pub max_auto_continues: usize,
pub inject_todo_instructions: bool,
pub continuation_prompt: Option<String>,
pub repl_prelude: Option<String>, pub repl_prelude: Option<String>,
pub cmd_prelude: Option<String>, pub cmd_prelude: Option<String>,
pub agent_session: Option<String>, pub agent_session: Option<String>,
@@ -177,6 +182,11 @@ impl Default for Config {
mapping_mcp_servers: Default::default(), mapping_mcp_servers: Default::default(),
enabled_mcp_servers: None, enabled_mcp_servers: None,
auto_continue: false,
max_auto_continues: 10,
inject_todo_instructions: true,
continuation_prompt: None,
repl_prelude: None, repl_prelude: None,
cmd_prelude: None, cmd_prelude: None,
agent_session: None, agent_session: None,
+153 -7
View File
@@ -1,4 +1,3 @@
use super::MessageContentToolCalls;
use super::rag_cache::{RagCache, RagKey}; use super::rag_cache::{RagCache, RagKey};
use super::session::Session; use super::session::Session;
use super::todo::TodoList; use super::todo::TodoList;
@@ -9,6 +8,7 @@ use super::{
SUMMARIZATION_PROMPT, SUMMARY_CONTEXT_PROMPT, StateFlags, TEMP_ROLE_NAME, TEMP_SESSION_NAME, SUMMARIZATION_PROMPT, SUMMARY_CONTEXT_PROMPT, StateFlags, TEMP_ROLE_NAME, TEMP_SESSION_NAME,
WorkingMode, ensure_parent_exists, list_agents, paths, WorkingMode, ensure_parent_exists, list_agents, paths,
}; };
use super::{MessageContentToolCalls, prompts};
use crate::client::{Model, ModelType, list_models}; use crate::client::{Model, ModelType, list_models};
use crate::function::{ use crate::function::{
FunctionDeclaration, Functions, ToolCallTracker, ToolResult, FunctionDeclaration, Functions, ToolCallTracker, ToolResult,
@@ -38,6 +38,13 @@ use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
pub struct AutoContinueConfig {
pub enabled: bool,
pub max_continues: usize,
pub inject_instructions: bool,
pub continuation_prompt: Option<String>,
}
pub struct RequestContext { pub struct RequestContext {
pub app: Arc<AppState>, pub app: Arc<AppState>,
@@ -523,7 +530,7 @@ impl RequestContext {
} }
pub fn extract_role(&self, app: &AppConfig) -> Role { pub fn extract_role(&self, app: &AppConfig) -> Role {
if let Some(session) = self.session.as_ref() { let mut role = if let Some(session) = self.session.as_ref() {
session.to_role() session.to_role()
} else if let Some(agent) = self.agent.as_ref() { } else if let Some(agent) = self.agent.as_ref() {
agent.to_role() agent.to_role()
@@ -539,6 +546,65 @@ impl RequestContext {
app.enabled_mcp_servers.clone(), app.enabled_mcp_servers.clone(),
); );
role role
};
if self.agent.is_none() {
let config = self.auto_continue_config();
if config.enabled && config.inject_instructions {
role.append_to_prompt(prompts::DEFAULT_TODO_INSTRUCTIONS);
}
}
role
}
pub fn auto_continue_config(&self) -> AutoContinueConfig {
if let Some(agent) = &self.agent {
return AutoContinueConfig {
enabled: agent.auto_continue_enabled(),
max_continues: agent.max_auto_continues(),
inject_instructions: agent.inject_todo_instructions(),
continuation_prompt: agent.continuation_prompt_value(),
};
}
let app = &self.app.config;
let enabled = self
.session
.as_ref()
.and_then(|s| s.auto_continue())
.or_else(|| self.role.as_ref().and_then(|r| r.auto_continue()))
.unwrap_or(app.auto_continue);
let max = self
.session
.as_ref()
.and_then(|s| s.max_auto_continues())
.or_else(|| self.role.as_ref().and_then(|r| r.max_auto_continues()))
.unwrap_or(app.max_auto_continues);
let inject = self
.session
.as_ref()
.and_then(|s| s.inject_todo_instructions())
.or_else(|| {
self.role
.as_ref()
.and_then(|r| r.inject_todo_instructions())
})
.unwrap_or(app.inject_todo_instructions);
let prompt = self
.session
.as_ref()
.and_then(|s| s.continuation_prompt().map(|v| v.to_string()))
.or_else(|| {
self.role
.as_ref()
.and_then(|r| r.continuation_prompt().map(|v| v.to_string()))
})
.or_else(|| app.continuation_prompt.clone());
AutoContinueConfig {
enabled,
max_continues: max,
inject_instructions: inject,
continuation_prompt: prompt,
} }
} }
@@ -747,6 +813,8 @@ impl RequestContext {
app.function_calling_support.to_string(), app.function_calling_support.to_string(),
), ),
("mcp_server_support", app.mcp_server_support.to_string()), ("mcp_server_support", app.mcp_server_support.to_string()),
("auto_continue", app.auto_continue.to_string()),
("max_auto_continues", app.max_auto_continues.to_string()),
("stream", app.stream.to_string()), ("stream", app.stream.to_string()),
("save", app.save.to_string()), ("save", app.save.to_string()),
("keybindings", app.keybindings.clone()), ("keybindings", app.keybindings.clone()),
@@ -1402,12 +1470,24 @@ impl RequestContext {
} }
pub async fn update(&mut self, data: &str, abort_signal: AbortSignal) -> Result<()> { pub async fn update(&mut self, data: &str, abort_signal: AbortSignal) -> Result<()> {
let parts: Vec<&str> = data.split_whitespace().collect(); let (key, raw_value) = match data.split_once(char::is_whitespace) {
if parts.len() != 2 { Some((k, v)) => (k, v.trim()),
None => bail!("Usage: .set <key> <value>. If value is null, unset key."),
};
if raw_value.is_empty() {
bail!("Usage: .set <key> <value>. If value is null, unset key."); bail!("Usage: .set <key> <value>. If value is null, unset key.");
} }
let key = parts[0];
let value = parts[1]; let value = match key {
"continuation_prompt" => raw_value,
_ => {
if raw_value.contains(char::is_whitespace) {
bail!("Usage: .set <key> <value>. If value is null, unset key.");
}
raw_value
}
};
match key { match key {
"temperature" => { "temperature" => {
let value = super::parse_value(value)?; let value = super::parse_value(value)?;
@@ -1522,6 +1602,49 @@ impl RequestContext {
let value = value.parse().with_context(|| "Invalid value")?; let value = value.parse().with_context(|| "Invalid value")?;
self.update_app_config(|app| app.highlight = value); self.update_app_config(|app| app.highlight = value);
} }
"auto_continue" => {
let value: bool = value.parse().with_context(|| "Invalid value")?;
if value && !self.app.config.function_calling_support {
bail!(
"Cannot enable auto_continue: function calling is disabled. Set 'function_calling_support: true' first."
);
}
if let Some(session) = self.session.as_mut() {
session.set_auto_continue(Some(value));
} else {
self.update_app_config(|app| app.auto_continue = value);
}
if value
&& self.app.config.function_calling_support
&& !self.tool_scope.functions.contains("todo__init")
{
self.tool_scope.functions.append_todo_functions();
}
}
"max_auto_continues" => {
let value: usize = value.parse().with_context(|| "Invalid value")?;
if let Some(session) = self.session.as_mut() {
session.set_max_auto_continues(Some(value));
} else {
self.update_app_config(|app| app.max_auto_continues = value);
}
}
"inject_todo_instructions" => {
let value: bool = value.parse().with_context(|| "Invalid value")?;
if let Some(session) = self.session.as_mut() {
session.set_inject_todo_instructions(Some(value));
} else {
self.update_app_config(|app| app.inject_todo_instructions = value);
}
}
"continuation_prompt" => {
let value: Option<String> = super::parse_value(value)?;
if let Some(session) = self.session.as_mut() {
session.set_continuation_prompt(value);
} else {
self.update_app_config(|app| app.continuation_prompt = value);
}
}
_ => bail!("Unknown key '{key}'"), _ => bail!("Unknown key '{key}'"),
} }
Ok(()) Ok(())
@@ -1603,10 +1726,14 @@ impl RequestContext {
}, },
".set" => { ".set" => {
let mut values = vec![ let mut values = vec![
"auto_continue",
"continuation_prompt",
"temperature", "temperature",
"top_p", "top_p",
"enabled_tools", "enabled_tools",
"enabled_mcp_servers", "enabled_mcp_servers",
"inject_todo_instructions",
"max_auto_continues",
"save_session", "save_session",
"compression_threshold", "compression_threshold",
"rag_reranker_model", "rag_reranker_model",
@@ -1721,6 +1848,19 @@ impl RequestContext {
.map(|v| v.id()) .map(|v| v.id())
.collect(), .collect(),
"highlight" => super::complete_bool(app.highlight), "highlight" => super::complete_bool(app.highlight),
"auto_continue" => {
let config = self.auto_continue_config();
super::complete_bool(config.enabled)
}
"max_auto_continues" => {
let config = self.auto_continue_config();
vec![config.max_continues.to_string()]
}
"inject_todo_instructions" => {
let config = self.auto_continue_config();
super::complete_bool(config.inject_instructions)
}
"continuation_prompt" => vec!["null".to_string()],
_ => vec![], _ => vec![],
}; };
values = candidates.into_iter().map(|v| (v, None)).collect(); values = candidates.into_iter().map(|v| (v, None)).collect();
@@ -1810,6 +1950,12 @@ impl RequestContext {
if self.working_mode.is_repl() { if self.working_mode.is_repl() {
functions.append_user_interaction_functions(); functions.append_user_interaction_functions();
} }
if self.agent.is_none()
&& app.function_calling_support
&& self.auto_continue_config().enabled
{
functions.append_todo_functions();
}
if !mcp_runtime.is_empty() { if !mcp_runtime.is_empty() {
functions.append_mcp_meta_functions(mcp_runtime.server_names()); functions.append_mcp_meta_functions(mcp_runtime.server_names());
} }
@@ -2196,7 +2342,7 @@ impl RequestContext {
.clone() .clone()
.unwrap_or_else(|| SUMMARY_CONTEXT_PROMPT.into()); .unwrap_or_else(|| SUMMARY_CONTEXT_PROMPT.into());
let todo_prefix = if self.agent.is_some() && !self.todo_list.is_empty() { let todo_prefix = if self.auto_continue_config().enabled && !self.todo_list.is_empty() {
format!( format!(
"[ACTIVE TODO LIST]\n{}\n\n", "[ACTIVE TODO LIST]\n{}\n\n",
self.todo_list.render_for_model() self.todo_list.render_for_model()
+50
View File
@@ -55,6 +55,14 @@ pub struct Role {
enabled_tools: Option<String>, enabled_tools: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
enabled_mcp_servers: Option<String>, enabled_mcp_servers: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
auto_continue: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
max_auto_continues: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
inject_todo_instructions: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
continuation_prompt: Option<String>,
#[serde(skip)] #[serde(skip)]
model: Model, model: Model,
@@ -90,6 +98,16 @@ impl Role {
"enabled_mcp_servers" => { "enabled_mcp_servers" => {
role.enabled_mcp_servers = value.as_str().map(|v| v.to_string()) role.enabled_mcp_servers = value.as_str().map(|v| v.to_string())
} }
"auto_continue" => role.auto_continue = value.as_bool(),
"max_auto_continues" => {
role.max_auto_continues = value.as_u64().map(|v| v as usize)
}
"inject_todo_instructions" => {
role.inject_todo_instructions = value.as_bool()
}
"continuation_prompt" => {
role.continuation_prompt = value.as_str().map(|v| v.to_string())
}
_ => (), _ => (),
} }
} }
@@ -131,6 +149,18 @@ impl Role {
if let Some(enabled_mcp_servers) = self.enabled_mcp_servers() { if let Some(enabled_mcp_servers) = self.enabled_mcp_servers() {
metadata.push(format!("enabled_mcp_servers: {enabled_mcp_servers}")); metadata.push(format!("enabled_mcp_servers: {enabled_mcp_servers}"));
} }
if let Some(auto_continue) = self.auto_continue {
metadata.push(format!("auto_continue: {auto_continue}"));
}
if let Some(max_auto_continues) = self.max_auto_continues {
metadata.push(format!("max_auto_continues: {max_auto_continues}"));
}
if let Some(inject_todo_instructions) = self.inject_todo_instructions {
metadata.push(format!("inject_todo_instructions: {inject_todo_instructions}"));
}
if let Some(continuation_prompt) = &self.continuation_prompt {
metadata.push(format!("continuation_prompt: {continuation_prompt}"));
}
if metadata.is_empty() { if metadata.is_empty() {
format!("{}\n", self.prompt) format!("{}\n", self.prompt)
} else if self.prompt.is_empty() { } else if self.prompt.is_empty() {
@@ -225,6 +255,26 @@ impl Role {
self.prompt.contains(INPUT_PLACEHOLDER) self.prompt.contains(INPUT_PLACEHOLDER)
} }
pub fn auto_continue(&self) -> Option<bool> {
self.auto_continue
}
pub fn max_auto_continues(&self) -> Option<usize> {
self.max_auto_continues
}
pub fn inject_todo_instructions(&self) -> Option<bool> {
self.inject_todo_instructions
}
pub fn continuation_prompt(&self) -> Option<&str> {
self.continuation_prompt.as_deref()
}
pub fn append_to_prompt(&mut self, text: &str) {
self.prompt.push_str(text);
}
pub fn echo_messages(&self, input: &Input) -> String { pub fn echo_messages(&self, input: &Input) -> String {
let input_markdown = input.render(); let input_markdown = input.render();
if self.is_empty_prompt() { if self.is_empty_prompt() {
+77
View File
@@ -32,6 +32,14 @@ pub struct Session {
save_session: Option<bool>, save_session: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
compression_threshold: Option<usize>, compression_threshold: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
auto_continue: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
max_auto_continues: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
inject_todo_instructions: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
continuation_prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
role_name: Option<String>, role_name: Option<String>,
@@ -170,6 +178,18 @@ impl Session {
if let Some(save_session) = self.save_session() { if let Some(save_session) = self.save_session() {
data["save_session"] = save_session.into(); data["save_session"] = save_session.into();
} }
if let Some(auto_continue) = self.auto_continue() {
data["auto_continue"] = auto_continue.into();
}
if let Some(max_auto_continues) = self.max_auto_continues() {
data["max_auto_continues"] = max_auto_continues.into();
}
if let Some(inject_todo_instructions) = self.inject_todo_instructions() {
data["inject_todo_instructions"] = inject_todo_instructions.into();
}
if let Some(continuation_prompt) = self.continuation_prompt() {
data["continuation_prompt"] = continuation_prompt.into();
}
let (tokens, percent) = self.tokens_usage(); let (tokens, percent) = self.tokens_usage();
data["total_tokens"] = tokens.into(); data["total_tokens"] = tokens.into();
if let Some(max_input_tokens) = self.model().max_input_tokens() { if let Some(max_input_tokens) = self.model().max_input_tokens() {
@@ -225,6 +245,19 @@ impl Session {
items.push(("compression_threshold", compression_threshold.to_string())); items.push(("compression_threshold", compression_threshold.to_string()));
} }
if let Some(auto_continue) = self.auto_continue() {
items.push(("auto_continue", auto_continue.to_string()));
}
if let Some(max_auto_continues) = self.max_auto_continues() {
items.push(("max_auto_continues", max_auto_continues.to_string()));
}
if let Some(inject_todo_instructions) = self.inject_todo_instructions() {
items.push(("inject_todo_instructions", inject_todo_instructions.to_string()));
}
if let Some(continuation_prompt) = self.continuation_prompt() {
items.push(("continuation_prompt", continuation_prompt.to_string()));
}
if let Some(max_input_tokens) = self.model().max_input_tokens() { if let Some(max_input_tokens) = self.model().max_input_tokens() {
items.push(("max_input_tokens", max_input_tokens.to_string())); items.push(("max_input_tokens", max_input_tokens.to_string()));
} }
@@ -335,6 +368,50 @@ impl Session {
} }
} }
pub fn auto_continue(&self) -> Option<bool> {
self.auto_continue
}
pub fn max_auto_continues(&self) -> Option<usize> {
self.max_auto_continues
}
pub fn set_auto_continue(&mut self, value: Option<bool>) {
if self.auto_continue != value {
self.auto_continue = value;
self.dirty = true;
}
}
pub fn set_max_auto_continues(&mut self, value: Option<usize>) {
if self.max_auto_continues != value {
self.max_auto_continues = value;
self.dirty = true;
}
}
pub fn inject_todo_instructions(&self) -> Option<bool> {
self.inject_todo_instructions
}
pub fn continuation_prompt(&self) -> Option<&str> {
self.continuation_prompt.as_deref()
}
pub fn set_inject_todo_instructions(&mut self, value: Option<bool>) {
if self.inject_todo_instructions != value {
self.inject_todo_instructions = value;
self.dirty = true;
}
}
pub fn set_continuation_prompt(&mut self, value: Option<String>) {
if self.continuation_prompt != value {
self.continuation_prompt = value;
self.dirty = true;
}
}
pub fn needs_compression(&self, global_compression_threshold: usize) -> bool { pub fn needs_compression(&self, global_compression_threshold: usize) -> bool {
if self.compressing { if self.compressing {
return false; return false;
+8 -2
View File
@@ -94,8 +94,14 @@ pub fn handle_todo_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value)
.strip_prefix(TODO_FUNCTION_PREFIX) .strip_prefix(TODO_FUNCTION_PREFIX)
.unwrap_or(cmd_name); .unwrap_or(cmd_name);
if ctx.agent.is_none() { if !ctx.app.config.function_calling_support {
bail!("No active agent"); bail!("Cannot use todo tools: function calling is disabled.");
}
let auto_config = ctx.auto_continue_config();
if !auto_config.enabled {
bail!(
"Auto-continue is not enabled. Set 'auto_continue: true' in your config to use todo tools."
);
} }
match action { match action {
+47 -35
View File
@@ -31,9 +31,19 @@ use reedline::{
use reedline::{MenuBuilder, Signal}; use reedline::{MenuBuilder, Signal};
use std::sync::LazyLock; use std::sync::LazyLock;
use std::{env, process, sync::Arc}; use std::{env, process, sync::Arc};
use indoc::indoc;
const MENU_NAME: &str = "completion_menu"; const MENU_NAME: &str = "completion_menu";
pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {"
[SYSTEM REMINDER - TODO CONTINUATION]
You have incomplete tasks. Rules:
1. BEFORE marking a todo done: verify the work compiles/works. No premature completion.
2. If a todo is broad (e.g. \"implement X and implement Y\"): break it into specific subtasks FIRST using todo__add, then work on those.\n\
3. Each todo should be atomic and be \"single responsibility\" - completable in one focused action.
4. Continue with the next pending item now. Call tools immediately."
};
static REPL_COMMANDS: LazyLock<[ReplCommand; 39]> = LazyLock::new(|| { static REPL_COMMANDS: LazyLock<[ReplCommand; 39]> = LazyLock::new(|| {
[ [
ReplCommand::new(".help", "Show this help guide", AssertState::pass()), ReplCommand::new(".help", "Show this help guide", AssertState::pass()),
@@ -141,7 +151,7 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 39]> = LazyLock::new(|| {
ReplCommand::new( ReplCommand::new(
".clear todo", ".clear todo",
"Clear the todo list and stop auto-continuation", "Clear the todo list and stop auto-continuation",
AssertState::True(StateFlags::AGENT), AssertState::pass(),
), ),
ReplCommand::new( ReplCommand::new(
".rag", ".rag",
@@ -764,25 +774,18 @@ pub async fn run_repl_command(
bail!("Use '.empty session' instead"); bail!("Use '.empty session' instead");
} }
Some("todo") => { Some("todo") => {
let cleared = match ctx.agent.as_mut() { let config = ctx.auto_continue_config();
Some(agent) => { if !config.enabled {
if !agent.auto_continue_enabled() { bail!(
bail!( "Auto-continue is not enabled. Set 'auto_continue: true' in your config to enable it."
"The todo system is not enabled for this agent. Set 'auto_continue: true' in the agent's config.yaml to enable it." );
); }
} if ctx.todo_list.is_empty() {
if ctx.todo_list.is_empty() { println!("Todo list is already empty.");
println!("Todo list is already empty."); } else {
false ctx.clear_todo_list();
} else { println!("Todo list cleared.");
ctx.clear_todo_list(); }
println!("Todo list cleared.");
true
}
}
None => bail!("No active agent"),
};
let _ = cleared;
} }
_ => unknown_command()?, _ => unknown_command()?,
}, },
@@ -881,19 +884,22 @@ async fn ask(
) )
.await .await
} else { } else {
let should_continue = agent_should_continue(ctx); let do_continue = should_continue(ctx);
if should_continue { if do_continue {
let full_prompt = { let full_prompt = {
let config = ctx.auto_continue_config();
let todo_state = ctx.todo_list.render_for_model(); let todo_state = ctx.todo_list.render_for_model();
let remaining = ctx.todo_list.incomplete_count(); let remaining = ctx.todo_list.incomplete_count();
ctx.set_last_continuation_response(output.clone()); ctx.set_last_continuation_response(output.clone());
ctx.increment_auto_continue_count(); ctx.increment_auto_continue_count();
let agent = ctx.agent.as_mut().expect("agent checked above");
let count = ctx.auto_continue_count; let count = ctx.auto_continue_count;
let max = agent.max_auto_continues(); let max = config.max_continues;
let prompt = agent.continuation_prompt(); let prompt = config
.continuation_prompt
.as_deref()
.unwrap_or(DEFAULT_CONTINUATION_PROMPT);
let color = if app.light_theme() { let color = if app.light_theme() {
nu_ansi_term::Color::LightGray nu_ansi_term::Color::LightGray
@@ -934,7 +940,7 @@ async fn ask(
.is_some_and(|s| s.needs_compression(app.compression_threshold)); .is_some_and(|s| s.needs_compression(app.compression_threshold));
if needs_compression { if needs_compression {
let agent_can_continue_after_compress = agent_should_continue(ctx); let agent_can_continue_after_compress = should_continue(ctx);
if let Some(session) = ctx.session.as_mut() { if let Some(session) = ctx.session.as_mut() {
session.set_compressing(true); session.set_compressing(true);
@@ -956,14 +962,17 @@ async fn ask(
if agent_can_continue_after_compress { if agent_can_continue_after_compress {
let full_prompt = { let full_prompt = {
let config = ctx.auto_continue_config();
let todo_state = ctx.todo_list.render_for_model(); let todo_state = ctx.todo_list.render_for_model();
let remaining = ctx.todo_list.incomplete_count(); let remaining = ctx.todo_list.incomplete_count();
ctx.increment_auto_continue_count(); ctx.increment_auto_continue_count();
let agent = ctx.agent.as_mut().expect("agent checked above");
let count = ctx.auto_continue_count; let count = ctx.auto_continue_count;
let max = agent.max_auto_continues(); let max = config.max_continues;
let prompt = agent.continuation_prompt(); let prompt = config
.continuation_prompt
.as_deref()
.unwrap_or(DEFAULT_CONTINUATION_PROMPT);
let color = if app.light_theme() { let color = if app.light_theme() {
nu_ansi_term::Color::LightGray nu_ansi_term::Color::LightGray
@@ -989,10 +998,11 @@ async fn ask(
} }
} }
fn agent_should_continue(ctx: &RequestContext) -> bool { fn should_continue(ctx: &RequestContext) -> bool {
ctx.agent.as_ref().is_some_and(|agent| { let config = ctx.auto_continue_config();
agent.auto_continue_enabled() && ctx.auto_continue_count < agent.max_auto_continues() config.enabled
}) && ctx.todo_list.has_incomplete() && ctx.auto_continue_count < config.max_continues
&& ctx.todo_list.has_incomplete()
} }
fn reset_continuation(ctx: &mut RequestContext) { fn reset_continuation(ctx: &mut RequestContext) {
@@ -1311,13 +1321,15 @@ mod tests {
} }
#[test] #[test]
fn repl_commands_clear_todo_requires_agent() { fn repl_commands_clear_todo_always_available() {
let cmd = REPL_COMMANDS let cmd = REPL_COMMANDS
.iter() .iter()
.find(|c| c.name == ".clear todo") .find(|c| c.name == ".clear todo")
.unwrap(); .unwrap();
assert!(cmd.is_valid(StateFlags::AGENT)); assert!(cmd.is_valid(StateFlags::AGENT));
assert!(!cmd.is_valid(StateFlags::empty())); assert!(cmd.is_valid(StateFlags::empty()));
assert!(cmd.is_valid(StateFlags::SESSION));
assert!(cmd.is_valid(StateFlags::ROLE));
} }
#[test] #[test]