Skip to content

Commit 992e407

Browse files
authored
Merge pull request #467 from moltis-org/honeysuckle-grapple
feat(agents): lazy tool registry with tool_search meta-tool
2 parents ef2695e + 5ea449c commit 992e407

13 files changed

Lines changed: 682 additions & 83 deletions

File tree

crates/agents/src/lazy_tools.rs

Lines changed: 432 additions & 0 deletions
Large diffs are not rendered by default.

crates/agents/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub use {
1212
model::{ChatMessage, ContentPart, UserContent},
1313
runner::AgentRunError,
1414
};
15+
pub mod lazy_tools;
1516
pub mod provider_chain;
1617
pub mod response_sanitizer;
1718
pub mod silent_turn;

crates/agents/src/prompt.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -592,10 +592,11 @@ fn append_memory_section(
592592
memory_text: Option<&str>,
593593
tool_schemas: &[serde_json::Value],
594594
) {
595+
let has_tool_search = has_tool_schema(tool_schemas, "tool_search");
595596
let has_memory_search = has_tool_schema(tool_schemas, "memory_search");
596597
let has_memory_save = has_tool_schema(tool_schemas, "memory_save");
597598
let memory_content = memory_text.filter(|text| !text.is_empty());
598-
if memory_content.is_none() && !has_memory_search && !has_memory_save {
599+
if memory_content.is_none() && !has_memory_search && !has_memory_save && !has_tool_search {
599600
return;
600601
}
601602

@@ -638,6 +639,15 @@ fn append_memory_section(
638639
"`memory_search` and do not consume prompt space.\n",
639640
));
640641
}
642+
// In lazy mode, memory tools are discoverable via tool_search but not
643+
// directly visible. Tell the model they exist so it knows to search.
644+
if has_tool_search && !has_memory_search && !has_memory_save {
645+
prompt.push_str(concat!(
646+
"\nMemory tools (`memory_search`, `memory_save`) are available but must be ",
647+
"activated first. Use `tool_search(query=\"memory\")` to discover them, ",
648+
"then `tool_search(name=\"memory_search\")` to activate.\n",
649+
));
650+
}
641651
prompt.push('\n');
642652
}
643653

crates/agents/src/runner.rs

Lines changed: 31 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -684,15 +684,20 @@ pub async fn run_agent_loop_with_context(
684684
let native_tools = provider.supports_tools();
685685
let config = moltis_config::discover_and_load();
686686
let max_tool_result_bytes = config.tools.max_tool_result_bytes;
687-
let max_iterations = resolve_agent_max_iterations(config.tools.agent_max_iterations);
688-
let tool_schemas = tools.list_schemas();
687+
let base_max_iterations = resolve_agent_max_iterations(config.tools.agent_max_iterations);
688+
// Lazy mode needs extra iterations for tool_search discovery round-trips.
689+
let max_iterations = if config.tools.registry_mode == moltis_config::ToolRegistryMode::Lazy {
690+
base_max_iterations * 3
691+
} else {
692+
base_max_iterations
693+
};
689694

690695
let is_multimodal = matches!(user_content, UserContent::Multimodal(_));
691696
info!(
692697
provider = provider.name(),
693698
model = provider.id(),
694699
native_tools,
695-
tools_count = tool_schemas.len(),
700+
tools_count = tools.list_names().len(),
696701
is_multimodal,
697702
"starting agent loop"
698703
);
@@ -709,13 +714,6 @@ pub async fn run_agent_loop_with_context(
709714
});
710715
let explicit_shell_command = explicit_shell_command_from_user_content(user_content);
711716

712-
// Only send tool schemas to providers that support them natively.
713-
let schemas_for_api = if native_tools {
714-
&tool_schemas
715-
} else {
716-
&vec![]
717-
};
718-
719717
// Extract session key once for hook payloads.
720718
let session_key_for_hooks = tool_context
721719
.as_ref()
@@ -744,6 +742,14 @@ pub async fn run_agent_loop_with_context(
744742
)));
745743
}
746744

