use std::time::Duration;
use crate::core::model_client::ModelClient;
use crate::models::{
ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt, Usage,
is_incomplete_stop_reason,
};
use crate::tools::spec::ToolError;
use crate::tui::auto_review::{
AutoReviewAction, DEFAULT_GUARDIAN_POLICY, ReviewerRiskLevel, ReviewerVerdict,
parse_reviewer_verdict,
};
use tokio_util::sync::CancellationToken;
const REVIEWER_TIMEOUT: Duration = Duration::from_secs(90);
const MAX_REVIEW_CONTEXT_BYTES: usize = 64 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ReviewerOutcome {
Allow {
risk: ReviewerRiskLevel,
reason: String,
},
Deny {
risk: ReviewerRiskLevel,
reason: String,
},
Unavailable {
reason: String,
},
Cancelled,
}
impl ReviewerOutcome {
pub(crate) fn audit_decision(&self) -> &'static str {
match self {
Self::Allow { .. } => "allow",
Self::Deny { .. } => "deny",
Self::Unavailable { .. } => "unavailable",
Self::Cancelled => "cancelled",
}
}
pub(crate) fn audit_risk(&self) -> Option<&'static str> {
match self {
Self::Allow { risk, .. } | Self::Deny { risk, .. } => Some(risk.as_str()),
Self::Unavailable { .. } | Self::Cancelled => None,
}
}
pub(crate) fn into_tool_result(self, tool_name: &str) -> Result<String, ToolError> {
match self {
Self::Allow { reason, .. } => Ok(reason),
Self::Deny { reason, .. } => Err(ToolError::permission_denied(format!(
"Auto-Review guardian denied tool '{tool_name}': {reason}. Do not work around this denial; find a materially safer path or stop."
))),
Self::Unavailable { reason } => Err(ToolError::permission_denied(format!(
"Auto-Review guardian unavailable ({reason}); the call was denied (fail closed). Switch to Ask to review this call yourself."
))),
Self::Cancelled => Err(ToolError::cancelled(
"Auto-Review guardian request cancelled",
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ReviewerResult {
pub(crate) outcome: ReviewerOutcome,
pub(crate) usage: Option<Usage>,
}
impl ReviewerResult {
fn finish(outcome: ReviewerOutcome, usage: Option<Usage>) -> Self {
Self { outcome, usage }
}
fn unavailable(reason: impl Into<String>, usage: Option<Usage>) -> Self {
Self::finish(
ReviewerOutcome::Unavailable {
reason: reason.into(),
},
usage,
)
}
}
pub(crate) async fn consult_reviewer(
client: &dyn ModelClient,
context_text: &str,
cancel_token: &CancellationToken,
) -> ReviewerResult {
if context_text.len() > MAX_REVIEW_CONTEXT_BYTES {
return ReviewerResult::unavailable(
"the exact review context exceeded the guardian limit",
None,
);
}
let request = MessageRequest {
model: client.model().to_string(),
messages: vec![Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: context_text.to_string(),
cache_control: None,
}],
}],
max_tokens: 384,
system: Some(SystemPrompt::Text(DEFAULT_GUARDIAN_POLICY.to_string())),
tools: None,
tool_choice: None,
metadata: None,
thinking: None,
reasoning_effort: None,
stream: Some(false),
temperature: Some(0.0),
top_p: None,
};
if cancel_token.is_cancelled() {
return ReviewerResult::finish(ReviewerOutcome::Cancelled, None);
}
let response = tokio::select! {
biased;
_ = cancel_token.cancelled() => {
return ReviewerResult::finish(ReviewerOutcome::Cancelled, None);
}
response = tokio::time::timeout(REVIEWER_TIMEOUT, client.create_message(request)) => response,
};
let response = match response {
Err(_) => return ReviewerResult::unavailable("the reviewer timed out", None),
Ok(Err(_)) => return ReviewerResult::unavailable("the reviewer request failed", None),
Ok(Ok(response)) => response,
};
let outcome = verdict_from_response(&response);
ReviewerResult::finish(outcome, Some(response.usage))
}
fn verdict_from_response(response: &MessageResponse) -> ReviewerOutcome {
if is_incomplete_stop_reason(response.stop_reason.as_deref()) {
return ReviewerOutcome::Unavailable {
reason: "the reviewer answer was incomplete".to_string(),
};
}
let text: String = response
.content
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
match parse_reviewer_verdict(&text) {
Some(ReviewerVerdict {
action: AutoReviewAction::Allow,
risk,
reason,
}) if risk.may_auto_run() => ReviewerOutcome::Allow { risk, reason },
Some(ReviewerVerdict {
action: AutoReviewAction::Allow,
risk,
..
}) => ReviewerOutcome::Deny {
risk,
reason: format!(
"the reviewer classified the call as {} risk, which Auto-Review cannot run automatically",
risk.as_str()
),
},
Some(ReviewerVerdict {
action: AutoReviewAction::Block,
risk,
reason,
}) => ReviewerOutcome::Deny { risk, reason },
Some(_) | None => ReviewerOutcome::Unavailable {
reason: format!("the reviewer answer was unparseable ({} chars)", text.len()),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm_client::mock::MockLlmClient;
fn response(text: &str, usage: Usage) -> MessageResponse {
MessageResponse {
id: "review".to_string(),
r#type: "message".to_string(),
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: text.to_string(),
cache_control: None,
}],
model: "mock-model".to_string(),
stop_reason: Some("end_turn".to_string()),
stop_sequence: None,
container: None,
usage,
}
}
#[tokio::test]
async fn reviewer_records_usage_and_keeps_context_untrusted() {
let usage = Usage {
input_tokens: 17,
output_tokens: 9,
..Usage::default()
};
let mock = MockLlmClient::new(Vec::new());
mock.push_message_response(response(
r#"{"risk_level":"low","decision":"allow","reason":"bounded and authorized"}"#,
usage.clone(),
));
let result = consult_reviewer(
&mock,
r#"{"proposed_tool_call":{"tool":"exec_shell"}}"#,
&CancellationToken::new(),
)
.await;
assert_eq!(
result.outcome,
ReviewerOutcome::Allow {
risk: ReviewerRiskLevel::Low,
reason: "bounded and authorized".to_string()
}
);
assert_eq!(result.usage, Some(usage));
let request = mock.last_request().expect("reviewer request");
assert_eq!(request.tools, None, "guardian requests never expose tools");
let SystemPrompt::Text(system) = request.system.expect("guardian policy") else {
panic!("guardian system prompt must be text");
};
assert!(system.contains("Never infer user intent"));
assert!(!system.contains("Prefer reversible work."));
}
#[tokio::test]
async fn reviewer_distinguishes_denial_malformed_and_incomplete_answers() {
let deny = MockLlmClient::new(Vec::new());
deny.push_message_response(response(
r#"{"risk_level":"high","decision":"deny","reason":"destination is not authorized"}"#,
Usage::default(),
));
assert_eq!(
consult_reviewer(&deny, "context", &CancellationToken::new())
.await
.outcome,
ReviewerOutcome::Deny {
risk: ReviewerRiskLevel::High,
reason: "destination is not authorized".to_string()
}
);
assert!(matches!(
verdict_from_response(&response(
r#"{"risk_level":"high","decision":"allow","reason":"the request mentions it"}"#,
Usage::default(),
)),
ReviewerOutcome::Deny {
risk: ReviewerRiskLevel::High,
..
}
));
let malformed = MockLlmClient::new(Vec::new());
malformed.push_message_response(response("allow it", Usage::default()));
let malformed_result =
consult_reviewer(&malformed, "context", &CancellationToken::new()).await;
assert!(matches!(
malformed_result.outcome,
ReviewerOutcome::Unavailable { .. }
));
assert!(malformed_result.usage.is_some());
let incomplete = MockLlmClient::new(Vec::new());
let mut incomplete_response = response(
r#"{"risk_level":"low","decision":"allow","reason":"looks safe"}"#,
Usage::default(),
);
incomplete_response.stop_reason = Some("max_tokens".to_string());
incomplete.push_message_response(incomplete_response);
assert_eq!(
consult_reviewer(&incomplete, "context", &CancellationToken::new())
.await
.outcome,
ReviewerOutcome::Unavailable {
reason: "the reviewer answer was incomplete".to_string()
}
);
}
#[tokio::test]
async fn reviewer_cancellation_aborts_without_calling_provider() {
let mock = MockLlmClient::new(Vec::new());
let cancel = CancellationToken::new();
cancel.cancel();
let result = consult_reviewer(&mock, "context", &cancel).await;
assert_eq!(result.outcome, ReviewerOutcome::Cancelled);
assert!(result.usage.is_none());
assert_eq!(mock.call_count(), 0);
}
#[tokio::test]
async fn reviewer_denies_oversized_exact_context_without_calling_provider() {
let mock = MockLlmClient::new(Vec::new());
let context = "x".repeat(MAX_REVIEW_CONTEXT_BYTES + 1);
let result = consult_reviewer(&mock, &context, &CancellationToken::new()).await;
assert_eq!(
result.outcome,
ReviewerOutcome::Unavailable {
reason: "the exact review context exceeded the guardian limit".to_string()
}
);
assert!(result.usage.is_none());
assert_eq!(mock.call_count(), 0);
}
}