feat: built-in user interaction tools to remove the need for the list/confirm/etc prompts in prompt tools and to enhance user interactions in Loki
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<FunctionDeclaration> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let question = args
|
||||
.get("question")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("'question' is required"))?
|
||||
.to_string();
|
||||
|
||||
let options: Option<Vec<String>> = 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<Vec<String>> {
|
||||
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"))
|
||||
}
|
||||
Reference in New Issue
Block a user