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::{
|
use crate::config::prompts::{
|
||||||
DEFAULT_SPAWN_INSTRUCTIONS, DEFAULT_TEAMMATE_INSTRUCTIONS, DEFAULT_TODO_INSTRUCTIONS,
|
DEFAULT_SPAWN_INSTRUCTIONS, DEFAULT_TEAMMATE_INSTRUCTIONS, DEFAULT_TODO_INSTRUCTIONS,
|
||||||
|
DEFAULT_USER_INTERACTION_INSTRUCTIONS,
|
||||||
};
|
};
|
||||||
use crate::vault::SECRET_RE;
|
use crate::vault::SECRET_RE;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -345,6 +346,7 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
output.push_str(DEFAULT_TEAMMATE_INSTRUCTIONS);
|
output.push_str(DEFAULT_TEAMMATE_INSTRUCTIONS);
|
||||||
|
output.push_str(DEFAULT_USER_INTERACTION_INSTRUCTIONS);
|
||||||
|
|
||||||
self.interpolate_text(&output)
|
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.
|
- **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."
|
- 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 supervisor;
|
||||||
pub(crate) mod todo;
|
pub(crate) mod todo;
|
||||||
|
pub(crate) mod user_interaction;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{Agent, Config, GlobalConfig},
|
config::{Agent, Config, GlobalConfig},
|
||||||
@@ -31,6 +32,7 @@ use std::{
|
|||||||
use strum_macros::AsRefStr;
|
use strum_macros::AsRefStr;
|
||||||
use supervisor::SUPERVISOR_FUNCTION_PREFIX;
|
use supervisor::SUPERVISOR_FUNCTION_PREFIX;
|
||||||
use todo::TODO_FUNCTION_PREFIX;
|
use todo::TODO_FUNCTION_PREFIX;
|
||||||
|
use user_interaction::USER_FUNCTION_PREFIX;
|
||||||
|
|
||||||
#[derive(Embed)]
|
#[derive(Embed)]
|
||||||
#[folder = "assets/functions/"]
|
#[folder = "assets/functions/"]
|
||||||
@@ -281,6 +283,11 @@ impl Functions {
|
|||||||
.extend(supervisor::teammate_function_declarations());
|
.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) {
|
pub fn clear_mcp_meta_functions(&mut self) {
|
||||||
self.declarations.retain(|d| {
|
self.declarations.retain(|d| {
|
||||||
!d.name.starts_with(MCP_INVOKE_META_FUNCTION_NAME_PREFIX)
|
!d.name.starts_with(MCP_INVOKE_META_FUNCTION_NAME_PREFIX)
|
||||||
@@ -907,6 +914,15 @@ impl ToolCall {
|
|||||||
json!({"tool_call_error": error_msg})
|
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) {
|
_ => match run_llm_function(cmd_name, cmd_args, envs, agent_name) {
|
||||||
Ok(Some(contents)) => serde_json::from_str(&contents)
|
Ok(Some(contents)) => serde_json::from_str(&contents)
|
||||||
.ok()
|
.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