ferrin_core/generate_text/approval/
mod.rs1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(tag = "status", rename_all = "kebab-case")]
25#[non_exhaustive]
26pub enum ApprovalStatus {
27 NotApplicable,
29 Approved {
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 reason: Option<String>,
35 },
36 Denied {
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 reason: Option<String>,
41 },
42 UserApproval {
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 reason: Option<String>,
48 },
49}
50
51impl ApprovalStatus {
52 #[must_use]
54 pub fn approved() -> Self {
55 Self::Approved { reason: None }
56 }
57
58 #[must_use]
60 pub fn denied() -> Self {
61 Self::Denied { reason: None }
62 }
63
64 #[must_use]
66 pub fn user_approval() -> Self {
67 Self::UserApproval { reason: None }
68 }
69
70 #[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 #[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#[derive(Debug, Clone, Copy)]
96pub struct ApprovalContext<'a> {
97 pub messages: &'a [Message],
99 pub tools_context: Option<&'a JsonValue>,
101 pub runtime_context: Option<&'a JsonValue>,
103}
104
105pub trait ApprovalPolicy: Send + Sync {
110 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
149pub struct ApprovalPolicyFn<F>(F);
151
152pub 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
184pub(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}