ferrin_policy/
capability.rs1use std::collections::BTreeSet;
8use std::fmt;
9use std::sync::Arc;
10
11use ferrin_core::middleware::CallKind;
12use ferrin_core::middleware::LanguageModelMiddleware;
13use ferrin_core::middleware::MiddlewareContext;
14use ferrin_spec::BoxFuture;
15use ferrin_spec::CallOptions;
16use ferrin_spec::JsonValue;
17use ferrin_spec::ToolChoice;
18use ferrin_spec::error::ProviderError;
19use serde_json::json;
20
21use crate::approval::FailureMode;
22use crate::client::PolicyClient;
23
24pub type CapabilityInputFn =
26 Arc<dyn Fn(&CallOptions, &MiddlewareContext<'_>) -> JsonValue + Send + Sync>;
27
28#[must_use]
39pub fn default_capability_input(options: &CallOptions, ctx: &MiddlewareContext<'_>) -> JsonValue {
40 let call = match ctx.kind {
41 CallKind::Generate => "generate",
42 CallKind::Stream => "stream",
43 _ => "unknown",
44 };
45 json!({
46 "model": {
47 "provider": ctx.model.provider(),
48 "model_id": ctx.model.model_id(),
49 },
50 "call": call,
51 "tools": options
52 .tools
53 .iter()
54 .map(|tool| json!({
55 "name": tool.name(),
56 "provider_defined": tool.is_provider_tool(),
57 }))
58 .collect::<Vec<_>>(),
59 "tool_choice": serde_json::to_value(&options.tool_choice).unwrap_or(JsonValue::Null),
60 })
61}
62
63#[must_use]
66pub fn parse_allowlist(raw: &JsonValue) -> Option<BTreeSet<String>> {
67 let names = match raw {
68 JsonValue::Array(names) => names,
69 JsonValue::Object(object) => object.get("tools")?.as_array()?,
70 _ => return None,
71 };
72 names
73 .iter()
74 .map(|name| name.as_str().map(str::to_owned))
75 .collect()
76}
77
78pub struct CapabilityMiddleware<C> {
80 client: C,
81 path: String,
82 to_input: Option<CapabilityInputFn>,
83 on_error: FailureMode,
84}
85
86pub fn capability_middleware<C: PolicyClient>(
96 client: C,
97 path: impl Into<String>,
98) -> CapabilityMiddleware<C> {
99 CapabilityMiddleware {
100 client,
101 path: path.into(),
102 to_input: None,
103 on_error: FailureMode::Deny,
104 }
105}
106
107impl<C> CapabilityMiddleware<C> {
108 #[must_use]
110 pub fn to_input(
111 mut self,
112 f: impl Fn(&CallOptions, &MiddlewareContext<'_>) -> JsonValue + Send + Sync + 'static,
113 ) -> Self {
114 self.to_input = Some(Arc::new(f));
115 self
116 }
117
118 #[must_use]
120 pub fn on_error(mut self, mode: FailureMode) -> Self {
121 self.on_error = mode;
122 self
123 }
124
125 #[must_use]
127 pub fn path(&self) -> &str {
128 &self.path
129 }
130}
131
132impl<C> fmt::Debug for CapabilityMiddleware<C> {
133 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134 f.debug_struct("CapabilityMiddleware")
135 .field("path", &self.path)
136 .field("custom_input", &self.to_input.is_some())
137 .field("on_error", &self.on_error)
138 .finish_non_exhaustive()
139 }
140}
141
142impl<C: PolicyClient> LanguageModelMiddleware for CapabilityMiddleware<C> {
143 fn transform_params<'a>(
144 &'a self,
145 mut options: CallOptions,
146 ctx: MiddlewareContext<'a>,
147 ) -> BoxFuture<'a, Result<CallOptions, ProviderError>> {
148 Box::pin(async move {
149 if options.tools.is_empty() {
150 return Ok(options);
151 }
152 let input = match &self.to_input {
153 Some(to_input) => to_input(&options, &ctx),
154 None => default_capability_input(&options, &ctx),
155 };
156 match self.client.evaluate(&self.path, input).await {
157 Ok(raw) => match parse_allowlist(&raw) {
158 Some(allowed) => {
159 options
160 .tools
161 .retain(|tool| allowed.contains(tool.name().as_str()));
162 }
163 None => {
164 tracing::warn!(path = %self.path, "unrecognized capability decision");
165 self.fail(&mut options);
166 }
167 },
168 Err(_error) => {
169 tracing::warn!(path = %self.path, "capability evaluation failed");
170 self.fail(&mut options);
171 }
172 }
173 clear_stale_tool_choice(&mut options);
174 Ok(options)
175 })
176 }
177}
178
179impl<C> CapabilityMiddleware<C> {
180 fn fail(&self, options: &mut CallOptions) {
181 match self.on_error {
182 FailureMode::Deny => options.tools.clear(),
183 FailureMode::FallThrough => {}
184 }
185 }
186}
187
188fn clear_stale_tool_choice(options: &mut CallOptions) {
189 let stale = match &options.tool_choice {
190 Some(ToolChoice::Tool { tool_name }) => {
191 !options.tools.iter().any(|tool| tool.name() == tool_name)
192 }
193 Some(ToolChoice::Required) => options.tools.is_empty(),
194 Some(ToolChoice::Auto | ToolChoice::None) | None => false,
195 };
196 if stale {
197 options.tool_choice = None;
198 }
199}