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

+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}"),
}
}