Skip to main content

ferrin_policy/
capability.rs

1//! Capability middleware: restrict the tools offered to the model.
2//!
3//! The pattern follows the Vercel AI SDK capability middleware (Apache-2.0,
4//! Copyright 2023 Vercel, Inc.), reimplemented for Ferrin's middleware
5//! interface.
6
7use 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
24/// Builds the policy input for a model call.
25pub type CapabilityInputFn =
26    Arc<dyn Fn(&CallOptions, &MiddlewareContext<'_>) -> JsonValue + Send + Sync>;
27
28/// The default capability input:
29///
30/// ```json
31/// {
32///   "model": { "provider": "..", "model_id": ".." },
33///   "call": "generate" | "stream",
34///   "tools": [ { "name": "..", "provider_defined": false }, .. ],
35///   "tool_choice": <tool choice or null>
36/// }
37/// ```
38#[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/// Parses the allowlist of a capability decision: an array of tool names or
64/// an object with a `tools` array. Returns `None` for anything else.
65#[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
78/// Middleware created by [`capability_middleware`].
79pub struct CapabilityMiddleware<C> {
80    client: C,
81    path: String,
82    to_input: Option<CapabilityInputFn>,
83    on_error: FailureMode,
84}
85
86/// Restricts `CallOptions::tools` to the allowlist returned by the policy at
87/// `path`.
88///
89/// Calls without tools are not evaluated. When the policy cannot be
90/// evaluated or returns an unrecognized document, all tools are removed
91/// (fail closed) unless [`CapabilityMiddleware::on_error`] selects
92/// [`FailureMode::FallThrough`], which keeps the tools unchanged. A
93/// `tool_choice` that forces a removed tool, or requires a tool when none
94/// remains, is cleared.
95pub 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    /// Replaces the default input document.
109    #[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    /// Sets the behaviour on evaluation errors (default: remove all tools).
119    #[must_use]
120    pub fn on_error(mut self, mode: FailureMode) -> Self {
121        self.on_error = mode;
122        self
123    }
124
125    /// The policy path.
126    #[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}