feat: support auto continuation in subagents and prevent them from calling todo__pause
This commit is contained in:
8 files changed
+305
-3
No files matched your search
@@ -648,6 +648,11 @@ impl Agent {
|
|||||||
enabled_tools: None,
|
enabled_tools: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn functions_mut(&mut self) -> &mut Functions {
|
||||||
|
&mut self.functions
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RoleLike for Agent {
|
impl RoleLike for Agent {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ pub async fn macro_execute(
|
|||||||
macro_ctx.current_depth = ctx.current_depth;
|
macro_ctx.current_depth = ctx.current_depth;
|
||||||
macro_ctx.auto_continue_count = ctx.auto_continue_count;
|
macro_ctx.auto_continue_count = ctx.auto_continue_count;
|
||||||
macro_ctx.todo_list = ctx.todo_list.clone();
|
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.tool_scope.tool_tracker = ctx.tool_scope.tool_tracker.clone();
|
||||||
macro_ctx.discontinuous_last_message();
|
macro_ctx.discontinuous_last_message();
|
||||||
|
|
||||||
|
|||||||
@@ -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__done`: Mark a task done by id. Call this immediately after completing each step.
|
||||||
- `todo__list`: Show the current todo list.
|
- `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__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:
|
RULES:
|
||||||
- Always create a todo list before starting work.
|
- Always create a todo list before starting work.
|
||||||
- Mark each task done as soon as you finish it; do not batch.
|
- 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 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."
|
- If you stop with incomplete tasks, the system will automatically prompt you to continue."
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ pub struct RequestContext {
|
|||||||
pub todo_list: TodoList,
|
pub todo_list: TodoList,
|
||||||
pub skill_registry: SkillRegistry,
|
pub skill_registry: SkillRegistry,
|
||||||
pub last_continuation_response: Option<String>,
|
pub last_continuation_response: Option<String>,
|
||||||
|
pub auto_continue_paused: Option<String>,
|
||||||
pub pending_prefill: Option<String>,
|
pub pending_prefill: Option<String>,
|
||||||
|
|
||||||
pub session_abort: Option<AbortSignal>,
|
pub session_abort: Option<AbortSignal>,
|
||||||
@@ -376,6 +377,7 @@ impl RequestContext {
|
|||||||
todo_list: TodoList::default(),
|
todo_list: TodoList::default(),
|
||||||
skill_registry: SkillRegistry::default(),
|
skill_registry: SkillRegistry::default(),
|
||||||
last_continuation_response: None,
|
last_continuation_response: None,
|
||||||
|
auto_continue_paused: None,
|
||||||
pending_prefill: None,
|
pending_prefill: None,
|
||||||
session_abort: None,
|
session_abort: None,
|
||||||
render_mode: RenderMode::default(),
|
render_mode: RenderMode::default(),
|
||||||
@@ -441,6 +443,7 @@ impl RequestContext {
|
|||||||
todo_list: TodoList::default(),
|
todo_list: TodoList::default(),
|
||||||
skill_registry: SkillRegistry::default(),
|
skill_registry: SkillRegistry::default(),
|
||||||
last_continuation_response: None,
|
last_continuation_response: None,
|
||||||
|
auto_continue_paused: None,
|
||||||
pending_prefill: None,
|
pending_prefill: None,
|
||||||
session_abort: None,
|
session_abort: None,
|
||||||
render_mode: RenderMode::default(),
|
render_mode: RenderMode::default(),
|
||||||
@@ -495,6 +498,7 @@ impl RequestContext {
|
|||||||
todo_list: self.todo_list.clone(),
|
todo_list: self.todo_list.clone(),
|
||||||
skill_registry: self.skill_registry.clone(),
|
skill_registry: self.skill_registry.clone(),
|
||||||
last_continuation_response: None,
|
last_continuation_response: None,
|
||||||
|
auto_continue_paused: self.auto_continue_paused.clone(),
|
||||||
pending_prefill: None,
|
pending_prefill: None,
|
||||||
session_abort: self.session_abort.clone(),
|
session_abort: self.session_abort.clone(),
|
||||||
render_mode: self.render_mode,
|
render_mode: self.render_mode,
|
||||||
@@ -545,6 +549,7 @@ impl RequestContext {
|
|||||||
todo_list: TodoList::default(),
|
todo_list: TodoList::default(),
|
||||||
skill_registry: SkillRegistry::default(),
|
skill_registry: SkillRegistry::default(),
|
||||||
last_continuation_response: None,
|
last_continuation_response: None,
|
||||||
|
auto_continue_paused: None,
|
||||||
pending_prefill: None,
|
pending_prefill: None,
|
||||||
session_abort: None,
|
session_abort: None,
|
||||||
render_mode: parent.render_mode,
|
render_mode: parent.render_mode,
|
||||||
@@ -582,6 +587,7 @@ impl RequestContext {
|
|||||||
|
|
||||||
pub fn init_todo_list(&mut self, goal: &str) {
|
pub fn init_todo_list(&mut self, goal: &str) {
|
||||||
self.todo_list = TodoList::new(goal);
|
self.todo_list = TodoList::new(goal);
|
||||||
|
self.auto_continue_paused = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_todo(&mut self, task: &str) -> usize {
|
pub fn add_todo(&mut self, task: &str) -> usize {
|
||||||
@@ -595,12 +601,21 @@ impl RequestContext {
|
|||||||
pub fn clear_todo_list(&mut self) {
|
pub fn clear_todo_list(&mut self) {
|
||||||
self.todo_list.clear();
|
self.todo_list.clear();
|
||||||
self.auto_continue_count = 0;
|
self.auto_continue_count = 0;
|
||||||
|
self.auto_continue_paused = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn increment_auto_continue_count(&mut self) {
|
pub fn increment_auto_continue_count(&mut self) {
|
||||||
self.auto_continue_count += 1;
|
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) {
|
pub fn reset_continuation_count(&mut self) {
|
||||||
self.auto_continue_count = 0;
|
self.auto_continue_count = 0;
|
||||||
self.last_continuation_response = None;
|
self.last_continuation_response = None;
|
||||||
@@ -4676,6 +4691,7 @@ impl RequestContext {
|
|||||||
self.current_depth = 0;
|
self.current_depth = 0;
|
||||||
self.auto_continue_count = 0;
|
self.auto_continue_count = 0;
|
||||||
self.todo_list = TodoList::default();
|
self.todo_list = TodoList::default();
|
||||||
|
self.auto_continue_paused = None;
|
||||||
|
|
||||||
if let Some(session_name) = session_name.as_deref() {
|
if let Some(session_name) = session_name.as_deref() {
|
||||||
self.use_session(app, Some(session_name), abort_signal)
|
self.use_session(app, Some(session_name), abort_signal)
|
||||||
@@ -4719,6 +4735,7 @@ impl RequestContext {
|
|||||||
self.auto_continue_count = 0;
|
self.auto_continue_count = 0;
|
||||||
self.pending_tasks_guardrail_count = 0;
|
self.pending_tasks_guardrail_count = 0;
|
||||||
self.todo_list = TodoList::default();
|
self.todo_list = TodoList::default();
|
||||||
|
self.auto_continue_paused = None;
|
||||||
self.rag.take();
|
self.rag.take();
|
||||||
// Cleared alongside `rag` so the pair never disagrees: an agent RAG is
|
// Cleared alongside `rag` so the pair never disagrees: an agent RAG is
|
||||||
// cached under `RagKey::Agent(<agent name>)`, and leaving that key behind
|
// cached under `RagKey::Agent(<agent name>)`, and leaving that key behind
|
||||||
|
|||||||
+122
-2
@@ -1,3 +1,4 @@
|
|||||||
|
use super::todo::TODO_FUNCTION_PREFIX;
|
||||||
use super::{FunctionDeclaration, JsonSchema};
|
use super::{FunctionDeclaration, JsonSchema};
|
||||||
use crate::client::{Model, ModelType, call_chat_completions};
|
use crate::client::{Model, ModelType, call_chat_completions};
|
||||||
use crate::config::{
|
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::utils::{AbortSignal, create_abort_signal, wait_abort_signal, wait_user_interrupt};
|
||||||
|
|
||||||
use crate::graph;
|
use crate::graph;
|
||||||
|
use crate::repl::DEFAULT_CONTINUATION_PROMPT;
|
||||||
use anyhow::{Context, Result, anyhow, bail};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
@@ -574,7 +576,13 @@ pub fn run_child_agent(
|
|||||||
|
|
||||||
if tool_results.is_empty() {
|
if tool_results.is_empty() {
|
||||||
match check_pending_tasks_guardrail(&mut child_ctx) {
|
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) => {
|
GuardrailAction::ForceTerminate(ids) => {
|
||||||
warn!(
|
warn!(
|
||||||
"Pending-agent guardrail force-cancelled {} agent(s) after max reminders: {:?}",
|
"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
|
/// 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
|
/// 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
|
/// 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;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1650,7 +1693,7 @@ async fn summarize_output(ctx: &RequestContext, agent_name: &str, output: &str)
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::test_fixtures::{FixtureServer, fixture_runtime};
|
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::function::jobs::RingBuf;
|
||||||
use crate::mcp::{McpServer, McpServersConfig, McpTransportType};
|
use crate::mcp::{McpServer, McpServersConfig, McpTransportType};
|
||||||
use crate::supervisor::escalation::{EscalationQueue, EscalationRequest};
|
use crate::supervisor::escalation::{EscalationQueue, EscalationRequest};
|
||||||
@@ -1673,6 +1716,58 @@ mod tests {
|
|||||||
ctx
|
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 {
|
fn ctx_with_job_capable_supervisor() -> RequestContext {
|
||||||
let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd);
|
let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd);
|
||||||
ctx.supervisor = Some(Arc::new(RwLock::new(
|
ctx.supervisor = Some(Arc::new(RwLock::new(
|
||||||
@@ -1840,6 +1935,31 @@ mod tests {
|
|||||||
assert!(!functions.contains("mcp_invoke_fixture"));
|
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> {
|
fn app_state_with_fixture_mcp_config() -> Arc<AppState> {
|
||||||
let mut state = AppState::test_default();
|
let mut state = AppState::test_default();
|
||||||
state.mcp_config = Some(McpServersConfig {
|
state.mcp_config = Some(McpServersConfig {
|
||||||
|
|||||||
@@ -727,6 +727,10 @@ impl Functions {
|
|||||||
.retain(|f| !f.name.starts_with(TODO_FUNCTION_PREFIX));
|
.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) {
|
pub fn append_memory_functions(&mut self) {
|
||||||
self.declarations
|
self.declarations
|
||||||
.extend(memory::memory_function_declarations());
|
.extend(memory::memory_function_declarations());
|
||||||
@@ -3371,6 +3375,7 @@ mod tests {
|
|||||||
assert!(f.contains("todo__done"));
|
assert!(f.contains("todo__done"));
|
||||||
assert!(f.contains("todo__list"));
|
assert!(f.contains("todo__list"));
|
||||||
assert!(f.contains("todo__clear"));
|
assert!(f.contains("todo__clear"));
|
||||||
|
assert!(f.contains("todo__pause"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -4914,6 +4919,79 @@ mod tests {
|
|||||||
assert!(err.contains("Auto-continue is not enabled"), "{err}");
|
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]
|
#[test]
|
||||||
fn eval_routes_memory_prefix_to_memory_handler() {
|
fn eval_routes_memory_prefix_to_memory_handler() {
|
||||||
let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
|
let mut ctx = RequestContext::new(Arc::new(AppState::test_default()), WorkingMode::Cmd);
|
||||||
|
|||||||
@@ -86,6 +86,33 @@ pub fn todo_function_declarations() -> Vec<FunctionDeclaration> {
|
|||||||
},
|
},
|
||||||
agent: false,
|
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();
|
ctx.clear_todo_list();
|
||||||
Ok(json!({"status": "ok", "message": "Todo list cleared"}))
|
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}"),
|
_ => bail!("Unknown todo action: {action}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+20
-1
@@ -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.
|
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\
|
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.
|
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(|| {
|
static REPL_COMMANDS: LazyLock<[ReplCommand; 63]> = LazyLock::new(|| {
|
||||||
@@ -1409,6 +1410,7 @@ pub async fn run_repl_command(
|
|||||||
handle_shell_passthrough(cmd)?;
|
handle_shell_passthrough(cmd)?;
|
||||||
} else {
|
} else {
|
||||||
reset_continuation(ctx);
|
reset_continuation(ctx);
|
||||||
|
ctx.resume_auto_continue();
|
||||||
let input = Input::from_str(ctx, line, None)?;
|
let input = Input::from_str(ctx, line, None)?;
|
||||||
ask(ctx, abort_signal.clone(), input, true).await?;
|
ask(ctx, abort_signal.clone(), input, true).await?;
|
||||||
}
|
}
|
||||||
@@ -1535,6 +1537,22 @@ async fn ask(
|
|||||||
ask(ctx, abort_signal, continuation_input, false).await
|
ask(ctx, abort_signal, continuation_input, false).await
|
||||||
} else {
|
} else {
|
||||||
reset_continuation(ctx);
|
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() {
|
if ctx.maybe_autoname_session() {
|
||||||
let color = if app.light_theme() {
|
let color = if app.light_theme() {
|
||||||
nu_ansi_term::Color::LightGray
|
nu_ansi_term::Color::LightGray
|
||||||
@@ -1621,6 +1639,7 @@ fn should_continue(ctx: &RequestContext) -> bool {
|
|||||||
&& config.enabled
|
&& config.enabled
|
||||||
&& ctx.auto_continue_count < config.max_continues
|
&& ctx.auto_continue_count < config.max_continues
|
||||||
&& ctx.todo_list.has_incomplete()
|
&& ctx.todo_list.has_incomplete()
|
||||||
|
&& ctx.auto_continue_paused.is_none()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reset_continuation(ctx: &mut RequestContext) {
|
fn reset_continuation(ctx: &mut RequestContext) {
|
||||||
|
|||||||
Reference in new issue
Block a user