Skip to main content

agent_engine/memory_backend/
competing.rs

1//! Which tools an exclusive memory backend refuses as independent memory.
2use crate::tools::{Tool, ToolOrigin, ToolRegistry};
3
4/// Whether `name`, exactly or in the wire form the tool registry offers it
5/// under (`memory:search` and `memory.search` are offered as
6/// `memory_search`), ignoring ASCII case, is one of the memory tool names.
7fn memory_tool_name(name: &str) -> bool {
8    super::competing_note_tool(&name.to_ascii_lowercase())
9        || super::competing_note_tool(&ToolRegistry::unclaimed_wire_name(name).to_ascii_lowercase())
10}
11
12/// Whether a non-builtin tool is an independent memory capability, which an
13/// exclusive (host-selected) memory backend refuses beside the host's own
14/// memory tools: its source registered a memory operation (an extension's
15/// [`Tool::source_operation`], an MCP server's or a plugin's tool name in its
16/// [`ToolOrigin`]), or its runtime name, or that name's last `:` segment, is
17/// or is offered as a memory tool's name. An extension `memory` registering
18/// `search` is offered as `memory_search` (`memory_search_2` beside the
19/// host's own tool) and is therefore competing. Builtins are never
20/// classified. A custom tool with any other name cannot be recognized
21/// generically; this guards against names a model would take for a memory
22/// tool, it is not a sandbox.
23pub fn competing_memory_tool(tool: &dyn Tool) -> bool {
24    let typed = match tool.origin() {
25        ToolOrigin::Builtin => return false,
26        ToolOrigin::Mcp {
27            server_tool_name, ..
28        } => memory_tool_name(&server_tool_name),
29        ToolOrigin::Plugin { tool_name, .. } => memory_tool_name(&tool_name),
30        ToolOrigin::Extension { .. } | ToolOrigin::Unknown => false,
31    };
32    let name = tool.name();
33    let last = name.rsplit(':').next().unwrap_or(name);
34    typed
35        || tool.source_operation().is_some_and(memory_tool_name)
36        || memory_tool_name(name)
37        || memory_tool_name(last)
38}
39
40#[cfg(test)]
41mod tests {
42    use super::*;
43    use crate::tools::ToolContext;
44    use serde_json::Value;
45
46    /// A tool with a runtime name, a registered operation and an origin.
47    struct Named {
48        name: &'static str,
49        operation: Option<&'static str>,
50        origin: ToolOrigin,
51    }
52
53    #[async_trait::async_trait]
54    impl Tool for Named {
55        fn name(&self) -> &str {
56            self.name
57        }
58        fn description(&self) -> &str {
59            "fixture"
60        }
61        fn parameters(&self) -> Value {
62            serde_json::json!({"type": "object"})
63        }
64        async fn execute(&self, _params: Value, _ctx: ToolContext) -> crate::Result<String> {
65            Ok(String::new())
66        }
67        fn source_operation(&self) -> Option<&str> {
68            self.operation
69        }
70        fn origin(&self) -> ToolOrigin {
71            self.origin.clone()
72        }
73    }
74
75    fn extension(plugin: &str, operation: &'static str) -> Named {
76        Named {
77            name: Box::leak(format!("{plugin}:{operation}").into_boxed_str()),
78            operation: Some(operation),
79            origin: ToolOrigin::Extension {
80                extension_id: plugin.into(),
81            },
82        }
83    }
84
85    fn mcp(server: &str, tool: &str) -> Named {
86        Named {
87            name: Box::leak(format!("ext__{server}__{tool}").into_boxed_str()),
88            operation: None,
89            origin: ToolOrigin::Mcp {
90                server_id: server.into(),
91                server_tool_name: tool.into(),
92            },
93        }
94    }
95
96    #[test]
97    fn a_registered_memory_operation_is_competing_under_any_plugin_name() {
98        for operation in [
99            "memory_store",
100            "memory_search",
101            "memory_fetch",
102            "memory_forget",
103            "memory_capture",
104            "memory_recall",
105        ] {
106            assert!(
107                competing_memory_tool(&extension("axel-memory-manager", operation)),
108                "{operation}"
109            );
110            assert!(
111                competing_memory_tool(&extension("plug", operation)),
112                "{operation}"
113            );
114        }
115    }
116
117    /// `memory:search` is offered to the model as `memory_search` (or with a
118    /// collision suffix next to the host's own tool), although its
119    /// registered operation is only `search`.
120    #[test]
121    fn a_runtime_name_offered_as_a_memory_tool_name_is_competing() {
122        for (plugin, operation) in [
123            ("memory", "search"),
124            ("memory", "fetch"),
125            ("memory", "store"),
126            ("memory", "forget"),
127            ("Memory", "Search"),
128            ("plug", "memory.search"),
129            ("plug", "memory:fetch"),
130            ("plug", "MEMORY_STORE"),
131        ] {
132            assert!(
133                competing_memory_tool(&extension(plugin, operation)),
134                "{plugin}:{operation}"
135            );
136        }
137    }
138
139    #[test]
140    fn other_names_are_not_competing() {
141        for (plugin, operation) in [
142            ("weather", "search"),
143            ("memory", "summarize"),
144            ("memory", "search_web"),
145            ("notes", "memory_searcher"),
146            ("memory_search", "status"),
147            ("memory-search", "run"),
148        ] {
149            assert!(
150                !competing_memory_tool(&extension(plugin, operation)),
151                "{plugin}:{operation}"
152            );
153        }
154    }
155
156    #[test]
157    fn builtins_are_never_classified() {
158        let builtin = Named {
159            name: "memory_search",
160            operation: Some("memory_search"),
161            origin: ToolOrigin::Builtin,
162        };
163        assert!(!competing_memory_tool(&builtin));
164    }
165
166    #[test]
167    fn mcp_tools_are_classified_by_their_server_tool_name_and_wire_form() {
168        assert!(competing_memory_tool(&mcp("notes", "memory_fetch")));
169        assert!(competing_memory_tool(&mcp("notes", "memory.search")));
170        assert!(competing_memory_tool(&mcp("notes", "Memory_Store")));
171        // `ext__memory__search` is not offered under a memory tool's name.
172        assert!(!competing_memory_tool(&mcp("memory", "search")));
173        assert!(!competing_memory_tool(&mcp("notes", "lookup")));
174    }
175
176    /// A tool that declares neither an operation nor an owner is still
177    /// classified by its runtime name.
178    #[test]
179    fn an_undeclared_tool_is_classified_by_its_runtime_name() {
180        let undeclared = |name: &'static str| Named {
181            name,
182            operation: None,
183            origin: ToolOrigin::Unknown,
184        };
185        assert!(competing_memory_tool(&undeclared("memory:recall")));
186        assert!(competing_memory_tool(&undeclared("x:memory_capture")));
187        assert!(!competing_memory_tool(&undeclared("x:recall")));
188    }
189}