const TOOL_SEARCH_DESCRIPTION: &str =
"Search newt's tools available to the current turn by intent/keyword — returns matching real \
tool names + what they do. Use this when unsure of a tool's exact name \
instead of guessing. To load a tool missing from your callable list, query its exact name; \
its schema will be included in the next request if a slot is available. Then call the tool \
normally. Search never executes the named tool or grants permission.";
const MAX_MATCHES: usize = 12;
const STOP_TERMS: &[&str] = &["a", "an", "the", "to", "of", "for", "in", "on", "my", "me"];
const DESC_MAX_CHARS: usize = 140;
pub fn tool_search_tool_definition() -> serde_json::Value {
serde_json::json!({
"type": "function",
"function": {
"name": "tool_search",
"description": TOOL_SEARCH_DESCRIPTION,
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Intent or keyword(s) to search the tool catalog by — \
e.g. \"read a file\", \"plan\", \"delegate\". \
An exact tool name also requests its schema for the next request."
}
},
"required": ["query"]
}
}
})
}
struct ToolRec<'a> {
name: &'a str,
desc: &'a str,
params: Vec<&'a str>,
required: Vec<&'a str>,
callable: bool,
loaded: bool,
}
fn one_line(s: &str, max: usize) -> String {
let collapsed = s.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.chars().count() > max {
let head: String = collapsed.chars().take(max).collect();
format!("{}…", head.trim_end())
} else {
collapsed
}
}
fn records(catalog: &serde_json::Value) -> Vec<ToolRec<'_>> {
let entries = catalog.as_array().map(Vec::as_slice).unwrap_or(&[]);
let mut out = Vec::with_capacity(entries.len());
for e in entries {
let f = &e["function"];
let Some(name) = f["name"].as_str() else {
continue;
};
if name == "tool_search" {
continue;
}
let desc = f["description"].as_str().unwrap_or("");
let params: Vec<&str> = f["parameters"]["properties"]
.as_object()
.map(|o| o.keys().map(String::as_str).collect())
.unwrap_or_default();
let required: Vec<&str> = f["parameters"]["required"]
.as_array()
.map(|a| a.iter().filter_map(serde_json::Value::as_str).collect())
.unwrap_or_default();
out.push(ToolRec {
name,
desc,
params,
required,
callable: true,
loaded: true,
});
}
out
}
fn match_line(r: &ToolRec) -> String {
if !r.callable {
let hidden = &super::DispositionVoices::default().discovery_hidden;
return format!("- {} — {hidden}", r.name);
}
let desc = one_line(r.desc, DESC_MAX_CHARS);
let line = if r.required.is_empty() {
format!("- {} — {desc}", r.name)
} else {
format!(
"- {} — {desc} [requires: {}]",
r.name,
r.required.join(", ")
)
};
if r.loaded {
line
} else {
format!("{line} {}", super::tools::exposure::HIDDEN_SEARCH_MARK)
}
}
pub(crate) fn execute_tool_search(query: &str, catalog: &serde_json::Value) -> String {
search(query, records(catalog))
}
fn search(query: &str, tools: Vec<ToolRec<'_>>) -> String {
let terms: Vec<String> = query
.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|t| t.chars().count() >= 2 && !STOP_TERMS.contains(t))
.map(str::to_string)
.collect();
if terms.is_empty() {
if tools.is_empty() {
return "No tools are available in the current turn.".to_string();
}
let body = tools.iter().map(match_line).collect::<Vec<_>>().join("\n");
return with_scope_note(format!("Available tools in this turn:\n{body}"), &tools);
}
let mut scored: Vec<(i64, &ToolRec)> = Vec::new();
for r in &tools {
let name_l = r.name.to_lowercase();
let desc_l = r.desc.to_lowercase();
let params_l: Vec<String> = r.params.iter().map(|p| p.to_lowercase()).collect();
let mut score = 0i64;
for term in &terms {
if name_l.contains(term.as_str()) {
score += 3;
}
if desc_l.contains(term.as_str()) {
score += 1;
}
if params_l.iter().any(|p| p.contains(term.as_str())) {
score += 1;
}
}
if score > 0 {
scored.push((score, r));
}
}
if scored.is_empty() {
let names = tools
.iter()
.filter(|r| r.callable)
.map(|r| r.name)
.collect::<Vec<_>>()
.join(", ");
if names.is_empty() {
return format!(
"No tool matched \"{query}\" — no tools are available in the current turn."
);
}
return format!(
"No tool matched \"{query}\". Available tools: {names}. Retry tool_search with a \
different keyword, or call one of these directly."
);
}
scored.sort_by(|a, b| {
b.0.cmp(&a.0)
.then_with(|| b.1.callable.cmp(&a.1.callable))
.then_with(|| a.1.name.cmp(b.1.name))
});
let mut shown: Vec<&ToolRec> = Vec::new();
let (mut callable, mut hidden) = (0, 0);
for (_, r) in &scored {
let count = if r.callable {
&mut callable
} else {
&mut hidden
};
if *count < MAX_MATCHES {
*count += 1;
shown.push(r);
}
}
let mut out = format!("Tools matching \"{query}\":\n");
for r in &shown {
out.push_str(&match_line(r));
out.push('\n');
}
if scored.len() > shown.len() {
out.push_str(&format!(
"…and {} more — refine the query.",
scored.len() - shown.len()
));
}
with_scope_note(out.trim_end().to_string(), shown)
}
fn with_scope_note<'a>(out: String, shown: impl IntoIterator<Item = &'a ToolRec<'a>>) -> String {
if shown.into_iter().all(|r| r.callable) {
return out;
}
format!(
"{out}\n\n{}",
super::DispositionVoices::default().discovery_scope
)
}
pub(crate) fn execute_tool_search_for_disposition(
query: &str,
catalog: &serde_json::Value,
disposition: super::PromptDisposition,
hidden: Option<&super::tools::HiddenTools>,
) -> String {
fn mark<'a>(mut r: ToolRec<'a>, hidden: Option<&super::tools::HiddenTools>) -> ToolRec<'a> {
r.loaded = !hidden.is_some_and(|hidden| hidden.is_hidden(r.name));
r
}
if disposition == super::PromptDisposition::Act {
return search(
query,
records(catalog)
.into_iter()
.map(|r| mark(r, hidden))
.collect(),
);
}
let visible = super::tools::filter_tools_for_disposition(catalog.clone(), disposition);
let mut tools: Vec<ToolRec<'_>> = records(&visible)
.into_iter()
.map(|r| mark(r, hidden))
.collect();
tools.extend(
records(catalog)
.into_iter()
.filter(|r| !super::tools::tool_allowed(disposition, r.name))
.map(|r| ToolRec {
callable: false,
..r
}),
);
search(query, tools)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agentic::tools::merged_tool_definitions;
use crate::agentic::NoMcp;
fn full_catalog() -> serde_json::Value {
let git = Some(&crate::caveats::Scope::All);
merged_tool_definitions(
&NoMcp,
true,
true,
true,
git,
true,
true,
true,
true,
true,
true,
true,
true,
Default::default(),
crate::agentic::shell_dialect_sentence(false, false),
)
}
#[test]
fn search_read_includes_read_file() {
let out = execute_tool_search("read", &full_catalog());
assert!(out.contains("read_file"), "got: {out}");
}
#[test]
fn search_plan_includes_update_plan() {
let out = execute_tool_search("plan", &full_catalog());
assert!(out.contains("update_plan"), "got: {out}");
}
#[test]
fn search_description_only_match() {
let out = execute_tool_search("team", &full_catalog());
assert!(
out.contains("crew") || out.contains("compose_roster"),
"description-only match should find crew/compose_roster: {out}"
);
}
#[test]
fn no_match_returns_full_name_list_not_dead_end() {
let out = execute_tool_search("zzzznotarealtoolname", &full_catalog());
assert!(out.contains("read_file"), "got: {out}");
assert!(out.contains("write_file"), "got: {out}");
assert!(!out.trim().is_empty());
}
#[test]
fn search_is_case_insensitive() {
let cat = full_catalog();
let lower = execute_tool_search("read", &cat);
let upper = execute_tool_search("READ", &cat);
assert!(upper.contains("read_file"), "got: {upper}");
let body = |s: &str| {
s.split_once('\n')
.map(|(_, b)| b.to_string())
.unwrap_or_default()
};
assert_eq!(
body(&lower),
body(&upper),
"matched tools must be identical regardless of query case"
);
}
#[test]
fn does_not_list_itself() {
let out = execute_tool_search("search", &full_catalog());
assert!(
!out.contains("- tool_search "),
"tool_search should not appear in its own results: {out}"
);
}
#[test]
fn empty_query_lists_everything() {
let out = execute_tool_search("", &full_catalog());
assert!(out.contains("Available tools in this turn:"), "got: {out}");
assert!(out.contains("read_file"), "got: {out}");
assert!(out.contains("write_file"), "got: {out}");
}
#[test]
fn empty_catalog_is_scoped_to_the_current_turn_not_the_session() {
let out = execute_tool_search("run_command", &serde_json::json!([]));
assert!(out.contains("current turn"), "got: {out}");
assert!(!out.contains("this session"), "got: {out}");
}
#[test]
fn explicit_plan_search_reports_a_hidden_tool_as_present_not_absent() {
let out = execute_tool_search_for_disposition(
"run_command",
&full_catalog(),
super::super::PromptDisposition::Plan,
None,
);
let voices = super::super::DispositionVoices::default();
assert!(
out.contains(&format!("- run_command — {}", voices.discovery_hidden)),
"an authorized tool must be reported hidden, not absent: {out}"
);
assert!(out.contains(&voices.discovery_scope), "got: {out}");
assert!(
!out.contains("Run a shell command"),
"a hidden tool must not be offered with its description: {out}"
);
assert!(!out.contains("Explain"), "got: {out}");
assert!(!out.contains("explain"), "got: {out}");
assert!(
!out.contains("cannot widen an already accepted turn"),
"got: {out}"
);
}
#[test]
fn explain_search_does_not_invent_an_ungranted_tool() {
let mut granted = full_catalog();
granted
.as_array_mut()
.unwrap()
.retain(|def| def["function"]["name"] != "run_command");
let out = execute_tool_search_for_disposition(
"run_command",
&granted,
super::super::PromptDisposition::Explain,
None,
);
assert!(!out.contains("- run_command"), "got: {out}");
}
#[test]
fn explain_read_search_leads_with_the_callable_reader() {
let out = execute_tool_search_for_disposition(
"read a file",
&full_catalog(),
super::super::PromptDisposition::Explain,
None,
);
let first = out.lines().nth(1).unwrap_or_default();
assert!(first.starts_with("- read_file — Read a file"), "got: {out}");
assert!(first.contains("[requires: path]"), "got: {out}");
}
#[test]
fn hidden_entries_never_displace_a_callable_match() {
let explain = super::super::PromptDisposition::Explain;
let catalog = full_catalog();
let filtered = crate::agentic::filter_tools_for_disposition(catalog.clone(), explain);
let voices = super::super::DispositionVoices::default();
let matches = |out: &str| -> Vec<String> {
out.lines()
.filter_map(|line| line.strip_prefix("- "))
.filter(|line| !line.contains(voices.discovery_hidden.as_str()))
.filter_map(|line| line.split(" — ").next())
.map(str::to_string)
.collect()
};
let mut checked = 0;
for query in [
"read a file",
"search the code",
"list files",
"git log",
"fetch a url",
"notes",
] {
let callable = matches(&execute_tool_search(query, &filtered));
checked += callable.len();
let mixed = execute_tool_search_for_disposition(query, &catalog, explain, None);
let shown = matches(&mixed);
let lost: Vec<&String> = callable.iter().filter(|t| !shown.contains(t)).collect();
assert!(
lost.is_empty(),
"{query:?}: hidden entries displaced {lost:?}: {mixed}"
);
}
assert!(
checked > 12,
"only {checked} callable matches were compared"
);
}
#[test]
fn filler_terms_do_not_score() {
let catalog = full_catalog();
let body = |query: &str| {
let out = execute_tool_search(query, &catalog);
out.split_once('\n')
.map(|(_, b)| b.to_string())
.unwrap_or_default()
};
assert_eq!(body("read a file"), body("read file"));
assert_eq!(body("search the code"), body("search code"));
}
#[test]
fn match_line_renders_required_params() {
let out = execute_tool_search("read_file", &full_catalog());
assert!(out.contains("read_file"), "got: {out}");
assert!(out.contains("requires:"), "required params shown: {out}");
}
#[test]
fn lifecycle_discovery_exposes_build_authority_in_short_description() {
let out = execute_tool_search("lifecycle", &full_catalog());
let lifecycle = out
.lines()
.find(|line| line.starts_with("- lifecycle —"))
.unwrap();
assert!(lifecycle.contains("action=build"), "{lifecycle}");
assert!(lifecycle.contains("explicit"), "{lifecycle}");
assert!(lifecycle.contains("offline"), "{lifecycle}");
}
#[test]
fn one_line_collapses_whitespace_and_truncates() {
let collapsed = one_line("a\n b\t c", 100);
assert_eq!(collapsed, "a b c");
let truncated = one_line(&"x".repeat(200), 10);
assert!(truncated.chars().count() <= 11, "got: {truncated}"); assert!(truncated.ends_with('…'));
}
}