Skip to main content

everruns_core/capabilities/
auto_tool_search.rs

1// Auto Tool Search Capability
2//
3// A model-adaptive dispatcher over the three real tool-search mechanisms:
4//
5//   - `openai_tool_search` (hosted): on models with native OpenAI tool_search
6//     support (GPT-5.4+), the LLM driver hides parameter schemas server-side via
7//     namespaces + defer_loading. No client-side tool is added.
8//   - `claude_tool_search` (hosted): on Claude models with native Anthropic
9//     tool_search support (Sonnet 4 / Opus 4 / Haiku 4.5 / Fable 5 and newer),
10//     the Anthropic driver marks tools `defer_loading: true` and adds a hosted
11//     `tool_search_tool_*_20251119` entry. No client-side tool is added.
12//   - `tool_search` (generic, client-side): on every other model (Gemini, OpenAI
13//     Completions, Claude/GPT reached via a gateway that masks the hosted format,
14//     ...), a `DeferSchemaHook` strips schemas and a `tool_search` tool loads
15//     them back on demand.
16//
17// Unlike picking one of those capabilities by hand, this one chooses at runtime.
18// It OWNS the two hosted capabilities and the generic one and implements
19// `Capability::resolve_for_model`: capability collection knows the agent's model
20// (via `SystemPromptContext::model`) and delegates to whichever inner capability
21// fits. Only that one capability's contributions are collected — the hosted
22// config for a hosted one, or the hook + tool + system prompt for the generic
23// one. No "contribute both, prune later" step is needed.
24//
25// Use this instead of the individual capabilities when a harness must work well
26// across providers.
27
28use super::claude_tool_search::{
29    ClaudeToolSearchCapability,
30    model_supports_native_tool_search as model_supports_native_claude_tool_search,
31};
32use super::openai_tool_search::{
33    OpenAiToolSearchCapability,
34    model_supports_native_tool_search as model_supports_native_openai_tool_search,
35};
36use super::tool_search::ToolSearchCapability;
37use super::{Capability, CapabilityLocalization, CapabilityStatus};
38
39pub use super::openai_tool_search::DEFAULT_TOOL_SEARCH_THRESHOLD;
40
41/// Capability ID for the model-adaptive tool search.
42pub const AUTO_TOOL_SEARCH_CAPABILITY_ID: &str = "auto_tool_search";
43
44/// Auto Tool Search capability.
45///
46/// Holds the three real tool-search capabilities and dispatches to one of them
47/// based on the agent's model. `threshold` (minimum number of tools before
48/// deferral activates) is shared by all and forwarded at construction.
49pub struct AutoToolSearchCapability {
50    openai: OpenAiToolSearchCapability,
51    claude: ClaudeToolSearchCapability,
52    generic: ToolSearchCapability,
53}
54
55impl AutoToolSearchCapability {
56    pub fn new() -> Self {
57        Self::with_threshold(DEFAULT_TOOL_SEARCH_THRESHOLD)
58    }
59
60    pub fn with_threshold(threshold: usize) -> Self {
61        Self {
62            openai: OpenAiToolSearchCapability::with_threshold(threshold),
63            claude: ClaudeToolSearchCapability::with_threshold(threshold),
64            generic: ToolSearchCapability::with_threshold(threshold),
65        }
66    }
67
68    /// Keep the named tools' full schemas under the generic (client-side)
69    /// mechanism. Forwarded to the inner [`ToolSearchCapability`]; the hosted
70    /// OpenAI path is unaffected (use `DeferrablePolicy::Never` there). See
71    /// [`ToolSearchCapability::with_never_defer`].
72    pub fn with_never_defer<I, S>(mut self, names: I) -> Self
73    where
74        I: IntoIterator<Item = S>,
75        S: Into<String>,
76    {
77        self.generic = self.generic.with_never_defer(names);
78        self
79    }
80}
81
82impl Default for AutoToolSearchCapability {
83    fn default() -> Self {
84        Self::new()
85    }
86}
87
88impl Capability for AutoToolSearchCapability {
89    fn id(&self) -> &str {
90        AUTO_TOOL_SEARCH_CAPABILITY_ID
91    }
92
93    fn name(&self) -> &str {
94        "Auto Tool Search"
95    }
96
97    fn description(&self) -> &str {
98        "Model-adaptive deferred tool loading. Uses the provider's hosted \
99         tool_search on models that support it (OpenAI GPT-5.4+ and Claude \
100         Sonnet 4 / Opus 4 / Haiku 4.5 / Fable 5 and newer) and a \
101         provider-agnostic client-side fallback on every other model. Reduces \
102         token usage for agents with many tools, regardless of provider."
103    }
104
105    fn localizations(&self) -> Vec<CapabilityLocalization> {
106        vec![CapabilityLocalization::text(
107            "uk",
108            "Автоматичний пошук інструментів",
109            "Відкладене завантаження інструментів, що адаптується до моделі. Використовує хостований tool_search провайдера на моделях, які його підтримують (OpenAI GPT-5.4+ та Claude Sonnet 4 / Opus 4 / Haiku 4.5 / Fable 5 і новіші), та незалежний від провайдера клієнтський резервний механізм на всіх інших моделях. Зменшує використання токенів для агентів із багатьма інструментами незалежно від провайдера.",
110        )]
111    }
112
113    fn status(&self) -> CapabilityStatus {
114        CapabilityStatus::Available
115    }
116
117    fn category(&self) -> Option<&str> {
118        Some("Optimization")
119    }
120
121    // The dispatch itself: capability collection calls this with the agent's
122    // model and collects the resolved capability's contributions in place of this
123    // one's. Models with native OpenAI or Anthropic support get the matching
124    // hosted mechanism (no client-side tool or hook); everything else — including
125    // an unknown model — gets the provider-agnostic client-side mechanism, which
126    // is safe everywhere. OpenAI and Anthropic profiles never both claim the same
127    // model id, so the order between the two hosted checks is immaterial.
128    fn resolve_for_model(&self, model: Option<&str>) -> Option<&dyn Capability> {
129        match model {
130            Some(m) if model_supports_native_openai_tool_search(m) => Some(&self.openai),
131            Some(m) if model_supports_native_claude_tool_search(m) => Some(&self.claude),
132            _ => Some(&self.generic),
133        }
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::capabilities::{
141        CLAUDE_TOOL_SEARCH_CAPABILITY_ID, OPENAI_TOOL_SEARCH_CAPABILITY_ID,
142        TOOL_SEARCH_CAPABILITY_ID,
143    };
144
145    // Metadata constants covered by builtin_capabilities_satisfy_registry_invariants.
146
147    #[test]
148    fn test_resolves_to_generic_without_model() {
149        // No model known → safe provider-agnostic client-side mechanism.
150        let cap = AutoToolSearchCapability::new();
151        let resolved = cap.resolve_for_model(None).expect("dispatches");
152        assert_eq!(resolved.id(), TOOL_SEARCH_CAPABILITY_ID);
153        // The generic mechanism carries the client-side tool + hook.
154        assert_eq!(resolved.tools().len(), 1);
155        assert_eq!(resolved.tool_definition_hooks().len(), 1);
156    }
157
158    #[test]
159    fn test_resolves_to_generic_on_non_native_model() {
160        // A model with no hosted tool_search support on either provider (here a
161        // pre-4 Claude) falls back to the safe client-side mechanism.
162        let cap = AutoToolSearchCapability::new();
163        let resolved = cap
164            .resolve_for_model(Some("claude-3-5-haiku"))
165            .expect("dispatches");
166        assert_eq!(resolved.id(), TOOL_SEARCH_CAPABILITY_ID);
167    }
168
169    #[test]
170    fn test_resolves_to_hosted_on_native_openai_model() {
171        let cap = AutoToolSearchCapability::new();
172        let resolved = cap.resolve_for_model(Some("gpt-5.4")).expect("dispatches");
173        assert_eq!(resolved.id(), OPENAI_TOOL_SEARCH_CAPABILITY_ID);
174        // The hosted mechanism contributes no client-side tool or hook.
175        assert!(resolved.tools().is_empty());
176        assert!(resolved.tool_definition_hooks().is_empty());
177    }
178
179    #[test]
180    fn test_resolves_to_hosted_on_native_claude_model() {
181        let cap = AutoToolSearchCapability::new();
182        let resolved = cap
183            .resolve_for_model(Some("claude-opus-4-8"))
184            .expect("dispatches");
185        assert_eq!(resolved.id(), CLAUDE_TOOL_SEARCH_CAPABILITY_ID);
186        // Hosted: no client-side tool or hook contributed.
187        assert!(resolved.tools().is_empty());
188        assert!(resolved.tool_definition_hooks().is_empty());
189    }
190}