745+
// Re-compute schemas each iteration so activated tools appear immediately.
746+
let tool_schemas = tools.list_schemas();
747+
let schemas_for_api = if native_tools {
748+
&tool_schemas
749+
} else {
750+
&vec![]
751+
};
752+
747753
if let Some(cb) = on_event {
748754
cb(RunnerEvent::Iteration(iterations));
749755
}
@@ -1229,15 +1235,20 @@ pub async fn run_agent_loop_streaming(
12291235
let native_tools = provider.supports_tools();
12301236
let config = moltis_config::discover_and_load();
12311237
let max_tool_result_bytes = config.tools.max_tool_result_bytes;
1232-
let max_iterations = resolve_agent_max_iterations(config.tools.agent_max_iterations);
1233-
let tool_schemas = tools.list_schemas();
1238+
let base_max_iterations = resolve_agent_max_iterations(config.tools.agent_max_iterations);
1239+
// Lazy mode needs extra iterations for tool_search discovery round-trips.
1240+
let max_iterations = if config.tools.registry_mode == moltis_config::ToolRegistryMode::Lazy {
1241+
base_max_iterations * 3
1242+
} else {
1243+
base_max_iterations
1244+
};
12341245

12351246
let is_multimodal = matches!(user_content, UserContent::Multimodal(_));
12361247
info!(
12371248
provider = provider.name(),
12381249
model = provider.id(),
12391250
native_tools,
1240-
tools_count = tool_schemas.len(),
1251+
tools_count = tools.list_names().len(),
12411252
is_multimodal,
12421253
"starting streaming agent loop"
12431254
);
@@ -1254,20 +1265,6 @@ pub async fn run_agent_loop_streaming(
12541265
});
12551266
let explicit_shell_command = explicit_shell_command_from_user_content(user_content);
12561267

1257-
// Only send tool schemas to providers that support them natively.
1258-
let schemas_for_api = if native_tools {
1259-
tool_schemas.clone()
1260-
} else {
1261-
vec![]
1262-
};
1263-
1264-
info!(
1265-
native_tools,
1266-
schemas_for_api_count = schemas_for_api.len(),
1267-
tool_schemas_count = tool_schemas.len(),
1268-
"schemas_for_api prepared for streaming"
1269-
);
1270-
12711268
// Extract session key once for hook payloads.
12721269
let session_key_for_hooks = tool_context
12731270
.as_ref()
@@ -1303,6 +1300,13 @@ pub async fn run_agent_loop_streaming(
13031300
)));
13041301
}
13051302

1303+
// Re-compute schemas each iteration so activated tools appear immediately.
1304+
let schemas_for_api = if native_tools {
1305+
tools.list_schemas()
1306+
} else {
1307+
vec![]
1308+
};
1309+
13061310
if let Some(cb) = on_event {
13071311
cb(RunnerEvent::Iteration(iterations));
13081312
}

crates/agents/src/tool_registry.rs

Lines changed: 89 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
use {
22
anyhow::Result,
33
async_trait::async_trait,
4-
std::{collections::HashMap, sync::Arc},
4+
std::{
5+
collections::HashMap,
6+
sync::{Arc, Mutex},
7+
},
58
};
69

710
/// Agent-callable tool.
@@ -29,17 +32,27 @@ pub enum ToolSource {
2932
}
3033

