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}