Files
coyote/src/function/user_interaction.rs
T

288 lines
10 KiB
Rust

use super::{FunctionDeclaration, JsonSchema};
use crate::config::RequestContext;
use crate::supervisor::escalation::{EscalationRequest, new_escalation_id};
use anyhow::{Result, anyhow, bail};
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 DEFAULT_ESCALATION_TIMEOUT_SECS: u64 = 300;
const CUSTOM_MULTI_CHOICE_ANSWER_OPTION: &str = "Other (custom)";
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(
ctx: &mut RequestContext,
cmd_name: &str,
args: &Value,
) -> Result<Value> {
let action = cmd_name
.strip_prefix(USER_FUNCTION_PREFIX)
.unwrap_or(cmd_name);
let depth = ctx.current_depth;
if depth == 0 {
handle_direct(action, args)
} else {
handle_escalated(ctx, 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 mut options = parse_options(args)?;
options.push(CUSTOM_MULTI_CHOICE_ANSWER_OPTION.to_string());
let mut answer = Select::new(question, options)
.without_filtering()
.with_help_message("↑↓ to move, enter to select")
.prompt()?;
if answer == CUSTOM_MULTI_CHOICE_ANSWER_OPTION {
answer = Text::new("Custom response:").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(&format!("{question}\nYour answer: ")).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(ctx: &RequestContext, 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>> = if args.get("options").is_some() {
Some(parse_options(args)?)
} else {
None
};
let from_agent_id = ctx
.self_agent_id
.clone()
.unwrap_or_else(|| "unknown".to_string());
let from_agent_name = ctx
.agent
.as_ref()
.map(|a| a.name().to_string())
.unwrap_or_else(|| "unknown".to_string());
let root_queue = ctx
.root_escalation_queue()
.cloned()
.ok_or_else(|| anyhow!("No escalation queue available; cannot reach parent agent"))?;
let timeout_secs = ctx
.agent
.as_ref()
.map(|a| a.escalation_timeout())
.unwrap_or(DEFAULT_ESCALATION_TIMEOUT_SECS);
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);
let timeout = Duration::from_secs(timeout_secs);
match tokio::time::timeout(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 {timeout_secs} seconds waiting for user response"
),
"fallback": "Make your best judgment and proceed",
})),
}
}
fn parse_options(args: &Value) -> Result<Vec<String>> {
let raw = args
.get("options")
.ok_or_else(|| anyhow!("'options' is required and must be an array of strings"))?;
let arr: Vec<Value> = match raw {
Value::Array(arr) => arr.clone(),
Value::String(s) => serde_json::from_str::<Vec<Value>>(s).map_err(|_| {
anyhow!(
"'options' was a string but did not parse as a JSON array. \
Pass options as a native JSON array, e.g. [\"yes\", \"no\"]."
)
})?,
_ => bail!("'options' is required and must be an array of strings"),
};
Ok(arr
.iter()
.filter_map(Value::as_str)
.map(String::from)
.collect())
}