use std::sync::Arc;
use std::time::Duration;
use crate::escalation::{Decision, EscalationQueue, EscalationRequest, HeldActionKind};
use crate::reasoning::loop_types::{LoopDecision, LoopState, ProposedAction};
use crate::reasoning::policy_bridge::ReasoningPolicyGate;
use crate::types::AgentId;
#[derive(Debug, Clone)]
pub struct EscalationGateConfig {
pub require_approval_tools: Vec<String>,
pub timeout: Duration,
}
pub struct EscalationGate {
inner: Arc<dyn ReasoningPolicyGate>,
queue: Arc<EscalationQueue>,
config: EscalationGateConfig,
}
impl EscalationGate {
pub fn new(
inner: Arc<dyn ReasoningPolicyGate>,
queue: Arc<EscalationQueue>,
config: EscalationGateConfig,
) -> Self {
Self {
inner,
queue,
config,
}
}
fn tool_name(action: &ProposedAction) -> Option<String> {
match action {
ProposedAction::ToolCall { name, .. } => Some(name.clone()),
ProposedAction::Delegate { .. } => {
Some(crate::reasoning::phases::DELEGATE_TOOL_NAME.to_string())
}
_ => None,
}
}
}
#[async_trait::async_trait]
impl ReasoningPolicyGate for EscalationGate {
async fn approve_prepared(
&self,
prepared: &crate::reasoning::prepared::PreparedAction,
state: &LoopState,
config: &crate::reasoning::loop_types::LoopConfig,
) -> Result<Option<crate::reasoning::prepared::ApprovalReceipt>, String> {
let needs_approval = prepared
.contract()
.is_some_and(|contract| contract.requires_approval)
|| Self::tool_name(prepared.action())
.is_some_and(|name| self.config.require_approval_tools.contains(&name));
if !needs_approval {
return self.inner.approve_prepared(prepared, state, config).await;
}
let approval_id = uuid::Uuid::new_v4();
let req = EscalationRequest {
agent_id: state.agent_id.to_string(),
kind: HeldActionKind::ToolCall,
summary: format!(
"tool_call {}",
Self::tool_name(prepared.action()).unwrap_or_default()
),
reason: "exact-call human approval required".into(),
context_snapshot: Some(
serde_json::json!({"approval_id": approval_id, "invocation": prepared.policy_context(), "session_started_at": state.started_at, "iteration": state.iteration}),
),
};
let resolved = self.queue.enqueue_resolved(req, self.config.timeout).await;
match resolved.decision {
Decision::Approve { .. } => crate::reasoning::prepared::ApprovalReceipt::issue(
prepared,
state,
config,
self.config.timeout,
approval_id,
)
.and_then(|receipt| {
receipt.with_resolution(
resolved
.resolution
.ok_or("approval resolution evidence is missing")?,
state.agent_id,
)
})
.map(Some),
Decision::Deny { reason } => Err(reason.unwrap_or_else(|| "denied by operator".into())),
}
}
async fn evaluate_prepared(
&self,
agent_id: &AgentId,
prepared: &crate::reasoning::prepared::PreparedAction,
state: &LoopState,
) -> LoopDecision {
self.inner
.evaluate_prepared(agent_id, prepared, state)
.await
}
async fn evaluate_action(
&self,
agent_id: &AgentId,
action: &ProposedAction,
state: &LoopState,
) -> LoopDecision {
let tool = Self::tool_name(action);
let needs_approval = tool
.as_deref()
.map(|t| self.config.require_approval_tools.iter().any(|r| r == t))
.unwrap_or(false);
if !needs_approval {
return self.inner.evaluate_action(agent_id, action, state).await;
}
let summary = tool.unwrap_or_else(|| "action".into());
let req = EscalationRequest {
agent_id: agent_id.to_string(),
kind: HeldActionKind::ToolCall,
summary: format!("tool_call {summary}"),
reason: "policy requires human approval".to_string(),
context_snapshot: serde_json::to_value(action).ok(),
};
match self.queue.enqueue(req, self.config.timeout).await {
Decision::Approve { .. } => self.inner.evaluate_action(agent_id, action, state).await,
Decision::Deny { reason } => LoopDecision::Deny {
reason: reason.unwrap_or_else(|| "denied by operator".into()),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::escalation::{Approver, Surface};
use crate::reasoning::conversation::Conversation;
use crate::reasoning::loop_types::{LoopState, ProposedAction};
use std::sync::Arc;
use std::time::Duration;
struct AlwaysAllow;
#[async_trait::async_trait]
impl ReasoningPolicyGate for AlwaysAllow {
async fn evaluate_action(
&self,
_a: &AgentId,
_act: &ProposedAction,
_s: &LoopState,
) -> LoopDecision {
LoopDecision::Allow
}
}
fn approver() -> Approver {
Approver {
surface: Surface::Tui,
id: "op1".into(),
display: "op1".into(),
}
}
fn action(tool: &str) -> ProposedAction {
ProposedAction::ToolCall {
call_id: "cid-1".into(),
name: tool.into(),
arguments: "{}".into(),
}
}
fn state(agent: &AgentId) -> LoopState {
LoopState::new(*agent, Conversation::new())
}
fn config() -> EscalationGateConfig {
EscalationGateConfig {
require_approval_tools: vec!["http_post".into()],
timeout: Duration::from_secs(5),
}
}
#[tokio::test]
async fn approval_required_tool_blocks_then_allows_on_approve() {
let q = Arc::new(EscalationQueue::new());
let gate = Arc::new(EscalationGate::new(
Arc::new(AlwaysAllow),
q.clone(),
config(),
));
let agent = AgentId::new();
let act = action("http_post");
let st = state(&agent);
let gate2 = gate.clone();
let agent2 = agent;
let fut = tokio::spawn(async move { gate2.evaluate_action(&agent2, &act, &st).await });
let id = loop {
if let Some(h) = q.list_pending_async().await.first() {
break h.id.clone();
}
tokio::time::sleep(Duration::from_millis(5)).await;
};
q.resolve_async(&id, Decision::Approve { reason: None }, approver())
.await
.unwrap();
assert!(matches!(fut.await.unwrap(), LoopDecision::Allow));
}
#[tokio::test]
async fn non_listed_tool_delegates_to_inner() {
let q = Arc::new(EscalationQueue::new());
let gate = EscalationGate::new(Arc::new(AlwaysAllow), q.clone(), config());
let agent = AgentId::new();
let st = state(&agent);
let d = gate
.evaluate_action(&agent, &action("read_file"), &st)
.await;
assert!(matches!(d, LoopDecision::Allow));
assert!(q.list_pending_async().await.is_empty());
}
fn delegate_action(call_id: &str, target: &str) -> ProposedAction {
ProposedAction::Delegate {
call_id: call_id.into(),
target: target.into(),
message: "hello".into(),
}
}
fn delegate_config() -> EscalationGateConfig {
EscalationGateConfig {
require_approval_tools: vec!["delegate".into()],
timeout: Duration::from_secs(5),
}
}
#[tokio::test]
async fn delegate_action_is_subject_to_approval_allowlist() {
let q = Arc::new(EscalationQueue::new());
let gate = Arc::new(EscalationGate::new(
Arc::new(AlwaysAllow),
q.clone(),
delegate_config(),
));
let agent = AgentId::new();
let act = delegate_action("cid-1", "other_agent");
let st = state(&agent);
let gate2 = gate.clone();
let agent2 = agent;
let fut = tokio::spawn(async move { gate2.evaluate_action(&agent2, &act, &st).await });
let id = loop {
if let Some(h) = q.list_pending_async().await.first() {
break h.id.clone();
}
tokio::time::sleep(Duration::from_millis(5)).await;
};
q.resolve_async(&id, Decision::Approve { reason: None }, approver())
.await
.unwrap();
assert!(matches!(fut.await.unwrap(), LoopDecision::Allow));
}
#[tokio::test]
async fn delegate_action_not_in_allowlist_delegates_to_inner() {
let q = Arc::new(EscalationQueue::new());
let gate = EscalationGate::new(Arc::new(AlwaysAllow), q.clone(), config());
let agent = AgentId::new();
let st = state(&agent);
let d = gate
.evaluate_action(&agent, &delegate_action("cid-2", "other_agent"), &st)
.await;
assert!(matches!(d, LoopDecision::Allow));
assert!(q.list_pending_async().await.is_empty());
}
#[tokio::test]
async fn denied_on_deny() {
let q = Arc::new(EscalationQueue::new());
let gate = Arc::new(EscalationGate::new(
Arc::new(AlwaysAllow),
q.clone(),
config(),
));
let agent = AgentId::new();
let act = action("http_post");
let st = state(&agent);
let gate2 = gate.clone();
let agent2 = agent;
let fut = tokio::spawn(async move { gate2.evaluate_action(&agent2, &act, &st).await });
let id = loop {
if let Some(h) = q.list_pending_async().await.first() {
break h.id.clone();
}
tokio::time::sleep(Duration::from_millis(5)).await;
};
q.resolve_async(
&id,
Decision::Deny {
reason: Some("no".into()),
},
approver(),
)
.await
.unwrap();
assert!(matches!(fut.await.unwrap(), LoopDecision::Deny { .. }));
}
}