diff --git a/src/config/agent.rs b/src/config/agent.rs index 87d6fe6..09540f8 100644 --- a/src/config/agent.rs +++ b/src/config/agent.rs @@ -648,6 +648,11 @@ impl Agent { enabled_tools: None, } } + + #[cfg(test)] + pub fn functions_mut(&mut self) -> &mut Functions { + &mut self.functions + } } impl RoleLike for Agent { diff --git a/src/config/macros.rs b/src/config/macros.rs index c2c160d..8ae1f1c 100644 --- a/src/config/macros.rs +++ b/src/config/macros.rs @@ -74,6 +74,7 @@ pub async fn macro_execute( macro_ctx.current_depth = ctx.current_depth; macro_ctx.auto_continue_count = ctx.auto_continue_count; macro_ctx.todo_list = ctx.todo_list.clone(); + macro_ctx.auto_continue_paused = ctx.auto_continue_paused.clone(); macro_ctx.tool_scope.tool_tracker = ctx.tool_scope.tool_tracker.clone(); macro_ctx.discontinuous_last_message(); diff --git a/src/config/prompts.rs b/src/config/prompts.rs index fa7d908..4baa732 100644 --- a/src/config/prompts.rs +++ b/src/config/prompts.rs @@ -63,11 +63,14 @@ pub(in crate::config) const DEFAULT_TODO_INSTRUCTIONS: &str = indoc! {" - `todo__done`: Mark a task done by id. Call this immediately after completing each step. - `todo__list`: Show the current todo list. - `todo__clear`: Clear the entire todo list and reset the goal. Use when the user cancels or changes direction. + - `todo__pause`: Pause auto-continue and yield the turn to the user, keeping incomplete todos intact. Use when you are blocked on user input or a user-side action. Auto-continue resumes on the user's next message. RULES: - Always create a todo list before starting work. - Mark each task done as soon as you finish it; do not batch. - If the user cancels the current task or changes direction, call `todo__clear` immediately. + - If you need user input (or the user must do something on their end) before you can continue, call `todo__pause` with the reason, ask the user, and end your turn. NEVER mark unfinished todos done just to stop the auto-continue prompts. + - `todo__pause` is for top-level sessions only. Subagents are driven to completion autonomously and cannot pause. If you are a subagent and need input, use a `user__*` tool (it escalates to the root agent mid-turn and blocks until answered, so you keep working without ending your turn). - If you stop with incomplete tasks, the system will automatically prompt you to continue." }; diff --git a/src/config/request_context.rs b/src/config/request_context.rs index e2f2547..9b4682d 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -336,6 +336,7 @@ pub struct RequestContext { pub todo_list: TodoList, pub skill_registry: SkillRegistry, pub last_continuation_response: Option, + pub auto_continue_paused: Option, pub pending_prefill: Option, pub session_abort: Option, @@ -376,6 +377,7 @@ impl RequestContext { todo_list: TodoList::default(), skill_registry: SkillRegistry::default(), last_continuation_response: None, + auto_continue_paused: None, pending_prefill: None, session_abort: None, render_mode: RenderMode::default(), @@ -441,6 +443,7 @@ impl RequestContext { todo_list: TodoList::default(), skill_registry: SkillRegistry::default(), last_continuation_response: None, + auto_continue_paused: None, pending_prefill: None, session_abort: None, render_mode: RenderMode::default(), @@ -495,6 +498,7 @@ impl RequestContext { todo_list: self.todo_list.clone(), skill_registry: self.skill_registry.clone(), last_continuation_response: None, + auto_continue_paused: self.auto_continue_paused.clone(), pending_prefill: None, session_abort: self.session_abort.clone(), render_mode: self.render_mode, @@ -545,6 +549,7 @@ impl RequestContext { todo_list: TodoList::default(), skill_registry: SkillRegistry::default(), last_continuation_response: None, + auto_continue_paused: None, pending_prefill: None, session_abort: None, render_mode: parent.render_mode, @@ -582,6 +587,7 @@ impl RequestContext { pub fn init_todo_list(&mut self, goal: &str) { self.todo_list = TodoList::new(goal); + self.auto_continue_paused = None; } pub fn add_todo(&mut self, task: &str) -> usize { @@ -595,12 +601,21 @@ impl RequestContext { pub fn clear_todo_list(&mut self) { self.todo_list.clear(); self.auto_continue_count = 0; + self.auto_continue_paused = None; } pub fn increment_auto_continue_count(&mut self) { self.auto_continue_count += 1; } + pub fn pause_auto_continue(&mut self, reason: &str) { + self.auto_continue_paused = Some(reason.to_string()); + } + + pub fn resume_auto_continue(&mut self) { + self.auto_continue_paused = None; + } + pub fn reset_continuation_count(&mut self) { self.auto_continue_count = 0; self.last_continuation_response = None; @@ -4676,6 +4691,7 @@ impl RequestContext { self.current_depth = 0; self.auto_continue_count = 0; self.todo_list = TodoList::default(); + self.auto_continue_paused = None; if let Some(session_name) = session_name.as_deref() { self.use_session(app, Some(session_name), abort_signal) @@ -4719,6 +4735,7 @@ impl RequestContext { self.auto_continue_count = 0; self.pending_tasks_guardrail_count = 0; self.todo_list = TodoList::default(); + self.auto_continue_paused = None; self.rag.take(); // Cleared alongside `rag` so the pair never disagrees: an agent RAG is // cached under `RagKey::Agent()`, and leaving that key behind diff --git a/src/function/agents.rs b/src/function/agents.rs index a03f49a..1b753c9 100644 --- a/src/function/agents.rs +++ b/src/function/agents.rs @@ -1,3 +1,4 @@ +use super::todo::TODO_FUNCTION_PREFIX; use super::{FunctionDeclaration, JsonSchema}; use crate::client::{Model, ModelType, call_chat_completions}; use crate::config::{ @@ -10,6 +11,7 @@ use crate::supervisor::{AgentExitStatus, AgentHandle, AgentResult, Supervisor, T use crate::utils::{AbortSignal, create_abort_signal, wait_abort_signal, wait_user_interrupt}; use crate::graph; +use crate::repl::DEFAULT_CONTINUATION_PROMPT; use anyhow::{Context, Result, anyhow, bail}; use chrono::Utc; use indexmap::IndexMap; @@ -574,7 +576,13 @@ pub fn run_child_agent( if tool_results.is_empty() { match check_pending_tasks_guardrail(&mut child_ctx) { - GuardrailAction::NoAction => break, + GuardrailAction::NoAction => { + if let Some(prompt) = todo_continuation_prompt(&mut child_ctx) { + input = Input::from_str(&child_ctx, &prompt, None)?; + continue; + } + break; + } GuardrailAction::ForceTerminate(ids) => { warn!( "Pending-agent guardrail force-cancelled {} agent(s) after max reminders: {:?}", @@ -601,6 +609,33 @@ pub fn run_child_agent( }) } +fn todo_continuation_prompt(ctx: &mut RequestContext) -> Option { + let config = ctx.auto_continue_config(); + if !ctx.app.config.function_calling_support + || !config.enabled + || ctx.auto_continue_count >= config.max_continues + || !ctx.todo_list.has_incomplete() + || ctx.auto_continue_paused.is_some() + { + return None; + } + + ctx.increment_auto_continue_count(); + debug!( + "Auto-continuing child agent ({}/{}): {} incomplete todo(s) remain", + ctx.auto_continue_count, + config.max_continues, + ctx.todo_list.incomplete_count() + ); + + let prompt = config + .continuation_prompt + .as_deref() + .unwrap_or(DEFAULT_CONTINUATION_PROMPT); + + Some(format!("{prompt}\n\n{}", ctx.todo_list.render_for_model())) +} + /// Spawn an agent synchronously from a graph node and return its accumulated /// output. This is similar to `handle_spawn` but runs the child agent in the /// current task (no tokio::spawn, no supervisor handle registration) so the @@ -749,6 +784,14 @@ fn sync_agent_functions_to_ctx(ctx: &mut RequestContext) -> Result<()> { }; ctx.tool_scope.functions = functions; + // Agent::init builds one function set regardless of how the agent runs; + // this sync is only ever called for spawned children. + if ctx.self_agent_id.is_some() { + ctx.tool_scope + .functions + .remove_function(&format!("{TODO_FUNCTION_PREFIX}pause")); + } + Ok(()) } @@ -1650,7 +1693,7 @@ async fn summarize_output(ctx: &RequestContext, agent_name: &str, output: &str) mod tests { use super::*; use crate::config::test_fixtures::{FixtureServer, fixture_runtime}; - use crate::config::{AgentConfig, AppState, WorkingMode}; + use crate::config::{AgentConfig, AppConfig, AppState, WorkingMode}; use crate::function::jobs::RingBuf; use crate::mcp::{McpServer, McpServersConfig, McpTransportType}; use crate::supervisor::escalation::{EscalationQueue, EscalationRequest}; @@ -1673,6 +1716,58 @@ mod tests { ctx } + fn auto_continue_ctx() -> RequestContext { + let app = AppState { + config: Arc::new(AppConfig { + auto_continue: true, + ..Default::default() + }), + ..AppState::test_default() + }; + RequestContext::new(Arc::new(app), WorkingMode::Cmd) + } + + #[test] + fn todo_continuation_fires_for_child_with_incomplete_todos() { + let mut ctx = auto_continue_ctx(); + ctx.init_todo_list("ship feature"); + ctx.add_todo("build the thing"); + + let prompt = todo_continuation_prompt(&mut ctx).expect("child should auto-continue"); + + assert!(prompt.contains("TODO CONTINUATION"), "{prompt}"); + assert!(prompt.contains("build the thing"), "{prompt}"); + assert_eq!(ctx.auto_continue_count, 1); + } + + #[test] + fn todo_continuation_stops_when_done_paused_capped_or_disabled() { + // All todos complete → run may end. + let mut ctx = auto_continue_ctx(); + ctx.init_todo_list("ship feature"); + let id = ctx.add_todo("build the thing"); + ctx.mark_todo_done(id); + assert!(todo_continuation_prompt(&mut ctx).is_none()); + + // Defense-in-depth: children cannot call todo__pause, but a set flag + // must still stop the continuation loop. + ctx.add_todo("deploy the thing"); + ctx.pause_auto_continue("blocked on credentials"); + assert!(todo_continuation_prompt(&mut ctx).is_none()); + ctx.resume_auto_continue(); + assert!(todo_continuation_prompt(&mut ctx).is_some()); + + // Continuation cap reached → run may end. + ctx.auto_continue_count = ctx.auto_continue_config().max_continues; + assert!(todo_continuation_prompt(&mut ctx).is_none()); + + // Auto-continue disabled → run may end even with incomplete todos. + let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd); + ctx.init_todo_list("ship feature"); + ctx.add_todo("build the thing"); + assert!(todo_continuation_prompt(&mut ctx).is_none()); + } + fn ctx_with_job_capable_supervisor() -> RequestContext { let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd); ctx.supervisor = Some(Arc::new(RwLock::new( @@ -1840,6 +1935,31 @@ mod tests { assert!(!functions.contains("mcp_invoke_fixture")); } + /// `Agent::init` registers the full todo tool set (incl. `todo__pause`) + /// whenever the agent config enables auto_continue, because the same + /// function set serves top-level `--agent` sessions. Spawned children + /// must never advertise `todo__pause` (they are driven to completion + /// autonomously), so the child-only sync strips that one declaration + /// while leaving the rest of the todo tools intact. + #[tokio::test] + async fn sync_strips_todo_pause_declaration_for_child_contexts() { + let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd); + let mut agent = Agent::test_new(AgentConfig::default()); + agent.functions_mut().append_todo_functions(); + ctx.agent = Some(agent); + ctx.self_agent_id = Some("agent_sisyphus_abc123".to_string()); + + sync_agent_functions_to_ctx(&mut ctx).unwrap(); + + let functions = &ctx.tool_scope.functions; + assert!(!functions.contains("todo__pause")); + assert!(functions.contains("todo__init")); + assert!(functions.contains("todo__add")); + assert!(functions.contains("todo__done")); + assert!(functions.contains("todo__list")); + assert!(functions.contains("todo__clear")); + } + fn app_state_with_fixture_mcp_config() -> Arc { let mut state = AppState::test_default(); state.mcp_config = Some(McpServersConfig { diff --git a/src/function/mod.rs b/src/function/mod.rs index 66dadd2..5511ea5 100644 --- a/src/function/mod.rs +++ b/src/function/mod.rs @@ -727,6 +727,10 @@ impl Functions { .retain(|f| !f.name.starts_with(TODO_FUNCTION_PREFIX)); } + pub fn remove_function(&mut self, name: &str) { + self.declarations.retain(|f| f.name != name); + } + pub fn append_memory_functions(&mut self) { self.declarations .extend(memory::memory_function_declarations()); @@ -3371,6 +3375,7 @@ mod tests { assert!(f.contains("todo__done")); assert!(f.contains("todo__list")); assert!(f.contains("todo__clear")); + assert!(f.contains("todo__pause")); } #[test] @@ -4914,6 +4919,79 @@ mod tests { assert!(err.contains("Auto-continue is not enabled"), "{err}"); } + #[test] + fn todo_pause_sets_and_clears_pause_flag() { + let app = AppState { + config: Arc::new(AppConfig { + auto_continue: true, + ..Default::default() + }), + ..AppState::test_default() + }; + let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Cmd); + ctx.tool_scope.functions.append_todo_functions(); + + // A blank reason is rejected and does not pause. + let out = run_async(call_with_args("todo__pause", json!({"reason": " "})).eval(&mut ctx)) + .unwrap(); + assert_eq!(out["error"], "reason is required"); + assert!(ctx.auto_continue_paused.is_none()); + + // A real reason pauses auto-continue, preserving the todo list. + run_async(call_with_args("todo__init", json!({"goal": "ship"})).eval(&mut ctx)).unwrap(); + run_async(call_with_args("todo__add", json!({"task": "deploy"})).eval(&mut ctx)).unwrap(); + let out = run_async( + call_with_args("todo__pause", json!({"reason": "need prod API key"})).eval(&mut ctx), + ) + .unwrap(); + assert_eq!(out["status"], "ok"); + assert_eq!( + ctx.auto_continue_paused.as_deref(), + Some("need prod API key") + ); + assert!(ctx.todo_list.has_incomplete()); + + // The next real user message lifts the pause. + ctx.resume_auto_continue(); + assert!(ctx.auto_continue_paused.is_none()); + + // Re-initializing or clearing the list also lifts a stale pause. + ctx.pause_auto_continue("stale"); + run_async(call_with_args("todo__init", json!({"goal": "new"})).eval(&mut ctx)).unwrap(); + assert!(ctx.auto_continue_paused.is_none()); + ctx.pause_auto_continue("stale again"); + run_async(call_with_args("todo__clear", json!({})).eval(&mut ctx)).unwrap(); + assert!(ctx.auto_continue_paused.is_none()); + } + + /// Subagents are driven to completion autonomously: their turn boundary + /// has no interactive user, and user__* escalations work mid-turn, so + /// pausing is never the right move for a child. The handler must reject + /// the call, leave the pause flag unset, and redirect to escalation. + #[test] + fn todo_pause_is_rejected_for_subagents() { + let app = AppState { + config: Arc::new(AppConfig { + auto_continue: true, + ..Default::default() + }), + ..AppState::test_default() + }; + let mut ctx = RequestContext::new(Arc::new(app), WorkingMode::Cmd); + ctx.tool_scope.functions.append_todo_functions(); + ctx.self_agent_id = Some("agent_sisyphus_abc123".to_string()); + + let out = run_async( + call_with_args("todo__pause", json!({"reason": "need user input"})).eval(&mut ctx), + ) + .unwrap(); + + let err = out["error"].as_str().unwrap(); + assert!(err.contains("unavailable to subagents"), "{err}"); + assert!(err.contains("user__"), "{err}"); + assert!(ctx.auto_continue_paused.is_none()); + } + #[test] fn eval_routes_memory_prefix_to_memory_handler() { let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd); diff --git a/src/function/todo.rs b/src/function/todo.rs index 9415849..3f8f4da 100644 --- a/src/function/todo.rs +++ b/src/function/todo.rs @@ -86,6 +86,33 @@ pub fn todo_function_declarations() -> Vec { }, agent: false, }, + FunctionDeclaration { + name: format!("{TODO_FUNCTION_PREFIX}pause"), + description: "Pause auto-continue and yield the turn back to the user while keeping \ + incomplete todos intact. Use when you cannot proceed without user input \ + or a user-side action. NEVER mark unfinished todos as done just to stop \ + auto-continuation. Auto-continue resumes automatically on the user's \ + next message. Unavailable to subagents: they are driven to completion \ + autonomously — a subagent needing input must use a user__* tool, which \ + escalates to the root agent mid-turn and blocks until answered." + .to_string(), + parameters: JsonSchema { + type_value: Some("object".to_string()), + properties: Some(IndexMap::from([( + "reason".to_string(), + JsonSchema { + type_value: Some("string".to_string()), + description: Some( + "What you are waiting on from the user (shown to the user)".into(), + ), + ..Default::default() + }, + )])), + required: Some(vec!["reason".to_string()]), + ..Default::default() + }, + agent: false, + }, ] } @@ -149,6 +176,38 @@ pub fn handle_todo_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value) ctx.clear_todo_list(); Ok(json!({"status": "ok", "message": "Todo list cleared"})) } + "pause" => { + if ctx.self_agent_id.is_some() { + return Ok(json!({ + "error": "todo__pause is unavailable to subagents: you are driven to \ + completion autonomously and there is no interactive user at your \ + turn boundary. If you need input, ask via a user__* tool — it \ + escalates to the root agent mid-turn and blocks until answered, so \ + you can keep working. If you are blocked on an action outside your \ + scope, finish what you can and report the blocker in your final \ + output." + })); + } + + let reason = args + .get("reason") + .and_then(Value::as_str) + .unwrap_or_default() + .trim() + .to_string(); + if reason.is_empty() { + return Ok(json!({"error": "reason is required"})); + } + + ctx.pause_auto_continue(&reason); + + Ok(json!({ + "status": "ok", + "message": "Auto-continue paused; incomplete todos are preserved. Tell the user \ + what you need from them, then end your turn. Auto-continue resumes \ + on their next message." + })) + } _ => bail!("Unknown todo action: {action}"), } } diff --git a/src/repl/mod.rs b/src/repl/mod.rs index a57efd9..cf53aeb 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -52,7 +52,8 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {" 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." + 4. If you are blocked on user input or a user-side action: call todo__pause with the reason, ask the user, and end your turn. NEVER mark unfinished todos done just to stop this reminder. + 5. Otherwise, continue with the next pending item now. Call tools immediately." }; static REPL_COMMANDS: LazyLock<[ReplCommand; 63]> = LazyLock::new(|| { @@ -1409,6 +1410,7 @@ pub async fn run_repl_command( handle_shell_passthrough(cmd)?; } else { reset_continuation(ctx); + ctx.resume_auto_continue(); let input = Input::from_str(ctx, line, None)?; ask(ctx, abort_signal.clone(), input, true).await?; } @@ -1535,6 +1537,22 @@ async fn ask( ask(ctx, abort_signal, continuation_input, false).await } else { reset_continuation(ctx); + if let Some(reason) = ctx.auto_continue_paused.clone() + && ctx.todo_list.has_incomplete() + { + let remaining = ctx.todo_list.incomplete_count(); + let color = if app.light_theme() { + nu_ansi_term::Color::LightGray + } else { + nu_ansi_term::Color::DarkGray + }; + eprintln!( + "\n⏸ {}", + color.italic().paint(format!( + "Auto-continue paused, awaiting user ({remaining} incomplete todo(s) preserved): {reason}" + )) + ); + } if ctx.maybe_autoname_session() { let color = if app.light_theme() { nu_ansi_term::Color::LightGray @@ -1621,6 +1639,7 @@ fn should_continue(ctx: &RequestContext) -> bool { && config.enabled && ctx.auto_continue_count < config.max_continues && ctx.todo_list.has_incomplete() + && ctx.auto_continue_paused.is_none() } fn reset_continuation(ctx: &mut RequestContext) {