3134
/// Internal entry pairing a tool with its source metadata.
32-
struct ToolEntry {
33-
tool: Arc<dyn AgentTool>,
34-
source: ToolSource,
35+
pub(crate) struct ToolEntry {
36+
pub(crate) tool: Arc<dyn AgentTool>,
37+
pub(crate) source: ToolSource,
3538
}
3639

40+
/// Shared set of tools activated at runtime by [`ToolSearchTool`](crate::lazy_tools::ToolSearchTool).
41+
///
42+
/// Uses `std::sync::Mutex` (not tokio) because the lock is held for
43+
/// microseconds — just a `HashMap` insert/lookup — and this keeps
44+
/// `list_schemas()` usable from sync contexts.
45+
pub(crate) type ActivatedTools = Arc<Mutex<HashMap<String, ToolEntry>>>;
46+
3747
/// Registry of available tools for an agent run.
3848
///
3949
/// Tools are stored as `Arc<dyn AgentTool>` so the registry can be cheaply
4050
/// cloned (e.g. for sub-agents that need a filtered copy of the parent's tools).
4151
pub struct ToolRegistry {
4252
tools: HashMap<String, ToolEntry>,
53+
/// Tools activated at runtime via lazy tool discovery (`tool_search`).
54+
/// Always present (empty when lazy mode is not in use).
55+
pub(crate) activated: ActivatedTools,
4356
}
4457

4558
impl Default for ToolRegistry {
@@ -52,6 +65,7 @@ impl ToolRegistry {
5265
pub fn new() -> Self {
5366
Self {
5467
tools: HashMap::new(),
68+
activated: Arc::new(Mutex::new(HashMap::new())),
5569
}
5670
}
5771

@@ -112,49 +126,47 @@ impl ToolRegistry {
112126
before - self.tools.len()
113127
}
114128

115-
pub fn get(&self, name: &str) -> Option<&dyn AgentTool> {
116-
self.tools.get(name).map(|e| e.tool.as_ref())
129+
pub fn get(&self, name: &str) -> Option<Arc<dyn AgentTool>> {
130+
if let Some(e) = self.tools.get(name) {
131+
return Some(Arc::clone(&e.tool));
132+
}
133+
let activated = self.activated.lock().unwrap_or_else(|e| e.into_inner());
134+
activated.get(name).map(|e| Arc::clone(&e.tool))
117135
}
118136

119-
/// Return a cloned tool handle by name.
120-
pub fn get_arc(&self, name: &str) -> Option<Arc<dyn AgentTool>> {
121-
self.tools.get(name).map(|e| Arc::clone(&e.tool))
137+
/// Return the [`ToolSource`] for a tool by name.
138+
pub(crate) fn get_source(&self, name: &str) -> Option<ToolSource> {
139+
self.tools.get(name).map(|e| e.source.clone())
122140
}
123141

124142
pub fn list_schemas(&self) -> Vec<serde_json::Value> {
125-
self.tools
126-
.values()
127-
.map(|e| {
128-
let mut schema = serde_json::json!({
129-
"name": e.tool.name(),
130-
"description": e.tool.description(),
131-
"parameters": e.tool.parameters_schema(),
132-
});
133-
match &e.source {
134-
ToolSource::Builtin => {
135-
schema["source"] = serde_json::json!("builtin");
136-
},
137-
ToolSource::Mcp { server } => {
138-
schema["source"] = serde_json::json!("mcp");
139-
schema["mcpServer"] = serde_json::json!(server);
140-
},
141-
ToolSource::Wasm { component_hash } => {
142-
schema["source"] = serde_json::json!("wasm");
143-
schema["componentHash"] =
144-
serde_json::json!(hex_component_hash(*component_hash));
145-
},
146-
}
147-
schema
148-
})
149-
.collect()
143+
let mut schemas: Vec<serde_json::Value> =
144+
self.tools.values().map(entry_to_schema).collect();
145+
146+
let activated = self.activated.lock().unwrap_or_else(|e| e.into_inner());
147+
for (name, entry) in activated.iter() {
148+
if !self.tools.contains_key(name) {
149+
schemas.push(entry_to_schema(entry));
150+
}
151+
}
152+
schemas
150153
}
151154

152-
/// List registered tool names.
155+
/// List registered tool names (static + activated).
153156
pub fn list_names(&self) -> Vec<String> {
154-
self.tools.keys().cloned().collect()
157+
let mut names: Vec<String> = self.tools.keys().cloned().collect();
158+
let activated = self.activated.lock().unwrap_or_else(|e| e.into_inner());
159+
for name in activated.keys() {
160+
if !self.tools.contains_key(name) {
161+
names.push(name.clone());
162+
}
163+
}
164+
names
155165
}
156166

157167
/// Clone the registry, excluding tools whose names start with `prefix`.
168+
///
169+
/// Sub-agent registries get a fresh (empty) activated set.
158170
pub fn clone_without_prefix(&self, prefix: &str) -> ToolRegistry {
159171
let tools = self
160172
.tools
@@ -167,7 +179,10 @@ impl ToolRegistry {
167179
})
168180
})
169181
.collect();
170-
ToolRegistry { tools }
182+
ToolRegistry {
183+
tools,
184+
activated: Arc::new(Mutex::new(HashMap::new())),
185+
}
171186
}
172187

173188
/// Clone the registry, excluding all MCP-sourced tools.
@@ -183,7 +198,10 @@ impl ToolRegistry {
183198
})
184199
})
185200
.collect();
186-
ToolRegistry { tools }
201+
ToolRegistry {
202+
tools,
203+
activated: Arc::new(Mutex::new(HashMap::new())),
204+
}
187205
}
188206

