use std::io::Write as _;
use std::time::Duration;
use futures::future::BoxFuture;
use tokio::io::{AsyncBufReadExt, BufReader};
use super::{
ApprovalScope, HumanLoopKind, HumanLoopProvider, HumanLoopRequest, HumanLoopResponse, RiskLevel,
};
use echo_core::error::Result;
pub struct ConsoleHumanLoopProvider;
impl HumanLoopProvider for ConsoleHumanLoopProvider {
fn request(&self, req: HumanLoopRequest) -> BoxFuture<'_, Result<HumanLoopResponse>> {
Box::pin(async move {
match req.kind {
HumanLoopKind::Approval => handle_approval(req).await,
HumanLoopKind::Input => handle_input(req).await,
}
})
}
}
async fn handle_approval(req: HumanLoopRequest) -> Result<HumanLoopResponse> {
let risk_level = req.risk_level.unwrap_or(RiskLevel::Medium);
let tool_name = req.tool_name.as_deref().unwrap_or("unknown");
print_approval_banner(tool_name, risk_level);
if let Some(args) = &req.args {
print_args(args);
}
print_actions(risk_level, req.timeout.as_ref());
let input = read_line_with_timeout(req.timeout).await?;
let trimmed = input.trim().to_lowercase();
match trimmed.as_str() {
"y" | "yes" | "" => {
println!(" Approved");
Ok(HumanLoopResponse::Approved)
}
"s" | "session" => {
println!(" Approved for this session");
Ok(HumanLoopResponse::ApprovedWithScope {
scope: ApprovalScope::Session,
})
}
"a" | "all" => {
println!(" Approved all tools for this session");
Ok(HumanLoopResponse::ApprovedWithScope {
scope: ApprovalScope::SessionAllTools,
})
}
"e" | "edit" => handle_edit_args(&req).await,
"d" | "defer" => {
println!(" Deferred");
Ok(HumanLoopResponse::Deferred)
}
_ => {
let reason = if trimmed.is_empty() {
None
} else {
Some(format!("user input: {trimmed}"))
};
println!(" Rejected");
Ok(HumanLoopResponse::Rejected { reason })
}
}
}
async fn handle_input(req: HumanLoopRequest) -> Result<HumanLoopResponse> {
println!();
println!(" Agent requests input:");
println!(" {}", req.prompt);
println!();
if let Some(timeout) = &req.timeout {
print!(" [timeout: {}s] > ", timeout.as_secs());
} else {
print!(" > ");
}
let _ = std::io::stdout().flush();
let input = read_line_with_timeout(req.timeout).await?;
Ok(HumanLoopResponse::Text(input.trim().to_string()))
}
async fn handle_edit_args(req: &HumanLoopRequest) -> Result<HumanLoopResponse> {
let original_args = req.args.clone().unwrap_or_default();
let original_json = serde_json::to_string_pretty(&original_args).unwrap_or_default();
println!();
println!(" --- Edit parameters ---");
println!(" Current JSON (press Enter to keep, or paste new JSON):");
println!();
for line in original_json.lines() {
println!(" {line}");
}
println!();
println!(" New JSON (empty line = keep original):");
print!(" > ");
let _ = std::io::stdout().flush();
let input = read_line_with_timeout(req.timeout).await?;
let trimmed = input.trim();
let new_args = if trimmed.is_empty() {
original_args
} else {
match serde_json::from_str::<serde_json::Value>(trimmed) {
Ok(v) => {
println!(" Parameters updated, approved");
v
}
Err(e) => {
println!(" Invalid JSON: {e}, using original parameters");
original_args
}
}
};
println!();
println!(" Scope: (o) once (s) session (a) all tools");
print!(" > ");
let _ = std::io::stdout().flush();
let scope_input = read_line_with_timeout(req.timeout).await?;
let scope = match scope_input.trim().to_lowercase().as_str() {
"s" | "session" => ApprovalScope::Session,
"a" | "all" => ApprovalScope::SessionAllTools,
_ => ApprovalScope::Once,
};
Ok(HumanLoopResponse::ModifiedArgs {
args: new_args,
scope,
})
}
fn print_approval_banner(tool_name: &str, risk_level: RiskLevel) {
let (icon, label): (&str, &str) = match risk_level {
RiskLevel::Low => (" [LOW]", "low risk"),
RiskLevel::Medium => (" [MED]", "medium risk"),
RiskLevel::High => (" [HIGH]", "high risk"),
RiskLevel::Critical => (" [CRIT]", "CRITICAL risk"),
};
let inner_width: usize = 56;
let title = format!("{icon} Tool approval request [{label}]");
let inner_width = inner_width.saturating_sub(4);
println!();
println!(" +{:-<inner_width$}+", "");
println!(" | {:<inner_width$} |", title);
println!(" +{:-<inner_width$}+", "");
println!(" Tool: {tool_name}");
}
fn print_args(args: &serde_json::Value) {
let args_str = serde_json::to_string_pretty(args).unwrap_or_default();
let lines: Vec<&str> = args_str.lines().collect();
println!(" Parameters:");
let display_limit = 15;
for line in lines.iter().take(display_limit) {
println!(" {line}");
}
if lines.len() > display_limit {
println!(" ... ({} more lines)", lines.len() - display_limit);
}
println!();
}
fn print_actions(risk_level: RiskLevel, timeout: Option<&Duration>) {
let risk_note = if risk_level == RiskLevel::Critical {
" WARNING: Critical risk — requires explicit approval + reason"
} else {
""
};
if !risk_note.is_empty() {
println!("{risk_note}");
println!();
}
print!(" Actions: (y) approve (s) session (a) all (e) edit (n) reject (d) defer");
if let Some(dur) = timeout {
print!(" [timeout: {}s]", dur.as_secs());
}
println!();
print!(" > ");
let _ = std::io::stdout().flush();
}
async fn read_line_with_timeout(timeout: Option<Duration>) -> Result<String> {
match timeout {
Some(dur) => match tokio::time::timeout(dur, read_line()).await {
Ok(result) => result,
Err(_) => {
println!();
println!(" Timeout");
Err(echo_core::error::ReactError::Other(
"Approval timeout".to_string(),
))
}
},
None => read_line().await,
}
}
async fn read_line() -> Result<String> {
let stdin = tokio::io::stdin();
let mut reader = BufReader::new(stdin);
let mut buf = String::new();
reader.read_line(&mut buf).await?;
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_print_approval_banner_low() {
print_approval_banner("Read", RiskLevel::Low);
print_approval_banner("Bash", RiskLevel::Medium);
print_approval_banner("Bash", RiskLevel::High);
print_approval_banner("DangerousOp", RiskLevel::Critical);
}
#[test]
fn test_print_args_simple() {
print_args(&json!({"path": "/tmp/test"}));
}
#[test]
fn test_print_args_large() {
let mut map = serde_json::Map::new();
for i in 0..20 {
map.insert(format!("key_{i}"), json!(format!("value_{i}")));
}
print_args(&serde_json::Value::Object(map));
}
#[test]
fn test_print_actions_no_timeout() {
print_actions(RiskLevel::Medium, None);
print_actions(RiskLevel::Critical, Some(&Duration::from_secs(30)));
}
#[test]
fn test_human_loop_request_approval_with_risk() {
let req =
HumanLoopRequest::approval_with_risk("Bash", json!({"cmd": "ls"}), RiskLevel::High);
assert_eq!(req.kind, HumanLoopKind::Approval);
assert_eq!(req.risk_level, Some(RiskLevel::High));
}
#[test]
fn test_human_loop_request_approval_with_timeout() {
let req = HumanLoopRequest::approval_with_timeout(
"Bash",
json!({"cmd": "ls"}),
Duration::from_secs(10),
);
assert_eq!(req.kind, HumanLoopKind::Approval);
assert_eq!(req.timeout, Some(Duration::from_secs(10)));
}
}