diff --git a/src/config/agent.rs b/src/config/agent.rs index bcd0ac8..535aeb3 100644 --- a/src/config/agent.rs +++ b/src/config/agent.rs @@ -8,6 +8,7 @@ use crate::{ use crate::config::prompts::{ DEFAULT_SPAWN_INSTRUCTIONS, DEFAULT_TEAMMATE_INSTRUCTIONS, DEFAULT_TODO_INSTRUCTIONS, + DEFAULT_USER_INTERACTION_INSTRUCTIONS, }; use crate::vault::SECRET_RE; use anyhow::{Context, Result}; @@ -345,6 +346,7 @@ impl Agent { } output.push_str(DEFAULT_TEAMMATE_INSTRUCTIONS); + output.push_str(DEFAULT_USER_INTERACTION_INSTRUCTIONS); self.interpolate_text(&output) } diff --git a/src/config/prompts.rs b/src/config/prompts.rs index 50b3ef8..0f140af 100644 --- a/src/config/prompts.rs +++ b/src/config/prompts.rs @@ -99,3 +99,16 @@ pub(in crate::config) const DEFAULT_TEAMMATE_INSTRUCTIONS: &str = indoc! {" - **Send messages** to teammates when you discover something that affects their work. - Messages are delivered to the agent's inbox and read on their next `check_inbox` call." }; + +pub(in crate::config) const DEFAULT_USER_INTERACTION_INSTRUCTIONS: &str = indoc! {" + ## User Interaction + + You have built-in tools to interact with the user directly: + - `user__ask --question \"...\" --options [\"A\", \"B\", \"C\"]`: Present a selection prompt. Returns the chosen option. + - `user__confirm --question \"...\"`: Ask a yes/no question. Returns \"yes\" or \"no\". + - `user__input --question \"...\"`: Request free-form text input from the user. + - `user__checkbox --question \"...\" --options [\"A\", \"B\", \"C\"]`: Multi-select prompt. Returns an array of selected options. + + Use these tools when you need user decisions, preferences, or clarification. + If you are running as a subagent, these questions are automatically escalated to the root agent for resolution." +}; diff --git a/src/function/mod.rs b/src/function/mod.rs index 128ecf9..4fb60a7 100644 --- a/src/function/mod.rs +++ b/src/function/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod supervisor; pub(crate) mod todo; +pub(crate) mod user_interaction; use crate::{ config::{Agent, Config, GlobalConfig}, @@ -31,6 +32,7 @@ use std::{ use strum_macros::AsRefStr; use supervisor::SUPERVISOR_FUNCTION_PREFIX; use todo::TODO_FUNCTION_PREFIX; +use user_interaction::USER_FUNCTION_PREFIX; #[derive(Embed)] #[folder = "assets/functions/"] @@ -281,6 +283,11 @@ impl Functions { .extend(supervisor::teammate_function_declarations()); } + pub fn append_user_interaction_functions(&mut self) { + self.declarations + .extend(user_interaction::user_interaction_function_declarations()); + } + pub fn clear_mcp_meta_functions(&mut self) { self.declarations.retain(|d| { !d.name.starts_with(MCP_INVOKE_META_FUNCTION_NAME_PREFIX) @@ -907,6 +914,15 @@ impl ToolCall { json!({"tool_call_error": error_msg}) }) } + _ if cmd_name.starts_with(USER_FUNCTION_PREFIX) => { + user_interaction::handle_user_tool(config, &cmd_name, &json_data) + .await + .unwrap_or_else(|e| { + let error_msg = format!("User interaction failed: {e}"); + eprintln!("{}", warning_text(&format!("⚠️ {error_msg} ⚠️"))); + json!({"tool_call_error": error_msg}) + }) + } _ => match run_llm_function(cmd_name, cmd_args, envs, agent_name) { Ok(Some(contents)) => serde_json::from_str(&contents) .ok() diff --git a/src/function/user_interaction.rs b/src/function/user_interaction.rs new file mode 100644 index 0000000..cfd48ff --- /dev/null +++ b/src/function/user_interaction.rs @@ -0,0 +1,267 @@ +use super::{FunctionDeclaration, JsonSchema}; +use crate::config::GlobalConfig; +use crate::supervisor::escalation::{EscalationRequest, new_escalation_id}; + +use anyhow::{Result, anyhow}; +use indexmap::IndexMap; +use inquire::{Confirm, MultiSelect, Select, Text}; +use serde_json::{Value, json}; +use std::time::Duration; +use tokio::sync::oneshot; + +pub const USER_FUNCTION_PREFIX: &str = "user__"; + +const ESCALATION_TIMEOUT: Duration = Duration::from_secs(300); + +pub fn user_interaction_function_declarations() -> Vec { + vec![ + FunctionDeclaration { + name: format!("{USER_FUNCTION_PREFIX}ask"), + description: "Ask the user to select one option from a list. Returns the selected option. Indicate the recommended choice if there is one.".to_string(), + parameters: JsonSchema { + type_value: Some("object".to_string()), + properties: Some(IndexMap::from([ + ( + "question".to_string(), + JsonSchema { + type_value: Some("string".to_string()), + description: Some("The question to present to the user".into()), + ..Default::default() + }, + ), + ( + "options".to_string(), + JsonSchema { + type_value: Some("array".to_string()), + description: Some("List of options for the user to choose from".into()), + items: Some(Box::new(JsonSchema { + type_value: Some("string".to_string()), + ..Default::default() + })), + ..Default::default() + }, + ), + ])), + required: Some(vec!["question".to_string(), "options".to_string()]), + ..Default::default() + }, + agent: false, + }, + FunctionDeclaration { + name: format!("{USER_FUNCTION_PREFIX}confirm"), + description: "Ask the user a yes/no question. Returns \"yes\" or \"no\".".to_string(), + parameters: JsonSchema { + type_value: Some("object".to_string()), + properties: Some(IndexMap::from([( + "question".to_string(), + JsonSchema { + type_value: Some("string".to_string()), + description: Some("The yes/no question to ask the user".into()), + ..Default::default() + }, + )])), + required: Some(vec!["question".to_string()]), + ..Default::default() + }, + agent: false, + }, + FunctionDeclaration { + name: format!("{USER_FUNCTION_PREFIX}input"), + description: "Ask the user for free-form text input. Returns the text entered.".to_string(), + parameters: JsonSchema { + type_value: Some("object".to_string()), + properties: Some(IndexMap::from([( + "question".to_string(), + JsonSchema { + type_value: Some("string".to_string()), + description: Some("The prompt/question to display".into()), + ..Default::default() + }, + )])), + required: Some(vec!["question".to_string()]), + ..Default::default() + }, + agent: false, + }, + FunctionDeclaration { + name: format!("{USER_FUNCTION_PREFIX}checkbox"), + description: "Ask the user to select one or more options from a list. Returns an array of selected options.".to_string(), + parameters: JsonSchema { + type_value: Some("object".to_string()), + properties: Some(IndexMap::from([ + ( + "question".to_string(), + JsonSchema { + type_value: Some("string".to_string()), + description: Some("The question to present to the user".into()), + ..Default::default() + }, + ), + ( + "options".to_string(), + JsonSchema { + type_value: Some("array".to_string()), + description: Some("List of options the user can select from (multiple selections allowed)".into()), + items: Some(Box::new(JsonSchema { + type_value: Some("string".to_string()), + ..Default::default() + })), + ..Default::default() + }, + ), + ])), + required: Some(vec!["question".to_string(), "options".to_string()]), + ..Default::default() + }, + agent: false, + }, + ] +} + +pub async fn handle_user_tool( + config: &GlobalConfig, + cmd_name: &str, + args: &Value, +) -> Result { + let action = cmd_name + .strip_prefix(USER_FUNCTION_PREFIX) + .unwrap_or(cmd_name); + + let depth = config.read().current_depth; + + if depth == 0 { + handle_direct(action, args) + } else { + handle_escalated(config, action, args).await + } +} + +fn handle_direct(action: &str, args: &Value) -> Result { + match action { + "ask" => handle_direct_ask(args), + "confirm" => handle_direct_confirm(args), + "input" => handle_direct_input(args), + "checkbox" => handle_direct_checkbox(args), + _ => Err(anyhow!("Unknown user interaction: {action}")), + } +} + +fn handle_direct_ask(args: &Value) -> Result { + let question = args + .get("question") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("'question' is required"))?; + let options = parse_options(args)?; + + let answer = Select::new(question, options).prompt()?; + + Ok(json!({ "answer": answer })) +} + +fn handle_direct_confirm(args: &Value) -> Result { + let question = args + .get("question") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("'question' is required"))?; + + let answer = Confirm::new(question).with_default(true).prompt()?; + + Ok(json!({ "answer": if answer { "yes" } else { "no" } })) +} + +fn handle_direct_input(args: &Value) -> Result { + let question = args + .get("question") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("'question' is required"))?; + + let answer = Text::new(question).prompt()?; + + Ok(json!({ "answer": answer })) +} + +fn handle_direct_checkbox(args: &Value) -> Result { + let question = args + .get("question") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("'question' is required"))?; + let options = parse_options(args)?; + + let answers = MultiSelect::new(question, options).prompt()?; + + Ok(json!({ "answers": answers })) +} + +async fn handle_escalated(config: &GlobalConfig, action: &str, args: &Value) -> Result { + let question = args + .get("question") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("'question' is required"))? + .to_string(); + + let options: Option> = args.get("options").and_then(Value::as_array).map(|arr| { + arr.iter() + .filter_map(Value::as_str) + .map(String::from) + .collect() + }); + + let (from_agent_id, from_agent_name, root_queue) = { + let cfg = config.read(); + let agent_id = cfg + .self_agent_id + .clone() + .unwrap_or_else(|| "unknown".to_string()); + let agent_name = cfg + .agent + .as_ref() + .map(|a| a.name().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let queue = cfg + .root_escalation_queue + .clone() + .ok_or_else(|| anyhow!("No escalation queue available; cannot reach parent agent"))?; + (agent_id, agent_name, queue) + }; + + let escalation_id = new_escalation_id(); + let (tx, rx) = oneshot::channel(); + + let request = EscalationRequest { + id: escalation_id.clone(), + from_agent_id, + from_agent_name: from_agent_name.clone(), + question: format!("[{action}] {question}"), + options, + reply_tx: tx, + }; + + root_queue.submit(request); + + match tokio::time::timeout(ESCALATION_TIMEOUT, rx).await { + Ok(Ok(reply)) => Ok(json!({ "answer": reply })), + Ok(Err(_)) => Ok(json!({ + "error": "Escalation was cancelled. The parent agent dropped the request", + "fallback": "Make your best judgment and proceed", + })), + Err(_) => Ok(json!({ + "error": format!( + "Escalation timed out after {} seconds waiting for user response", + ESCALATION_TIMEOUT.as_secs() + ), + "fallback": "Make your best judgment and proceed", + })), + } +} + +fn parse_options(args: &Value) -> Result> { + args.get("options") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(Value::as_str) + .map(String::from) + .collect() + }) + .ok_or_else(|| anyhow!("'options' is required and must be an array of strings")) +}