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