Skip to main content

basis_acp/
approver.rs

1//! Asking the ACP client for permission.
2//!
3//! basis's [`Approver`] answers while the turn is blocked inside mentra waiting
4//! for it; ACP answers with a `session/request_permission` round trip to the
5//! client. This module is the join between them.
6//!
7//! # Why this can await at all
8//!
9//! The approver runs on basis's forwarding task — an async task that exists only
10//! to drain one session's events — so awaiting here parks that task and
11//! nothing else. The ACP dispatch loop is a different task and stays free.
12//!
13//! That freedom is the invariant this depends on: `session/prompt` spawns
14//! before driving a turn (see [`server`](crate::server)), so when this
15//! module's request reaches the client, the loop can still read the answer.
16//! Driving a turn inline from the loop instead would deadlock permanently —
17//! the client answers, and nothing is listening.
18
19use std::time::Duration;
20
21use agent_client_protocol::{
22    Client, ConnectionTo,
23    schema::v1::{
24        PermissionOption, PermissionOptionKind, RequestPermissionOutcome, RequestPermissionRequest,
25        SessionId, ToolCallUpdate, ToolCallUpdateFields,
26    },
27};
28
29use basis::approval::{ApprovalAnswer, ApprovalDecision, ApprovalRequest, Approver};
30
31/// Option ids on the wire. Chosen by basis, echoed back by the client, and
32/// matched here — so they are a contract with ourselves and belong in one
33/// place.
34const ALLOW_ONCE: &str = "allow-once";
35const ALLOW_ALWAYS: &str = "allow-always";
36const REJECT_ONCE: &str = "reject-once";
37const REJECT_ALWAYS: &str = "reject-always";
38
39/// How long to wait for a person to answer before giving up.
40///
41/// Generous, because reading a diff takes as long as it takes. Bounded anyway,
42/// because a client that goes away mid-request would otherwise strand the turn
43/// forever, and a stuck agent with no explanation is worse than a denial.
44const ANSWER_TIMEOUT: Duration = Duration::from_secs(30 * 60);
45
46/// Puts approval requests to the ACP client.
47pub struct AcpApprover {
48    session_id: SessionId,
49    connection: ConnectionTo<Client>,
50}
51
52impl AcpApprover {
53    pub fn new(session_id: SessionId, connection: ConnectionTo<Client>) -> Self {
54        Self {
55            session_id,
56            connection,
57        }
58    }
59}
60
61#[async_trait::async_trait]
62impl Approver for AcpApprover {
63    async fn approve(&mut self, request: &ApprovalRequest) -> ApprovalAnswer {
64        let outbound = RequestPermissionRequest::new(
65            self.session_id.clone(),
66            ToolCallUpdate::new(
67                request.tool_call_id.clone(),
68                ToolCallUpdateFields::new()
69                    .title(request.description.clone())
70                    .raw_input(request.input.clone()),
71            ),
72            options(),
73        );
74
75        match round_trip(&self.connection, outbound).await {
76            Ok(outcome) => answer(&outcome, &request.tool_name),
77            // A failed round trip, a closed connection, or a client that never
78            // answered. Deny rather than assume consent, and say which it was
79            // — the model reads this, and "denied" alone invites a retry.
80            Err(why) => refused(&request.tool_name, &why),
81        }
82    }
83}
84
85/// A refusal that names the reason, for the model to read.
86fn refused(tool_name: &str, why: &str) -> ApprovalAnswer {
87    ApprovalAnswer::new(ApprovalDecision::Deny)
88        .because(format!("{tool_name} needs approval and {why}"))
89}
90
91/// Performs the round trip, describing the failure when there is one.
92async fn round_trip(
93    connection: &ConnectionTo<Client>,
94    request: RequestPermissionRequest,
95) -> Result<RequestPermissionOutcome, String> {
96    let response = tokio::time::timeout(
97        ANSWER_TIMEOUT,
98        connection.send_request(request).block_task(),
99    )
100    .await;
101
102    match response {
103        Ok(Ok(response)) => Ok(response.outcome),
104        Ok(Err(_)) => Err("the client could not be reached".to_string()),
105        Err(_) => Err(format!(
106            "the client did not answer within {} minutes",
107            ANSWER_TIMEOUT.as_secs() / 60
108        )),
109    }
110}
111
112/// The four choices basis offers, matching its four [`ApprovalDecision`]s.
113///
114/// ACP lets an agent name its own options; offering exactly the decisions basis
115/// can act on means no answer can arrive that basis has to reinterpret.
116fn options() -> Vec<PermissionOption> {
117    vec![
118        PermissionOption::new(ALLOW_ONCE, "Allow", PermissionOptionKind::AllowOnce),
119        PermissionOption::new(
120            ALLOW_ALWAYS,
121            "Allow for this session",
122            PermissionOptionKind::AllowAlways,
123        ),
124        PermissionOption::new(REJECT_ONCE, "Deny", PermissionOptionKind::RejectOnce),
125        PermissionOption::new(
126            REJECT_ALWAYS,
127            "Deny for this session",
128            PermissionOptionKind::RejectAlways,
129        ),
130    ]
131}
132
133/// Reads the client's answer.
134///
135/// Matched on the option id basis itself sent. An id basis does not recognize is a
136/// client bug, and the safe reading of an answer we do not understand is a
137/// denial.
138fn answer(outcome: &RequestPermissionOutcome, tool_name: &str) -> ApprovalAnswer {
139    let RequestPermissionOutcome::Selected(selected) = outcome else {
140        // `Cancelled` — the turn is being torn down; there is nothing to allow.
141        return refused(tool_name, "the request was cancelled");
142    };
143
144    match &*selected.option_id.0 {
145        ALLOW_ONCE => ApprovalDecision::Allow.into(),
146        ALLOW_ALWAYS => ApprovalDecision::AllowForSession.into(),
147        REJECT_ONCE => ApprovalAnswer::new(ApprovalDecision::Deny)
148            .because(format!("{tool_name} was refused by the client")),
149        REJECT_ALWAYS => ApprovalAnswer::new(ApprovalDecision::DenyForSession).because(format!(
150            "{tool_name} was refused by the client, for the rest of this session"
151        )),
152        _ => refused(tool_name, "the client's answer could not be read"),
153    }
154}
155
156/// Whether an id is one basis offered, for tests and for callers checking a
157/// client's echo.
158#[cfg(test)]
159fn is_known_option(id: &str) -> bool {
160    matches!(id, ALLOW_ONCE | ALLOW_ALWAYS | REJECT_ONCE | REJECT_ALWAYS)
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use agent_client_protocol::schema::v1::{PermissionOptionId, SelectedPermissionOutcome};
167
168    fn selected(id: &str) -> RequestPermissionOutcome {
169        RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(PermissionOptionId::new(
170            id.to_string(),
171        )))
172    }
173
174    #[test]
175    fn every_offered_option_maps_to_a_decision() {
176        assert_eq!(
177            answer(&selected(ALLOW_ONCE), "shell").decision,
178            ApprovalDecision::Allow
179        );
180        assert_eq!(
181            answer(&selected(ALLOW_ALWAYS), "shell").decision,
182            ApprovalDecision::AllowForSession
183        );
184        assert_eq!(
185            answer(&selected(REJECT_ONCE), "shell").decision,
186            ApprovalDecision::Deny
187        );
188        assert_eq!(
189            answer(&selected(REJECT_ALWAYS), "shell").decision,
190            ApprovalDecision::DenyForSession
191        );
192    }
193
194    #[test]
195    fn allowing_needs_no_reason_and_refusing_gives_one() {
196        // The model reads a refusal's reason as the tool result; an allowed
197        // call explains itself by happening.
198        assert_eq!(answer(&selected(ALLOW_ONCE), "shell").reason, None);
199
200        for id in [REJECT_ONCE, REJECT_ALWAYS] {
201            let reason = answer(&selected(id), "shell")
202                .reason
203                .unwrap_or_else(|| panic!("{id} must explain itself"));
204            assert!(reason.starts_with("shell "), "{reason}");
205        }
206    }
207
208    #[test]
209    fn the_offered_options_are_exactly_the_ones_understood() {
210        // A client can only answer with what it was offered, so an option basis
211        // sends but cannot read would be a silent denial.
212        for option in options() {
213            assert!(
214                is_known_option(&option.option_id.0),
215                "offered {} but cannot map it",
216                option.option_id.0
217            );
218        }
219        assert_eq!(options().len(), 4);
220    }
221
222    #[test]
223    fn a_cancelled_request_denies() {
224        let answer = answer(&RequestPermissionOutcome::Cancelled, "shell");
225
226        assert_eq!(
227            answer.decision,
228            ApprovalDecision::Deny,
229            "a cancelled turn has nothing left to authorize"
230        );
231        assert_eq!(
232            answer.reason.as_deref(),
233            Some("shell needs approval and the request was cancelled")
234        );
235    }
236
237    #[test]
238    fn an_unrecognized_answer_denies() {
239        let answer = answer(&selected("something-else"), "shell");
240
241        assert_eq!(
242            answer.decision,
243            ApprovalDecision::Deny,
244            "an answer basis cannot read must not be treated as consent"
245        );
246        assert_eq!(
247            answer.reason.as_deref(),
248            Some("shell needs approval and the client's answer could not be read")
249        );
250    }
251
252    #[test]
253    fn a_client_that_never_answers_says_so_in_minutes() {
254        // The wording quotes the timeout, so it cannot drift away from the
255        // constant that actually bounds the wait.
256        assert_eq!(
257            refused("shell", "the client did not answer within 30 minutes")
258                .reason
259                .as_deref(),
260            Some("shell needs approval and the client did not answer within 30 minutes")
261        );
262        assert_eq!(ANSWER_TIMEOUT.as_secs() / 60, 30);
263    }
264}