use async_trait::async_trait;
use clawft_plugin::CancellationToken;
use clawft_types::agent_chat::{DEFER_DEFAULT_TIMEOUT_MS, DeferPromptEvent, DeferUserDecision};
#[derive(Debug, Clone)]
pub struct DeferRequest {
pub conv_id: String,
pub tool: String,
pub reason: String,
pub arguments_preview: String,
pub timeout_ms: u64,
}
impl DeferRequest {
pub fn into_prompt_event(self, defer_id: impl Into<String>) -> DeferPromptEvent {
DeferPromptEvent {
defer_id: defer_id.into(),
conv_id: self.conv_id,
tool: self.tool,
reason: self.reason,
arguments_preview: self.arguments_preview,
timeout_ms: if self.timeout_ms == 0 {
DEFER_DEFAULT_TIMEOUT_MS
} else {
self.timeout_ms
},
ts_ms: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeferOutcome {
Allow,
Deny {
reason: String,
},
Cancel,
Timeout,
Cancelled,
}
impl DeferOutcome {
pub fn from_user(decision: DeferUserDecision, deny_reason: impl Into<String>) -> Self {
match decision {
DeferUserDecision::Allow => Self::Allow,
DeferUserDecision::Deny => Self::Deny {
reason: deny_reason.into(),
},
DeferUserDecision::Cancel => Self::Cancel,
}
}
pub fn tool_result_json(&self, gate_reason: &str) -> Option<String> {
match self {
Self::Allow => None,
Self::Deny { reason } => Some(
serde_json::json!({
"denied": true,
"reason": reason,
})
.to_string(),
),
Self::Cancel => Some(
serde_json::json!({
"deferred": true,
"cancelled": true,
"reason": format!("user cancelled defer: {gate_reason}"),
})
.to_string(),
),
Self::Timeout => Some(
serde_json::json!({
"denied": true,
"reason": format!("defer timed out (default-deny): {gate_reason}"),
"timeout": true,
})
.to_string(),
),
Self::Cancelled => Some(
serde_json::json!({
"denied": true,
"reason": format!("defer aborted (turn cancelled): {gate_reason}"),
"cancelled": true,
})
.to_string(),
),
}
}
}
#[async_trait]
pub trait DeferInteractor: Send + Sync + 'static {
async fn await_decision(
&self,
request: DeferRequest,
cancel: &CancellationToken,
) -> DeferOutcome;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct AlwaysAllowDefer;
#[async_trait]
impl DeferInteractor for AlwaysAllowDefer {
async fn await_decision(
&self,
_request: DeferRequest,
_cancel: &CancellationToken,
) -> DeferOutcome {
DeferOutcome::Allow
}
}
#[derive(Debug, Clone)]
pub struct AlwaysDenyDefer {
pub reason: String,
}
#[async_trait]
impl DeferInteractor for AlwaysDenyDefer {
async fn await_decision(
&self,
_request: DeferRequest,
_cancel: &CancellationToken,
) -> DeferOutcome {
DeferOutcome::Deny {
reason: self.reason.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allow_has_no_tool_result_envelope() {
assert!(DeferOutcome::Allow.tool_result_json("x").is_none());
}
#[test]
fn timeout_is_default_deny() {
let body = DeferOutcome::Timeout
.tool_result_json("needs review")
.unwrap();
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["denied"], true);
assert_eq!(v["timeout"], true);
assert!(v["reason"].as_str().unwrap().contains("timed out"));
}
#[test]
fn from_user_maps_decisions() {
assert_eq!(
DeferOutcome::from_user(DeferUserDecision::Allow, "r"),
DeferOutcome::Allow
);
assert_eq!(
DeferOutcome::from_user(DeferUserDecision::Cancel, "r"),
DeferOutcome::Cancel
);
}
#[tokio::test]
async fn always_allow_interactor() {
let out = AlwaysAllowDefer
.await_decision(
DeferRequest {
conv_id: "c".into(),
tool: "echo".into(),
reason: "review".into(),
arguments_preview: "{}".into(),
timeout_ms: 1_000,
},
&CancellationToken::new(),
)
.await;
assert_eq!(out, DeferOutcome::Allow);
}
}