ferrin_core/generate_text/approval/
mod.rs1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(tag = "status", rename_all = "kebab-case")]
24#[non_exhaustive]
25pub enum ApprovalStatus {
26 NotApplicable,
28 Approved {
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 reason: Option<String>,
34 },
35 Denied {
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 reason: Option<String>,
40 },
41 UserApproval {
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 reason: Option<String>,
47 },
48}
49
50impl ApprovalStatus {
51 #[must_use]
53 pub fn approved() -> Self {
54 Self::Approved { reason: None }
55 }
56
57 #[must_use]
59 pub fn denied() -> Self {
60 Self::Denied { reason: None }
61 }
62
63 #[must_use]
65 pub fn user_approval() -> Self {
66 Self::UserApproval { reason: None }
67 }
68
69 #[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 #[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#[derive(Debug, Clone, Copy)]
95pub struct ApprovalContext<'a> {
96 pub messages: &'a [Message],
98 pub tools_context: Option<&'a JsonValue>,
100}
101
102pub trait ApprovalPolicy: Send + Sync {
107 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
136pub struct ApprovalPolicyFn<F>(F);
138
139pub 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
167pub(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}