//! The agent-callable surface: `request_approval`, a Rig tool the agent invokes //! when it judges that an action needs a human. //! //! Denial returns `Ok(String)` - the model sees it as policy feedback it can //! reason about ("do not proceed"). Non-decisions (timeout, cancellation) and //! channel errors return `Err(ToolError)` - the model sees an infrastructure //! failure. This aligns with the config-gate path in `gate.rs`. use std::sync::Arc; use rig::completion::ToolDefinition; use rig::tool::{Tool, ToolError}; use serde::{Deserialize, Serialize}; use serde_json::Value; use super::decision::{AgentScope, ApprovalDecision, ApprovalOrigin, ApprovalOutcome, DecisionId}; use super::protocol::{ApprovalItem, ApprovalRequest, PROTOCOL_VERSION}; use super::route::{ApprovalError, DecisionRoute}; /// The `request_approval` tool. Constructs an /// [`ApprovalOrigin::AgentRequested`] and dispatches through the shared /// [`DecisionRoute`]. /// /// [`ApprovalOrigin::AgentRequested`]: super::decision::ApprovalOrigin::AgentRequested #[derive(Clone)] pub struct RequestApprovalTool { route: Arc, scope: AgentScope, run: Arc, agent_name: String, /// Instance ID of the AURA process that built this tool. instance_id: String, } impl RequestApprovalTool { #[must_use] pub fn new( route: Arc, scope: AgentScope, agent_name: String, instance_id: String, ) -> Self { Self { route, scope, // A worker's tool is built inside its run; a single agent's is built // before one exists and is bound by `begin_run`. run: Arc::new(crate::run_context::BoundRun::captured()), agent_name, instance_id, } } /// Names the run this tool's approvals belong to. Clones share the slot, /// so this also binds the clone rig holds in its toolset. pub fn bind_run(&self, run: Arc) { self.run.bind(run); } fn run(&self) -> Option> { self.run.get() } } /// Arguments the LLM provides when calling `request_approval`. #[derive(Debug, Deserialize, Serialize)] pub struct RequestApprovalArgs { /// What the agent wants to do (the action awaiting approval). pub action_description: String, /// Why the agent is asking for approval. pub risk_rationale: String, /// Optional structured metadata for the reviewer. #[serde(default)] pub context: Option, /// The agent's reasoning for requesting approval. #[serde(default, rename = "_aura_reasoning", skip_serializing)] pub tool_call_intent: Option, } /// Map [`DecisionRoute::decide`] outcome to [`Tool::Output`] / [`Tool::Error`]. /// /// Denial -> `Ok` (policy feedback). Timeout/cancel/channel -> `Err` (tool error). fn approval_outcome_to_tool_result( result: Result, action: &str, ) -> Result { match result { Ok(ApprovalOutcome::Decided(ApprovalDecision::Approved)) => { Ok(format!("Approved. You may proceed with: {action}")) } Ok(ApprovalOutcome::Decided(ApprovalDecision::Denied { reason })) => Ok(format!( "Rejected: {}. Do not proceed with this action.", reason.unwrap_or_else(|| "no reason provided".to_string()) )), Ok(ApprovalOutcome::TimedOut { .. }) => Err(ToolError::ToolCallError( "Approval timed out. Treat this as not approved; do not proceed." .to_string() .into(), )), Ok(ApprovalOutcome::Cancelled(_)) => Err(ToolError::ToolCallError( "Approval was cancelled. Treat this as not approved; do not proceed." .to_string() .into(), )), Err(e) => Err(ToolError::ToolCallError( format!("Approval request failed: {e}. You may try again or notify the user.").into(), )), } } /// Normalize model reasoning into a `tool_call_intent` via /// [`crate::tool_wrapper::non_blank`]. fn normalize_tool_call_intent(raw: Option<&str>) -> Option { raw.and_then(crate::tool_wrapper::non_blank) .map(str::to_string) } impl Tool for RequestApprovalTool { const NAME: &'static str = "request_approval"; type Error = ToolError; type Args = RequestApprovalArgs; type Output = String; async fn definition(&self, _prompt: String) -> ToolDefinition { ToolDefinition { name: Self::NAME.to_string(), description: "Request human approval before proceeding with a sensitive action. \ Describe what you want to do, why it's risky, and optionally provide structured \ context. The call blocks until a human approves or rejects." .to_string(), parameters: serde_json::json!({ "type": "object", "properties": { "action_description": { "type": "string", "description": "What you want to do (the action awaiting approval)." }, "risk_rationale": { "type": "string", "description": "Why this action requires human approval." }, "context": { "type": "object", "description": "Optional additional structured metadata for the reviewer." }, "_aura_reasoning": { "type": "string", "description": "Explain your reasoning for requesting this approval. Why does this specific action need a human decision?" } }, "required": ["action_description", "risk_rationale"] }), } } async fn call(&self, args: Self::Args) -> Result { let run = self.run(); let request_id = run .as_ref() .map(|run| run.id().to_string()) .unwrap_or_default(); // Blank reasoning collapses to absent, consistent with the config_gate path. let tool_call_intent = normalize_tool_call_intent(args.tool_call_intent.as_deref()); let request = ApprovalRequest { version: PROTOCOL_VERSION, instance_id: self.instance_id.clone(), decision_id: DecisionId::generate(), request_id: request_id.clone(), scope: self.scope.clone(), origin: ApprovalOrigin::AgentRequested { reason: args.risk_rationale.clone(), agent_name: self.agent_name.clone(), }, items: vec![ApprovalItem { tool_name: Self::NAME.to_string(), tool_namespace: None, arguments: serde_json::to_value(&args).unwrap_or_default(), tool_call_intent, }], }; let cancel = run .as_ref() .map(|run| { crate::request_cancellation::RequestCancelToken::from(run.cancel_token().clone()) }) .unwrap_or_else(crate::request_cancellation::RequestCancelToken::unbound); // `DecisionRoute` emits the lifecycle itself; the scope is how those // events find the run, since rig calls this off it. let decided = match run { Some(run) => { crate::run_context::with_run(run, self.route.decide(request, &cancel)).await } None => self.route.decide(request, &cancel).await, }; approval_outcome_to_tool_result(decided, &args.action_description) } } #[cfg(test)] mod tests { use super::super::decision::{ApprovalOutcome, CancelReason}; use super::*; use crate::hitl::PendingApprovals; use crate::session_store::{ApprovalStore, EventBus, InMemoryApprovalStore, InMemoryEventBus}; use aura_events::agent::AgentEventPayload; #[test] fn mapping_approved_returns_ok_with_action() { let result = approval_outcome_to_tool_result( Ok(ApprovalOutcome::Decided(ApprovalDecision::Approved)), "deploy service", ); assert!(result.is_ok()); assert!(result.as_ref().unwrap().contains("Approved")); assert!(result.as_ref().unwrap().contains("deploy service")); } #[test] fn mapping_denied_returns_ok_with_reason() { let result = approval_outcome_to_tool_result( Ok(ApprovalOutcome::Decided(ApprovalDecision::Denied { reason: Some("too risky".to_string()), })), "deploy service", ); assert!(result.is_ok()); assert!(result.as_ref().unwrap().contains("Rejected")); assert!(result.as_ref().unwrap().contains("too risky")); } #[test] fn mapping_denied_with_no_reason_returns_ok() { let result = approval_outcome_to_tool_result( Ok(ApprovalOutcome::Decided(ApprovalDecision::Denied { reason: None, })), "deploy service", ); assert!(result.is_ok()); assert!(result.as_ref().unwrap().contains("no reason provided")); } #[test] fn mapping_timeout_returns_err() { let result = approval_outcome_to_tool_result( Ok(ApprovalOutcome::TimedOut { waited: std::time::Duration::from_secs(5), }), "deploy service", ); assert!(result.is_err()); let msg = result.unwrap_err().to_string(); assert!(msg.contains("timed out")); } #[test] fn mapping_cancelled_returns_err() { let result = approval_outcome_to_tool_result( Ok(ApprovalOutcome::Cancelled(CancelReason::ClientDisconnected)), "deploy service", ); assert!(result.is_err()); let msg = result.unwrap_err().to_string(); assert!(msg.contains("cancelled")); } #[test] fn mapping_channel_error_returns_err() { let result = approval_outcome_to_tool_result( Err(ApprovalError::Transport("connection refused".to_string())), "deploy service", ); assert!(result.is_err()); let msg = result.unwrap_err().to_string(); assert!(msg.contains("Approval request failed")); } #[test] fn request_approval_args_serialization_strips_aura_reasoning() { let args = RequestApprovalArgs { action_description: "deploy".to_string(), risk_rationale: "prod change".to_string(), context: None, tool_call_intent: Some("need to unblock the migration".to_string()), }; let serialized = serde_json::to_value(&args).unwrap(); // skip_serializing keeps _aura_reasoning OUT of the wire arguments assert!(serialized.get("_aura_reasoning").is_none()); assert!(serialized.get("tool_call_intent").is_none()); } #[test] fn request_approval_args_deserializes_aura_reasoning_into_tool_call_intent() { let json = serde_json::json!({ "action_description": "deploy", "risk_rationale": "prod change", "_aura_reasoning": "need to unblock the migration" }); let args: RequestApprovalArgs = serde_json::from_value(json).unwrap(); assert_eq!( args.tool_call_intent.as_deref(), Some("need to unblock the migration") ); } #[test] fn request_approval_args_deserializes_without_aura_reasoning() { let json = serde_json::json!({ "action_description": "deploy", "risk_rationale": "prod change" }); let args: RequestApprovalArgs = serde_json::from_value(json).unwrap(); assert!(args.tool_call_intent.is_none()); } // -------------------------------------------------------------------- // agent_requested path: drive `RequestApprovalTool::call` end-to-end // through a real `DecisionRoute::Conversational` and inspect the parked // `ApprovalItem` the production call site builds. These pin the call // site's normalization: the blank case asserts the parked item's // `tool_call_intent` is `None`, which only holds when the production // path normalizes `Some("")`/`Some(" ")` away. // -------------------------------------------------------------------- /// Drive `RequestApprovalTool::call` through a real conversational route /// backed by an inspectable in-memory store, returning the production-built /// `ApprovalItem` parked for the request. Approves the request so the /// spawned call completes and the caller can await it. async fn drive_request_approval_tool( store: &Arc, registry: &PendingApprovals, route: &Arc, args: RequestApprovalArgs, ) -> ApprovalItem { let request_id = format!("req_w2_{}", uuid::Uuid::new_v4().simple()); let tool = RequestApprovalTool::new( route.clone(), AgentScope::Single { session_id: None }, "test-agent".to_string(), "test-instance-id".to_string(), ); // The scope goes inside the spawn, because task-locals do not cross one. let (run, mut rx) = crate::run_context::RunContext::channel(request_id.as_str()); let call_handle: tokio::task::JoinHandle> = tokio::spawn(crate::run_context::with_run(run, async move { tool.call(args).await })); // Learn the decision_id from the Requested event, then read the // parked request the production call site registered. let event = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv()) .await .expect("requested event should arrive") .expect("event channel open"); let decision_id = match event.payload { AgentEventPayload::ApprovalRequested(req) => { DecisionId::parse(&req.decision_id).expect("valid decision id") } other => panic!("expected Requested event, got {other:?}"), }; let parked = store .get(&decision_id) .await .unwrap() .expect("parked approval exists"); // Approve so the spawned tool call completes. registry .resolve(&decision_id, ApprovalDecision::Approved) .await .expect("resolve"); let result = call_handle.await.expect("call task did not panic"); assert!(result.is_ok(), "approved call should succeed: {:?}", result); parked.request.items.into_iter().next().expect("one item") } #[tokio::test] async fn request_approval_normalizes_blank_reasoning_to_absent() { let store: Arc = Arc::new(InMemoryApprovalStore::new()); let bus: Arc = Arc::new(InMemoryEventBus::new()); let registry = PendingApprovals::with_backend(store.clone(), bus); let registry_for_resolve = registry.clone(); let route = Arc::new(DecisionRoute::Conversational { registry, timeout: std::time::Duration::from_secs(60), }); // Both empty and whitespace-only _aura_reasoning deserialize to // Some(...) — the bug precondition. The production call site must // normalize them to None before the ApprovalItem is built, so the // field is omitted on the wire (never null, never empty string). for raw in ["", " "] { let args: RequestApprovalArgs = serde_json::from_value(serde_json::json!({ "action_description": "deploy", "risk_rationale": "prod change", "_aura_reasoning": raw, })) .unwrap(); // Precondition: serde reads blank _aura_reasoning as Some(...), // so only the production path's normalization can collapse it. assert_eq!( args.tool_call_intent.as_deref(), Some(raw), "blank _aura_reasoning {:?} must deserialize to Some(...) first", raw, ); let item = drive_request_approval_tool(&store, ®istry_for_resolve, &route, args).await; assert!( item.tool_call_intent.is_none(), "production-built item must have tool_call_intent None for blank _aura_reasoning {:?}, got: {:?}", raw, item.tool_call_intent, ); let wire = serde_json::to_value(&item).unwrap(); assert!( wire.get("tool_call_intent").is_none(), "tool_call_intent must be omitted on the wire for blank _aura_reasoning {:?}, got: {}", raw, wire, ); assert!( item.arguments.get("_aura_reasoning").is_none(), "items[].arguments must not contain _aura_reasoning for blank _aura_reasoning {:?}, got: {}", raw, item.arguments, ); } } #[tokio::test] async fn request_approval_preserves_non_blank_reasoning() { let store: Arc = Arc::new(InMemoryApprovalStore::new()); let bus: Arc = Arc::new(InMemoryEventBus::new()); let registry = PendingApprovals::with_backend(store.clone(), bus); let registry_for_resolve = registry.clone(); let route = Arc::new(DecisionRoute::Conversational { registry, timeout: std::time::Duration::from_secs(60), }); // Non-blank intent must flow through the production path unchanged, // surrounding whitespace included — guards against over-filtering. let args: RequestApprovalArgs = serde_json::from_value(serde_json::json!({ "action_description": "deploy", "risk_rationale": "prod change", "_aura_reasoning": " need to unblock the migration ", })) .unwrap(); let item = drive_request_approval_tool(&store, ®istry_for_resolve, &route, args).await; assert_eq!( item.tool_call_intent.as_deref(), Some(" need to unblock the migration "), "production-built item must carry non-blank tool_call_intent unchanged", ); let wire = serde_json::to_value(&item).unwrap(); assert_eq!( wire.get("tool_call_intent").and_then(|v| v.as_str()), Some(" need to unblock the migration "), "non-blank tool_call_intent must appear on the wire unchanged, got: {}", wire, ); assert!( item.arguments.get("_aura_reasoning").is_none(), "items[].arguments must not contain _aura_reasoning, got: {}", item.arguments, ); } /// Trace correlation for the agent-callable surface: the decision id must land on the tool's own execution span. Mirrors the equivalent coverage in `gate.rs`'s `decision_id_span` tests. Gated on `otel`: without the feature there is no span data to assert against. #[cfg(feature = "otel")] mod decision_id_span { use std::sync::Arc; use super::*; use crate::test_span_capture::traced_as_execute_tool; #[tokio::test] async fn request_approval_tool_stamps_the_decision_id_on_the_execution_span() { let store: Arc = Arc::new(InMemoryApprovalStore::new()); let bus: Arc = Arc::new(InMemoryEventBus::new()); let registry = PendingApprovals::with_backend(store, bus); let route = Arc::new(DecisionRoute::Conversational { registry: registry.clone(), timeout: std::time::Duration::from_secs(60), }); let request_id = format!("req_tool_span_{}", uuid::Uuid::new_v4().simple()); let (run, mut events) = crate::run_context::RunContext::channel(request_id.as_str()); let tool = RequestApprovalTool::new( route, AgentScope::Single { session_id: None }, "test-agent".to_string(), "test-instance-id".to_string(), ); let args = RequestApprovalArgs { action_description: "delete namespace".to_string(), risk_rationale: "touches prod".to_string(), context: None, tool_call_intent: None, }; let ((result, payload_id), span_id) = traced_as_execute_tool(async { tokio::join!(crate::run_context::with_run(run, tool.call(args)), async { let event = events.recv().await.expect("requested event arrives"); let id = match event.payload { AgentEventPayload::ApprovalRequested(req) => { DecisionId::parse(&req.decision_id).expect("valid decision id") } other => panic!("expected Requested event, got {other:?}"), }; registry .resolve(&id, ApprovalDecision::Approved) .await .expect("parked approval resolves"); id }) }) .await; assert!( result.is_ok(), "an approved call must succeed: {:?}", result ); assert_eq!( span_id.as_deref(), Some(payload_id.to_string().as_str()), "the execution span must carry the request_approval tool's decision id", ); } } }