Skip to main content

ferrin_core/generate_text/approval/
mod.rs

1//! Tool approval: status resolution, request signatures and replay of
2//! approval responses from the message history.
3
4use std::collections::HashMap;
5use std::fmt;
6use std::sync::Arc;
7
8use ferrin_message::Message;
9use ferrin_spec::BoxFuture;
10use ferrin_spec::JsonValue;
11use ferrin_spec::ToolName;
12use ferrin_tool::Tool;
13use ferrin_tool::ToolContext;
14use serde::Deserialize;
15use serde::Serialize;
16
17use super::ParsedToolCall;
18
19pub(crate) mod collect;
20pub(crate) mod signature;
21
22/// Outcome of approval resolution for one tool call.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(tag = "status", rename_all = "kebab-case")]
25#[non_exhaustive]
26pub enum ApprovalStatus {
27    /// No approval needed; the tool executes directly.
28    NotApplicable,
29    /// Approved automatically; the tool executes and the decision is
30    /// recorded.
31    Approved {
32        /// Reason recorded with the decision.
33        #[serde(default, skip_serializing_if = "Option::is_none")]
34        reason: Option<String>,
35    },
36    /// Denied automatically; the tool does not execute.
37    Denied {
38        /// Reason recorded with the decision.
39        #[serde(default, skip_serializing_if = "Option::is_none")]
40        reason: Option<String>,
41    },
42    /// An external approver must decide; the loop stops with an approval
43    /// request.
44    UserApproval {
45        /// Reason shown to the approver.
46        #[serde(default, skip_serializing_if = "Option::is_none")]
47        reason: Option<String>,
48    },
49}
50
51impl ApprovalStatus {
52    /// Approved without reason.
53    #[must_use]
54    pub fn approved() -> Self {
55        Self::Approved { reason: None }
56    }
57
58    /// Denied without reason.
59    #[must_use]
60    pub fn denied() -> Self {
61        Self::Denied { reason: None }
62    }
63
64    /// User approval without reason.
65    #[must_use]
66    pub fn user_approval() -> Self {
67        Self::UserApproval { reason: None }
68    }
69
70    /// Attaches a reason (ignored for `NotApplicable`).
71    #[must_use]
72    pub fn with_reason(self, reason: impl Into<String>) -> Self {
73        let reason = Some(reason.into());
74        match self {
75            Self::NotApplicable => Self::NotApplicable,
76            Self::Approved { .. } => Self::Approved { reason },
77            Self::Denied { .. } => Self::Denied { reason },
78            Self::UserApproval { .. } => Self::UserApproval { reason },
79        }
80    }
81
82    /// The reason, if any.
83    #[must_use]
84    pub fn reason(&self) -> Option<&str> {
85        match self {
86            Self::NotApplicable => None,
87            Self::Approved { reason } | Self::Denied { reason } | Self::UserApproval { reason } => {
88                reason.as_deref()
89            }
90        }
91    }
92}
93
94/// Information available to an [`ApprovalPolicy`].
95#[derive(Debug, Clone, Copy)]
96pub struct ApprovalContext<'a> {
97    /// Messages sent to the model in this step.
98    pub messages: &'a [Message],
99    /// The tools context of the call.
100    pub tools_context: Option<&'a JsonValue>,
101    /// Application state for this step, separate from tool execution context.
102    pub runtime_context: Option<&'a JsonValue>,
103}
104
105/// Decides whether a tool call needs approval.
106///
107/// The policy runs first; returning `None` falls through to the tool's own
108/// `needs_approval` declaration.
109pub trait ApprovalPolicy: Send + Sync {
110    /// Resolves the approval status of `call`.
111    fn resolve<'a>(
112        &'a self,
113        call: &'a ParsedToolCall,
114        ctx: ApprovalContext<'a>,
115    ) -> BoxFuture<'a, Option<ApprovalStatus>>;
116}
117
118impl<P: ApprovalPolicy + ?Sized> ApprovalPolicy for Arc<P> {
119    fn resolve<'a>(
120        &'a self,
121        call: &'a ParsedToolCall,
122        ctx: ApprovalContext<'a>,
123    ) -> BoxFuture<'a, Option<ApprovalStatus>> {
124        self.as_ref().resolve(call, ctx)
125    }
126}
127
128impl ApprovalPolicy for ApprovalStatus {
129    fn resolve<'a>(
130        &'a self,
131        _call: &'a ParsedToolCall,
132        _ctx: ApprovalContext<'a>,
133    ) -> BoxFuture<'a, Option<ApprovalStatus>> {
134        Box::pin(async move { Some(self.clone()) })
135    }
136}
137
138impl ApprovalPolicy for HashMap<ToolName, ApprovalStatus> {
139    fn resolve<'a>(
140        &'a self,
141        call: &'a ParsedToolCall,
142        _ctx: ApprovalContext<'a>,
143    ) -> BoxFuture<'a, Option<ApprovalStatus>> {
144        let status = self.get(&call.tool_name).cloned();
145        Box::pin(async move { status })
146    }
147}
148
149/// Adapter turning a synchronous closure into an [`ApprovalPolicy`].
150pub struct ApprovalPolicyFn<F>(F);
151
152/// Wraps a synchronous closure as an approval policy.
153///
154/// `None` means [`ApprovalStatus::NotApplicable`] and overrides the tool's
155/// own approval declaration. Implement [`ApprovalPolicy`] directly to return
156/// explicit fall-through instead.
157pub fn approval_policy<F>(f: F) -> ApprovalPolicyFn<F>
158where
159    F: Fn(&ParsedToolCall, &ApprovalContext<'_>) -> Option<ApprovalStatus> + Send + Sync,
160{
161    ApprovalPolicyFn(f)
162}
163
164impl<F> fmt::Debug for ApprovalPolicyFn<F> {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        f.write_str("ApprovalPolicyFn(..)")
167    }
168}
169
170impl<F> ApprovalPolicy for ApprovalPolicyFn<F>
171where
172    F: Fn(&ParsedToolCall, &ApprovalContext<'_>) -> Option<ApprovalStatus> + Send + Sync,
173{
174    fn resolve<'a>(
175        &'a self,
176        call: &'a ParsedToolCall,
177        ctx: ApprovalContext<'a>,
178    ) -> BoxFuture<'a, Option<ApprovalStatus>> {
179        let status = (self.0)(call, &ctx).unwrap_or(ApprovalStatus::NotApplicable);
180        Box::pin(async move { Some(status) })
181    }
182}
183
184/// Resolves the approval status: call-level policy first, then the tool's
185/// declaration.
186pub(crate) async fn resolve_approval(
187    call: &ParsedToolCall,
188    tool: Option<&Tool>,
189    policy: Option<&dyn ApprovalPolicy>,
190    ctx: ApprovalContext<'_>,
191    tool_ctx: impl FnOnce() -> ToolContext,
192) -> ApprovalStatus {
193    if let Some(policy) = policy
194        && let Some(status) = policy.resolve(call, ctx).await
195    {
196        return status;
197    }
198    let Some(tool) = tool else {
199        return ApprovalStatus::NotApplicable;
200    };
201    if !tool.needs_approval().is_declared() {
202        return ApprovalStatus::NotApplicable;
203    }
204    if tool
205        .needs_approval()
206        .resolve(call.input.clone(), tool_ctx())
207        .await
208    {
209        ApprovalStatus::UserApproval { reason: None }
210    } else {
211        ApprovalStatus::NotApplicable
212    }
213}