use crate::emit::emit_ops_event;
use crate::escalation::Severity;
use crate::gates::{
ApprovalError, ApprovalOutcome, ApprovalTimeoutPolicy, Gate, GateDecision, GateRequest,
};
use crate::ops_event::OpsEvent;
use crate::tenant::current_tenant;
use async_trait::async_trait;
use klieo_core::error::ToolError;
use klieo_core::ids::RunId;
use klieo_core::llm::ToolDef;
use klieo_core::memory::EpisodicMemory;
use klieo_core::tool::{ToolCtx, ToolInvoker};
use std::sync::Arc;
use std::time::Duration;
const DEFAULT_APPROVAL_TIMEOUT: Duration = Duration::from_secs(600);
pub struct GatedToolInvoker {
inner: Arc<dyn ToolInvoker>,
gates: Vec<Arc<dyn Gate>>,
episodic: Arc<dyn EpisodicMemory>,
run_id: RunId,
approval_timeout: Duration,
approval_timeout_policy: ApprovalTimeoutPolicy,
}
impl GatedToolInvoker {
#[must_use]
pub fn new(
inner: Arc<dyn ToolInvoker>,
gates: Vec<Arc<dyn Gate>>,
episodic: Arc<dyn EpisodicMemory>,
run_id: RunId,
) -> Self {
Self {
inner,
gates,
episodic,
run_id,
approval_timeout: DEFAULT_APPROVAL_TIMEOUT,
approval_timeout_policy: ApprovalTimeoutPolicy::default(),
}
}
#[must_use]
pub fn with_approval_timeout(mut self, timeout: Duration) -> Self {
self.approval_timeout = timeout;
self
}
#[must_use]
pub fn with_approval_timeout_policy(mut self, policy: ApprovalTimeoutPolicy) -> Self {
self.approval_timeout_policy = policy;
self
}
async fn audit(&self, tool: &str, gate: &str, decision: &str, reason: Option<String>) {
if let Err(err) = emit_ops_event(
&*self.episodic,
self.run_id,
OpsEvent::GateDecision {
tenant: current_tenant(),
tool: tool.into(),
decision: decision.into(),
gate: gate.into(),
policy_ref: None,
reason,
},
)
.await
{
tracing::warn!(
target: "klieo.ops.audit",
error = %err,
"audit emit failed; episode not recorded"
);
}
}
async fn emit_escalation_state_changed(
&self,
ticket: &str,
from: &str,
to: &str,
reason: &str,
) {
if let Err(err) = emit_ops_event(
&*self.episodic,
self.run_id,
OpsEvent::EscalationStateChanged {
tenant: current_tenant(),
ticket: ticket.into(),
from: from.into(),
to: to.into(),
reason: Some(reason.into()),
},
)
.await
{
tracing::warn!(
target: "klieo.ops.audit",
error = %err,
"audit emit failed (EscalationStateChanged)"
);
}
}
async fn emit_escalation_resolved(&self, ticket: &str, outcome: &str, reason: &str) {
if let Err(err) = emit_ops_event(
&*self.episodic,
self.run_id,
OpsEvent::EscalationResolved {
tenant: current_tenant(),
ticket: ticket.into(),
outcome: outcome.into(),
reason: Some(reason.into()),
},
)
.await
{
tracing::warn!(
target: "klieo.ops.audit",
error = %err,
"audit emit failed (EscalationResolved)"
);
}
}
async fn wait_with_requeue(
&self,
name: &str,
gate: &Arc<dyn Gate>,
ticket: String,
) -> Result<(), ToolError> {
let max_requeues = match self.approval_timeout_policy {
ApprovalTimeoutPolicy::Deny => {
return self.wait_once(name, gate, &ticket).await.map(|_| ());
}
ApprovalTimeoutPolicy::RequeueAndEscalate { max_requeues } => max_requeues,
};
let mut attempts: u8 = 0;
let mut severity = Severity::High;
loop {
match gate
.wait_for_approval(ticket.clone(), self.approval_timeout)
.await
{
Ok(ApprovalOutcome::Allow) => {
self.audit(
name,
gate.name(),
"approved",
Some(format!("ticket={ticket}")),
)
.await;
return Ok(());
}
Ok(ApprovalOutcome::Deny { code, reason }) => {
self.audit(name, gate.name(), "approval_denied", Some(reason.clone()))
.await;
return Err(ToolError::Permanent(format!(
"gate `{}` approval denied: code={code} reason={reason}",
gate.name()
)));
}
Err(ApprovalError::TimedOut { millis }) => {
attempts += 1;
if attempts > max_requeues {
self.emit_escalation_resolved(
&ticket,
"timed_out",
"max requeues exhausted",
)
.await;
return Err(ToolError::Permanent(format!(
"gate `{}` approval timed out after {millis}ms; \
max requeues ({max_requeues}) exhausted",
gate.name()
)));
}
let from_label = severity_label(severity);
severity = severity.escalate_one_level();
let to_label = severity_label(severity);
self.emit_escalation_state_changed(
&ticket,
from_label,
to_label,
&format!("timeout requeue {attempts}/{max_requeues}"),
)
.await;
self.audit(
name,
gate.name(),
"approval_timed_out_requeue",
Some(format!(
"attempt {attempts}/{max_requeues} after {millis}ms"
)),
)
.await;
}
Err(err) => return Err(self.terminal_approval_error(gate.name(), err)),
}
}
}
async fn wait_once(
&self,
name: &str,
gate: &Arc<dyn Gate>,
ticket: &str,
) -> Result<ApprovalOutcome, ToolError> {
match gate
.wait_for_approval(ticket.to_string(), self.approval_timeout)
.await
{
Ok(outcome @ ApprovalOutcome::Allow) => {
self.audit(
name,
gate.name(),
"approved",
Some(format!("ticket={ticket}")),
)
.await;
Ok(outcome)
}
Ok(ApprovalOutcome::Deny { code, reason }) => {
self.audit(name, gate.name(), "approval_denied", Some(reason.clone()))
.await;
Err(ToolError::Permanent(format!(
"gate `{}` approval denied: code={code} reason={reason}",
gate.name()
)))
}
Err(ApprovalError::TimedOut { millis }) => {
self.audit(
name,
gate.name(),
"approval_timed_out",
Some(format!("after {millis}ms")),
)
.await;
Err(ToolError::Permanent(format!(
"gate `{}` approval timed out after {millis}ms",
gate.name()
)))
}
Err(err) => Err(self.terminal_approval_error(gate.name(), err)),
}
}
fn terminal_approval_error(&self, gate_name: &str, err: ApprovalError) -> ToolError {
match err {
ApprovalError::Denied(reason) => {
ToolError::Permanent(format!("gate `{gate_name}` approval denied: {reason}"))
}
ApprovalError::Halted => {
ToolError::Permanent(format!("gate `{gate_name}` approval halted (kill-switch)"))
}
ApprovalError::NotSupported => ToolError::Permanent(format!(
"gate `{gate_name}` requires approval but does not support waiting; \
register a FourEyesGate or remove the dual_control classifier"
)),
ApprovalError::VerificationFailed(msg) => ToolError::Permanent(format!(
"gate `{gate_name}` approver verification failed: {msg}"
)),
ApprovalError::TimedOut { millis } => ToolError::Permanent(format!(
"gate `{gate_name}` approval timed out after {millis}ms"
)),
}
}
}
#[async_trait]
impl ToolInvoker for GatedToolInvoker {
async fn invoke(
&self,
name: &str,
args: serde_json::Value,
ctx: ToolCtx,
) -> Result<serde_json::Value, ToolError> {
let req = GateRequest::new(name, args.clone());
for gate in &self.gates {
match gate.evaluate(req.clone()).await {
GateDecision::Allow => {
self.audit(name, gate.name(), "allow", None).await;
}
GateDecision::Deny { code, reason } => {
self.audit(name, gate.name(), "deny", Some(reason.clone()))
.await;
return Err(ToolError::Permanent(format!(
"gate `{}` denied: code={code} reason={reason}",
gate.name()
)));
}
GateDecision::RequireApproval { ticket, quorum } => {
self.audit(
name,
gate.name(),
"require_approval",
Some(format!("ticket={ticket} quorum={quorum}")),
)
.await;
self.wait_with_requeue(name, gate, ticket).await?;
}
}
}
self.inner.invoke(name, args, ctx).await
}
fn catalogue(&self) -> Vec<ToolDef> {
self.inner.catalogue()
}
}
fn severity_label(s: Severity) -> &'static str {
match s {
Severity::Low => "low",
Severity::Medium => "medium",
Severity::High => "high",
Severity::Critical => "critical",
}
}