feat: support auto continuation in subagents and prevent them from calling todo__pause

This commit is contained in:
Dark-Alex-17 committed 2026-09-03 15:50:00 -06:00
1 parent 101b706be5
commit cb5b46800f
8 files changed
+305 -3

No files matched your search

+5
View File
@@ -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 {
+1
View File
@@ -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();
+3
View File
@@ -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."
};
+17
View File
@@ -336,6 +336,7 @@ pub struct RequestContext {
pub todo_list: TodoList,
pub skill_registry: SkillRegistry,
pub last_continuation_response: Option<String>,
pub auto_continue_paused: Option<String>,
pub pending_prefill: Option<String>,
pub session_abort: Option<AbortSignal>,
@@ -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(<agent name>)`, and leaving that key behind
+122 -2
View File
@@ -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<String> {
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<AppState> {
let mut state = AppState::test_default();
state.mcp_config = Some(McpServersConfig {
+78
View File
@@ -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);
+59
View File
@@ -86,6 +86,33 @@ pub fn todo_function_declarations() -> Vec<FunctionDeclaration> {
},
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}"),
}
}
+20 -1
View File
@@ -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) {