use std::time::Duration;
use futures::future::BoxFuture;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::{HumanLoopKind, HumanLoopProvider, HumanLoopRequest, HumanLoopResponse, RiskLevel};
use echo_core::error::Result;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchApprovalItem {
pub tool_name: String,
pub args: Value,
pub risk_level: RiskLevel,
pub prompt: String,
}
impl BatchApprovalItem {
pub fn new(tool_name: impl Into<String>, args: Value, risk_level: RiskLevel) -> Self {
let tool_name = tool_name.into();
let prompt = format!("工具 [{}] 需要人工审批({}风险)", tool_name, risk_level);
Self {
tool_name,
args,
risk_level,
prompt,
}
}
}
#[derive(Debug, Clone)]
pub struct BatchApprovalRequest {
pub items: Vec<BatchApprovalItem>,
pub prompt: String,
pub timeout: Option<Duration>,
}
impl BatchApprovalRequest {
pub fn new(items: Vec<BatchApprovalItem>) -> Self {
let prompt = format!(
"共 {} 个工具调用需要审批:\n{}",
items.len(),
items
.iter()
.enumerate()
.map(|(i, item)| {
format!(" {}. {} [{:?}]", i + 1, item.tool_name, item.risk_level)
})
.collect::<Vec<_>>()
.join("\n")
);
Self {
items,
prompt,
timeout: None,
}
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
}
#[derive(Debug, Clone)]
pub enum BatchItemDecision {
Approved,
Rejected { reason: Option<String> },
Skipped,
}
#[derive(Debug, Clone)]
pub enum BatchApprovalResponse {
AllApproved,
AllRejected { reason: Option<String> },
Individual(Vec<BatchItemDecision>),
Timeout,
}
pub trait BatchApprovalProvider: HumanLoopProvider {
fn batch_request(
&self,
batch: BatchApprovalRequest,
) -> BoxFuture<'_, Result<BatchApprovalResponse>> {
Box::pin(async move {
let mut decisions = Vec::with_capacity(batch.items.len());
for item in &batch.items {
let req = HumanLoopRequest {
kind: HumanLoopKind::Approval,
prompt: item.prompt.clone(),
tool_name: Some(item.tool_name.clone()),
args: Some(item.args.clone()),
risk_level: Some(item.risk_level),
timeout: batch.timeout,
};
match self.request(req).await? {
HumanLoopResponse::Approved => {
decisions.push(BatchItemDecision::Approved);
}
HumanLoopResponse::ApprovedWithScope { scope: _ } => {
decisions.push(BatchItemDecision::Approved);
}
HumanLoopResponse::ModifiedArgs { args: _, scope: _ } => {
decisions.push(BatchItemDecision::Approved);
}
HumanLoopResponse::Rejected { reason } => {
decisions.push(BatchItemDecision::Rejected { reason });
}
HumanLoopResponse::Timeout => {
return Ok(BatchApprovalResponse::Timeout);
}
HumanLoopResponse::Deferred => {
decisions.push(BatchItemDecision::Skipped);
}
HumanLoopResponse::Text(_) => {
decisions.push(BatchItemDecision::Skipped);
}
}
}
let all_approved = decisions
.iter()
.all(|d| matches!(d, BatchItemDecision::Approved));
let all_rejected = decisions
.iter()
.all(|d| matches!(d, BatchItemDecision::Rejected { .. }));
if all_approved {
Ok(BatchApprovalResponse::AllApproved)
} else if all_rejected {
Ok(BatchApprovalResponse::AllRejected { reason: None })
} else {
Ok(BatchApprovalResponse::Individual(decisions))
}
})
}
}
impl<T: HumanLoopProvider> BatchApprovalProvider for T {}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_batch_approval_item_new() {
let item = BatchApprovalItem::new("Bash", json!({"cmd": "ls"}), RiskLevel::High);
assert_eq!(item.tool_name, "Bash");
}
#[test]
fn test_batch_approval_request_new() {
let items = vec![
BatchApprovalItem::new("Bash", json!({"cmd": "ls"}), RiskLevel::High),
BatchApprovalItem::new("Write", json!({"path": "/tmp"}), RiskLevel::Medium),
];
let request = BatchApprovalRequest::new(items);
assert_eq!(request.items.len(), 2);
assert!(request.prompt.contains("2"));
assert!(request.prompt.contains("Bash"));
assert!(request.prompt.contains("Write"));
}
#[test]
fn test_batch_approval_response_variants() {
let all_approved = BatchApprovalResponse::AllApproved;
assert!(matches!(all_approved, BatchApprovalResponse::AllApproved));
let all_rejected = BatchApprovalResponse::AllRejected { reason: None };
assert!(matches!(
all_rejected,
BatchApprovalResponse::AllRejected { .. }
));
let individual = BatchApprovalResponse::Individual(vec![
BatchItemDecision::Approved,
BatchItemDecision::Rejected {
reason: Some("test".to_string()),
},
BatchItemDecision::Skipped,
]);
assert!(matches!(individual, BatchApprovalResponse::Individual(_)));
let timeout = BatchApprovalResponse::Timeout;
assert!(matches!(timeout, BatchApprovalResponse::Timeout));
}
}