189207
/// Clone the registry, excluding tools whose names are in `exclude`.
@@ -199,7 +217,10 @@ impl ToolRegistry {
199217
})
200218
})
201219
.collect();
202-
ToolRegistry { tools }
220+
ToolRegistry {
221+
tools,
222+
activated: Arc::new(Mutex::new(HashMap::new())),
223+
}
203224
}
204225

205226
/// Clone the registry keeping only tools that match `predicate`.
@@ -218,10 +239,35 @@ impl ToolRegistry {
218239
})
219240
})
220241
.collect();
221-
ToolRegistry { tools }
242+
ToolRegistry {
243+
tools,
244+
activated: Arc::new(Mutex::new(HashMap::new())),
245+
}
222246
}
223247
}
224248

249+
fn entry_to_schema(e: &ToolEntry) -> serde_json::Value {
250+
let mut schema = serde_json::json!({
251+
"name": e.tool.name(),
252+
"description": e.tool.description(),
253+
"parameters": e.tool.parameters_schema(),
254+
});
255+
match &e.source {
256+
ToolSource::Builtin => {
257+
schema["source"] = serde_json::json!("builtin");
258+
},
259+
ToolSource::Mcp { server } => {
260+
schema["source"] = serde_json::json!("mcp");
261+
schema["mcpServer"] = serde_json::json!(server);
262+
},
263+
ToolSource::Wasm { component_hash } => {
264+
schema["source"] = serde_json::json!("wasm");
265+
schema["componentHash"] = serde_json::json!(hex_component_hash(*component_hash));
266+
},
267+
}
268+
schema
269+
}
270+
225271
fn hex_component_hash(component_hash: [u8; 32]) -> String {
226272
let mut output = String::with_capacity(component_hash.len() * 2);
227273
for byte in component_hash {
@@ -409,13 +455,13 @@ mod tests {
409455
}
410456

411457
#[test]
412-
fn test_get_arc_returns_cloned_tool_handle() {
458+
fn test_get_returns_cloned_tool_handle() {
413459
let mut registry = ToolRegistry::new();
414460
registry.register(Box::new(DummyTool {
415461
name: "exec".to_string(),
416462
}));
417-
assert!(registry.get_arc("exec").is_some());
418-
assert!(registry.get_arc("missing").is_none());
463+
assert!(registry.get("exec").is_some());
464+
assert!(registry.get("missing").is_none());
419465
}
420466

421467
#[test]

crates/chat/src/lib.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5392,7 +5392,7 @@ async fn run_explicit_shell_command(
53925392

53935393
let exec_tool = {
53945394
let registry = tool_registry.read().await;
5395-
registry.get_arc("exec")
5395+
registry.get("exec")
53965396
};
53975397

53985398
let exec_result = match exec_tool {
@@ -5995,6 +5995,14 @@ async fn run_with_tools(
59955995
if tools_enabled && let Some(manager) = state.memory_manager() {
59965996
install_agent_scoped_memory_tools(&mut filtered_registry, manager, agent_id);
59975997
}
5998+
if tools_enabled
5999+
&& matches!(
6000+
persona.config.tools.registry_mode,
6001+
moltis_config::ToolRegistryMode::Lazy
6002+
)
6003+
{
6004+
filtered_registry = moltis_agents::lazy_tools::wrap_registry_lazy(filtered_registry);
6005+
}
59986006

59996007
// Build system prompt:
60006008
// - Native tools: full prompt with tool schemas sent via API

0 commit comments

Comments
 (0)