Skip to main content

gglib_core/request_pipeline/
tools.rs

1//! Stage: strip `tools` from requests to models that cannot call them.
2//!
3//! A client that always advertises its tools (most agentic harnesses do)
4//! will send them to whatever model is selected. For a model without
5//! [`ModelCapabilities::SUPPORTS_TOOL_CALLS`], forwarding the array is worse
6//! than useless: llama-server either rejects the request outright or renders
7//! dozens of schemas into the prompt of a model that will only parrot them
8//! back as text. The `WebUI`'s chat path has stripped tools this way since it
9//! existed; this stage gives every `apply` caller — the proxy above all —
10//! the same behaviour.
11//!
12//! The check is deliberately conservative: it acts only on a
13//! [`catalog_resolved`](super::ModelContext::catalog_resolved) context. A
14//! passthrough context has an empty capability bitfield because *nobody
15//! knows* what the model supports, and stripping tools from an unknown model
16//! would silently break a working agent. Unknown models keep their tools.
17//!
18//! [`ModelCapabilities::SUPPORTS_TOOL_CALLS`]: crate::domain::ModelCapabilities::SUPPORTS_TOOL_CALLS
19
20use serde_json::Value;
21use tracing::debug;
22
23use super::ModelContext;
24
25/// Remove `tools` and `tool_choice` when the resolved model cannot use them.
26///
27/// No-op for tool-capable models, for unresolved (passthrough) contexts, and
28/// for requests that carry no tools.
29pub fn strip_unsupported_tools(body: &mut Value, ctx: &ModelContext) {
30    if !ctx.catalog_resolved || ctx.capabilities.supports_tool_calls() {
31        return;
32    }
33    let Some(obj) = body.as_object_mut() else {
34        return;
35    };
36    if obj.remove("tools").is_some() {
37        obj.remove("tool_choice");
38        debug!("stripped tools from request: model does not support tool calls");
39    }
40}
41
42#[cfg(test)]
43mod tests {
44    use super::*;
45    use crate::domain::ModelCapabilities;
46    use serde_json::json;
47
48    fn body_with_tools() -> Value {
49        json!({
50            "model": "m",
51            "messages": [{"role": "user", "content": "hi"}],
52            "tools": [{"type": "function", "function": {"name": "f"}}],
53            "tool_choice": "auto",
54        })
55    }
56
57    fn resolved(capabilities: ModelCapabilities) -> ModelContext {
58        ModelContext {
59            capabilities,
60            catalog_resolved: true,
61            ..ModelContext::passthrough()
62        }
63    }
64
65    /// The case this stage exists for: a resolved model without the
66    /// capability loses the tools array and its `tool_choice`.
67    #[test]
68    fn a_resolved_non_tool_model_loses_its_tools() {
69        let mut body = body_with_tools();
70        strip_unsupported_tools(&mut body, &resolved(ModelCapabilities::empty()));
71
72        assert!(body.get("tools").is_none());
73        assert!(body.get("tool_choice").is_none());
74        assert_eq!(body["model"], "m", "everything else is untouched");
75    }
76
77    #[test]
78    fn a_tool_capable_model_keeps_its_tools() {
79        let mut body = body_with_tools();
80        strip_unsupported_tools(&mut body, &resolved(ModelCapabilities::SUPPORTS_TOOL_CALLS));
81
82        assert!(body.get("tools").is_some());
83        assert!(body.get("tool_choice").is_some());
84    }
85
86    /// An unknown model must not be second-guessed — empty capabilities on a
87    /// passthrough context mean "unknown", not "unsupported".
88    #[test]
89    fn an_unresolved_model_keeps_its_tools() {
90        let mut body = body_with_tools();
91        strip_unsupported_tools(&mut body, &ModelContext::passthrough());
92
93        assert!(body.get("tools").is_some());
94    }
95
96    /// `tool_choice` alone is left in place: it is inert without `tools`,
97    /// and inventing removals the `WebUI` path never did would be a behaviour
98    /// change smuggled into a refactor.
99    #[test]
100    fn tool_choice_without_tools_is_left_alone() {
101        let mut body = json!({"model": "m", "tool_choice": "auto"});
102        strip_unsupported_tools(&mut body, &resolved(ModelCapabilities::empty()));
103
104        assert!(body.get("tool_choice").is_some());
105    }
106}