use super::super::prompt_intake::PromptDisposition;
use super::*;
use std::sync::LazyLock;
const FILE_PATH_DESCRIPTION: &str = "Absolute or workspace-relative file path. \
Preserve an operator-supplied absolute path.";
pub fn tool_definitions() -> serde_json::Value {
tool_definitions_with_budget(Default::default())
}
pub(crate) fn tool_definitions_with_budget(
command_budget: crate::RunCommandBudget,
) -> serde_json::Value {
tool_definitions_with_dialect(
command_budget,
super::shell::shell_dialect_sentence(false, false),
)
}
pub(crate) fn tool_definitions_with_dialect(
command_budget: crate::RunCommandBudget,
shell_dialect: &str,
) -> serde_json::Value {
let shell_dialect = if shell_dialect.is_empty() {
super::shell::shell_dialect_sentence(false, false)
} else {
shell_dialect
};
serde_json::json!([
{
"type": "function",
"function": {
"name": "run_command",
"description": format!("{} {} {}", "Run a command in the workspace shell and return its output and exit status. \
To publish, run `git push origin` and `gh pr create --base <base> --head <branch> --title … --body …` as standalone commands; newt brokers them with the operator's credentials.", super::shell::run_command_limit_sentence(command_budget), shell_dialect),
"parameters": {
"type": "object",
"properties": {
"command": { "type": "string", "description": "The shell command to run" },
"timeout_secs": { "type": "integer", "minimum": 1, "maximum": super::shell::RUN_COMMAND_MAX_SECS, "description": "Wall-clock limit in seconds, max 300. Build commands otherwise get 30 minutes." },
"cwd": { "type": "string", "description": "Working directory, relative to the workspace root or absolute. Default: the current command directory from the turn context." },
"fs_read": { "type": "array", "items": { "type": "string" }, "description": "Absolute paths this call needs to read." },
"fs_write": { "type": "array", "items": { "type": "string" }, "description": "Absolute paths this call needs to write." }
},
"required": ["command"]
}
}
},
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file: up to `limit` lines (default 2000) starting at \
1-based `offset` (default 1). A truncated result ends with a \
footer naming the next window.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": FILE_PATH_DESCRIPTION },
"offset": { "type": "integer", "description": "1-based line number to start at (default 1)" },
"limit": { "type": "integer", "description": "Maximum number of lines to return (default 2000)" }
},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "write_file",
"description": "Create a file, or replace one entirely (parent directories \
are created). To change part of an existing file use edit_file. \
copy_from appends an exact line range from another file; \
move_from extracts named Rust free functions from another file \
into this new .rs file and wires the module (content must be \
empty and copy_from absent).",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": FILE_PATH_DESCRIPTION },
"content": { "type": "string", "description": "The file contents; with copy_from, a header placed before the copied lines (may be empty); with move_from, must be empty" },
"move_from": { "type": "object", "properties": { "path": { "type": "string" }, "items": { "type": "array", "items": { "type": "string" }, "minItems": 1, "uniqueItems": true } }, "required": ["path", "items"] },
"copy_from": { "type": "object", "description": "Append lines start_line..end_line of another file, copied exactly", "properties": { "path": { "type": "string" }, "start_line": { "type": "integer" }, "end_line": { "type": "integer" } } }
},
"required": ["path", "content"]
}
}
},
{
"type": "function",
"function": {
"name": "edit_file",
"description": "Replace one exact string in an existing file. Fails if \
old_string is missing or matches more than once (add context). \
An empty new_string deletes the match.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": FILE_PATH_DESCRIPTION },
"old_string": { "type": "string", "description": "Exact string to replace (must match exactly once)" },
"new_string": { "type": "string", "description": "Replacement (\"\" deletes the match)" }
},
"required": ["path", "old_string", "new_string"]
}
}
},
{
"type": "function",
"function": {
"name": "delete_file",
"description": "Delete one file. Refuses directories and missing files.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "File path relative to workspace root" }
},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "list_dir",
"description": "List files in a directory",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Directory path relative to workspace root (use '.' for root)" }
},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "find",
"description": "Find files and directories under the workspace by basename glob, without a shell. Returns relative paths, one per line, sorted; respects .gitignore.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Directory to search under, relative to the workspace root. Default '.'." },
"name": { "type": "string", "description": "Basename glob, e.g. '*.py'. Omit to match everything." },
"type": { "type": "string", "enum": ["f", "d", "any"], "description": "Files ('f'), directories ('d'), or both ('any', the default)." },
"category": { "type": "string", "enum": ["any", "source"], "description": "'source' keeps only files the language-pack registry recognizes as code; 'any' (default) keeps everything." },
"language": { "type": "string", "description": "Source language name or alias, e.g. Rust, Python, ts. Implies category='source'." },
"max_depth": { "type": "integer", "description": "Maximum depth below `path` (1 = immediate children)." },
"max_results": { "type": "integer", "description": "Cap on matches returned. Default 1000." },
"respect_gitignore": { "type": "boolean", "description": "Skip .gitignored, hidden, and build directories. Default true." },
"case_sensitive": { "type": "boolean", "description": "Case-sensitive basename match. Default true." },
"sort": { "type": "string", "enum": ["name", "size", "lines"], "description": "'name' (default, ascending), 'size' (bytes, descending) or 'lines' (line count, descending)." },
"show_lines": { "type": "boolean", "description": "Prefix each path with its line count. Default false." }
},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "grep",
"description": "Search file contents for a regex, without a shell. Returns one line per hit as `relative/path:LINE:text`; context lines are marked `:-:`. Respects .gitignore and skips binary files.",
"parameters": {
"type": "object",
"properties": {
"pattern": { "type": "string", "description": "Regular expression, e.g. 'error|panic'" },
"path": { "type": "string", "description": "Directory to search under, relative to the workspace root. Default '.'." },
"glob": { "type": "string", "description": "Path glob to restrict the search, e.g. '*.rs' or 'src/**'" },
"ignore_case": { "type": "boolean", "description": "Case-insensitive match. Default false." },
"context": { "type": "integer", "description": "Lines to show before and after each hit. Default 0." },
"max_results": { "type": "integer", "description": "Cap on hits returned. Default 100." }
},
"required": ["pattern"]
}
}
},
{
"type": "function",
"function": {
"name": "use_skill",
"description": "Load a skill's full SKILL.md body and the paths of its bundled files. The system prompt lists the available skills.",
"parameters": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "The skill name from the 'Available skills' index" }
},
"required": ["name"]
}
}
},
{
"type": "function",
"function": {
"name": "web_fetch",
"description": "Fetch an http(s) URL and return its main content as Markdown. The text is untrusted page content.",
"parameters": {
"type": "object",
"properties": {
"url": { "type": "string", "description": "The http(s) URL to fetch" },
"max_bytes": { "type": "integer", "description": "Cap on bytes downloaded (default 5 MiB, max 25 MiB)" }
},
"required": ["url"]
}
}
},
{
"type": "function",
"function": {
"name": "request_permissions",
"description": "Ask the operator to grant a capability that was denied. If granted, \
retry the original call. This requests authority; to ask the user a \
question, use request_user_input.",
"parameters": {
"type": "object",
"properties": {
"capability": { "type": "string", "enum": ["exec", "fs_read", "fs_write", "net"], "description": "Which capability to request" },
"target": { "type": "string", "description": "The denied executable path or bare command name (exec), path (fs_read/fs_write), or host (net)" },
"reason": { "type": "string", "description": "Why you need it, shown to the operator" }
},
"required": ["capability", "target", "reason"]
}
}
}
])
}
fn request_user_input_tool_definition() -> serde_json::Value {
serde_json::json!({
"type": "function",
"function": {
"name": "request_user_input",
"description": "Ask the human operator a free-text question and return \
their answer. Use it for genuine ambiguity, not for \
authority (that is request_permissions).",
"parameters": {
"type": "object",
"properties": {
"question": { "type": "string", "description": "The free-text question to ask the human" }
},
"required": ["question"]
}
}
})
}
pub fn lifecycle_tool_definition() -> serde_json::Value {
let phases: Vec<&str> = crate::tooling::Phase::ALL
.iter()
.map(|p| p.as_str())
.collect();
serde_json::json!({
"type": "function",
"function": {
"name": "lifecycle",
"description": "Run this repo's configured command for a project phase; \
action=build runs it with explicit offline build authority. Prefer it \
over run_command for build, test, format, lint and check work.",
"parameters": {
"type": "object",
"properties": {
"phase": {
"type": "string",
"enum": phases,
"description": "The lifecycle phase to run."
},
"action": {
"type": "string",
"enum": ["run", "list", "build"],
"description": "run (default) executes the phase's command; list shows \
it without running; build adds explicit offline build \
authority."
},
"dir": {
"type": "string",
"description": "Subdirectory to run the phase in, relative to the \
workspace root, for a nested project."
}
},
"required": ["phase"]
}
}
})
}
#[allow(clippy::too_many_arguments)] pub(crate) fn merged_tool_definitions(
mcp: &dyn McpTools,
with_save_note: bool,
with_recall: bool,
with_memory_fetch: bool,
git_read_scope: Option<&crate::caveats::Scope<String>>,
with_team: bool,
with_scratchpad: bool,
with_code_search: bool,
with_experiential: bool,
with_scheduled: bool,
with_operating_mode_control: bool,
with_plan_mode_control: bool,
with_plan_mode_active: bool,
command_budget: crate::RunCommandBudget,
shell_dialect: &str,
) -> serde_json::Value {
let mut defs = match tool_definitions_with_dialect(command_budget, shell_dialect) {
serde_json::Value::Array(a) => a,
other => vec![other],
};
for spec in EXTENDED_TOOL_REGISTRY {
if gate_satisfied(
spec.gate,
with_save_note,
with_recall,
with_memory_fetch,
git_read_scope.is_some(),
with_team,
with_scratchpad,
with_code_search,
with_experiential,
with_scheduled,
with_operating_mode_control,
with_plan_mode_control,
with_plan_mode_active,
) {
defs.push(match (spec.gate, git_read_scope) {
(Gate::Git, Some(scope)) => {
super::super::git_tool::definition_for_read_scope(scope)
}
_ => (spec.definition)(),
});
}
}
let mut mcp_defs = mcp.tool_defs();
for definition in &mut mcp_defs {
super::strip_mcp_catalog_metadata(definition);
}
defs.extend(mcp_defs);
serde_json::Value::Array(defs)
}
fn is_persona_unfenceable_tool(name: &str) -> bool {
matches!(name, "re_read" | "propose_context")
|| EXTENDED_TOOL_REGISTRY.iter().any(|spec| {
matches!(
spec.gate,
Gate::Always | Gate::OperatingMode | Gate::PlanMode | Gate::ScheduledPlanMode
) && spec.name == name
})
}
pub fn persona_tool_allowed(name: &str, allow: &[String]) -> bool {
allow.iter().any(|t| t == name) || is_persona_unfenceable_tool(name)
}
fn tool_def_name(def: &serde_json::Value) -> Option<&str> {
def.get("function")
.and_then(|f| f.get("name"))
.and_then(|n| n.as_str())
}
pub fn filter_advertised_tools(
defs: serde_json::Value,
allow: Option<&[String]>,
) -> serde_json::Value {
let Some(allow) = allow else { return defs };
let serde_json::Value::Array(mut arr) = defs else {
return defs;
};
arr.sort_by_key(|def| {
!tool_def_name(def).is_some_and(|name| persona_tool_allowed(name, allow))
});
serde_json::Value::Array(arr)
}
pub fn tool_allowed(disposition: PromptDisposition, name: &str) -> bool {
match disposition {
PromptDisposition::Act | PromptDisposition::Explain | PromptDisposition::Research => true,
PromptDisposition::Ask => false,
PromptDisposition::Plan => {
(name != "web_fetch" && common_read_only_tool_allowed(name))
|| matches!(name, "update_plan" | "exit_plan_mode")
}
}
}
pub(super) fn is_mcp_tool_name(name: &str) -> bool {
name.split_once("__")
.is_some_and(|(server, tool)| !server.is_empty() && !tool.is_empty())
}
fn common_read_only_tool_allowed(name: &str) -> bool {
crate::navigator::NAV_TOOL_NAMES.contains(&name)
|| matches!(
name,
"read_file"
| "git"
| "list_dir"
| "find"
| "grep"
| "prompt_read"
| "artifact_read"
| "resume_context"
| "re_read"
| "propose_context"
| "web_fetch"
| "use_skill"
| "recall"
| "memory_fetch"
| "state_get"
| "code_search"
| "where_is"
| "experience_recall"
| "plan_get"
| "tool_search"
| "get_context_remaining"
| "render_report"
| "select_operating_mode"
| "request_user_input"
)
}
pub fn filter_tools_for_disposition(
defs: serde_json::Value,
disposition: PromptDisposition,
) -> serde_json::Value {
if matches!(
disposition,
PromptDisposition::Act | PromptDisposition::Explain | PromptDisposition::Research
) {
return defs;
}
let serde_json::Value::Array(arr) = defs else {
return serde_json::Value::Array(Vec::new());
};
serde_json::Value::Array(
arr.into_iter()
.filter(|def| tool_def_name(def).is_some_and(|name| tool_allowed(disposition, name)))
.map(|def| {
if tool_def_name(&def) == Some("git") {
super::super::git_tool::read_only_definition()
} else {
def
}
})
.collect(),
)
}
pub(super) fn disposition_tool_denied_message(
disposition: PromptDisposition,
name: &str,
) -> String {
debug_assert!(
disposition != PromptDisposition::Act,
"Act permits every tool and must never reach a disposition refusal"
);
super::super::DispositionVoices::default().denied_block(disposition, name)
}
const DIRECT_TOOL_NAMES: &[&str] = &[
"list_dir",
"read_file",
"write_file",
"edit_file",
"delete_file",
"use_skill",
"web_fetch",
"find",
"grep",
];
const SHELL_COMPOSITION_MARKERS: &[&str] = &["|", ">", "<", ";", "&&", "||", "$(", "`", "&"];
fn uses_shell_composition(command: &str) -> bool {
SHELL_COMPOSITION_MARKERS
.iter()
.any(|m| command.contains(m))
}
pub(super) fn run_command_redirect(command: &str) -> Option<&'static str> {
if uses_shell_composition(command) {
return None;
}
let first = command.split_ascii_whitespace().next().unwrap_or("");
DIRECT_TOOL_NAMES.iter().copied().find(|&t| t == first)
}
const SHELL_SEGMENT_SEPARATORS: &[char] = &['&', '|', ';', '>', '<', '`', '\n'];
const GIT_GLOBAL_OPTS_TAKING_VALUE: &[&str] =
&["-c", "-C", "--git-dir", "--work-tree", "--namespace"];
fn is_git_binary(token: &str) -> bool {
token == "git" || token.ends_with("/git")
}
fn is_env_assignment(token: &str) -> bool {
let Some((name, _val)) = token.split_once('=') else {
return false;
};
!name.is_empty()
&& name.bytes().next().is_some_and(|b| b.is_ascii_alphabetic())
&& name.bytes().all(|b| b.is_ascii_alphabetic() || b == b'_')
}
fn git_subcommand_after_binary<'a>(tail: &'a [&'a str]) -> Option<&'a str> {
let mut i = 0;
while i < tail.len() {
let tok = tail[i];
if tok.starts_with('-') {
i += if GIT_GLOBAL_OPTS_TAKING_VALUE.contains(&tok) {
2
} else {
1
};
continue;
}
return Some(tok);
}
None
}
const SHELL_GIT_COMMIT_SUBCOMMANDS: &[&str] =
&["commit", "merge", "cherry-pick", "revert", "rebase"];
const SHELL_GIT_ABORT_FLAGS: &[&str] = &["--abort", "--quit"];
pub(super) fn run_command_creates_shell_git_commit(command: &str) -> bool {
let normalized = command.replace("$(", "\n");
for segment in normalized.split(SHELL_SEGMENT_SEPARATORS) {
let toks: Vec<&str> = segment.split_whitespace().collect();
if toks.is_empty() {
continue;
}
let mut i = 0;
while i < toks.len() && is_env_assignment(toks[i]) {
i += 1;
}
if i >= toks.len() || !is_git_binary(toks[i]) {
continue;
}
if let Some(sub) = git_subcommand_after_binary(&toks[i + 1..]) {
if !SHELL_GIT_COMMIT_SUBCOMMANDS.contains(&sub) {
continue;
}
let args = &toks[i + 2..];
if sub != "commit" && args.iter().any(|a| SHELL_GIT_ABORT_FLAGS.contains(a)) {
continue;
}
return true;
}
}
false
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Gate {
Always,
SaveNote,
Recall,
MemoryFetch,
Git,
Team,
Scratchpad,
CodeSearch,
Experiential,
Scheduled,
PlanMode,
ScheduledPlanMode,
OperatingMode,
}
pub(super) struct ToolSpec {
pub(super) name: &'static str,
pub(super) definition: fn() -> serde_json::Value,
pub(super) gate: Gate,
}
pub(super) const EXTENDED_TOOL_REGISTRY: &[ToolSpec] = &[
ToolSpec {
name: "resume_context",
definition: super::super::resume::resume_context_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "prompt_read",
definition: super::super::prompt_read::prompt_read_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "artifact_read",
definition: super::super::artifact_read::artifact_read_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "tool_search",
definition: super::super::tool_search::tool_search_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "get_context_remaining",
definition: super::super::budget::get_context_remaining_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "request_user_input",
definition: request_user_input_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "lifecycle",
definition: lifecycle_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "render_report",
definition: render_report_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "save_note",
definition: save_note_tool_definition,
gate: Gate::SaveNote,
},
ToolSpec {
name: "recall",
definition: recall_tool_definition,
gate: Gate::Recall,
},
ToolSpec {
name: "memory_fetch",
definition: memory_fetch_tool_definition,
gate: Gate::MemoryFetch,
},
ToolSpec {
name: "git",
definition: super::super::git_tool::git_tool_definition,
gate: Gate::Git,
},
ToolSpec {
name: "compose_roster",
definition: super::super::crew_tool::compose_roster_tool_definition,
gate: Gate::Team,
},
ToolSpec {
name: "crew",
definition: super::super::crew_tool::crew_tool_definition,
gate: Gate::Team,
},
ToolSpec {
name: "state_set",
definition: super::super::scratchpad::state_set_tool_definition,
gate: Gate::Scratchpad,
},
ToolSpec {
name: "state_get",
definition: super::super::scratchpad::state_get_tool_definition,
gate: Gate::Scratchpad,
},
ToolSpec {
name: "state_clear",
definition: super::super::scratchpad::state_clear_tool_definition,
gate: Gate::Scratchpad,
},
ToolSpec {
name: "code_search",
definition: super::super::semantic::code_search_tool_definition,
gate: Gate::CodeSearch,
},
ToolSpec {
name: "where_is",
definition: crate::where_is::where_is_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "goto_definition",
definition: crate::navigator::goto_definition_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "text_search",
definition: crate::navigator::text_search_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "find_references",
definition: crate::navigator::find_references_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "find_tests",
definition: crate::navigator::find_tests_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "find_callers",
definition: crate::navigator::find_callers_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "find_callees",
definition: crate::navigator::find_callees_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "find_implementations",
definition: crate::navigator::find_implementations_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "find_hierarchy",
definition: crate::navigator::find_hierarchy_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "inspect_type",
definition: crate::navigator::inspect_type_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "impact",
definition: crate::navigator::impact_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "experience_record",
definition: super::super::experiential::experience_record_tool_definition,
gate: Gate::Experiential,
},
ToolSpec {
name: "experience_recall",
definition: super::super::experiential::experience_recall_tool_definition,
gate: Gate::Experiential,
},
ToolSpec {
name: "update_plan",
definition: super::super::scheduled::update_plan_tool_definition,
gate: Gate::Scheduled,
},
ToolSpec {
name: "plan_get",
definition: super::super::scheduled::plan_get_tool_definition,
gate: Gate::Scheduled,
},
ToolSpec {
name: "enter_plan_mode",
definition: super::super::scheduled::enter_plan_mode_tool_definition,
gate: Gate::ScheduledPlanMode,
},
ToolSpec {
name: "exit_plan_mode",
definition: super::super::scheduled::exit_plan_mode_tool_definition,
gate: Gate::PlanMode,
},
ToolSpec {
name: "select_operating_mode",
definition: super::super::operating_mode::select_operating_mode_tool_definition,
gate: Gate::OperatingMode,
},
];
pub(super) const BASE_TOOL_NAMES: &[&str] = &[
"run_command",
"read_file",
"write_file",
"edit_file",
"delete_file",
"list_dir",
"find",
"grep",
"use_skill",
"web_fetch",
"request_permissions",
];
pub(super) static ALL_TOOL_NAMES: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
BASE_TOOL_NAMES
.iter()
.copied()
.chain(EXTENDED_TOOL_REGISTRY.iter().map(|s| s.name))
.collect()
});
pub(crate) fn known_builtin_tool_name(tool_name: &str) -> bool {
matches!(tool_name, "re_read" | "propose_context") || ALL_TOOL_NAMES.contains(&tool_name)
}
#[allow(clippy::too_many_arguments)] fn gate_satisfied(
gate: Gate,
with_save_note: bool,
with_recall: bool,
with_memory_fetch: bool,
with_git: bool,
with_team: bool,
with_scratchpad: bool,
with_code_search: bool,
with_experiential: bool,
with_scheduled: bool,
with_operating_mode_control: bool,
with_plan_mode_control: bool,
with_plan_mode_active: bool,
) -> bool {
match gate {
Gate::Always => true,
Gate::SaveNote => with_save_note,
Gate::Recall => with_recall,
Gate::MemoryFetch => with_memory_fetch,
Gate::Git => with_git,
Gate::Team => with_team,
Gate::Scratchpad => with_scratchpad,
Gate::CodeSearch => with_code_search,
Gate::Experiential => with_experiential,
Gate::Scheduled => with_scheduled,
Gate::PlanMode => (with_scheduled && with_plan_mode_control) || with_plan_mode_active,
Gate::ScheduledPlanMode => with_scheduled && with_plan_mode_control,
Gate::OperatingMode => with_operating_mode_control,
}
}
pub(crate) fn is_hallucination(tool_name: &str, args: &serde_json::Value) -> bool {
if tool_name == "run_command" {
let cmd = args["command"].as_str().unwrap_or("");
return run_command_redirect(cmd).is_some();
}
if tool_name.contains("__") {
return false;
}
!ALL_TOOL_NAMES.contains(&tool_name)
}
pub(crate) enum AliasOutcome {
Rewrite(&'static str),
Correct(String),
}
pub(crate) fn resolve_tool_alias(name: &str) -> Option<AliasOutcome> {
match name {
"execute" | "exec" | "bash" | "shell" | "sh" | "zsh" | "terminal" | "run_shell_command"
| "shell_command" | "system" => Some(AliasOutcome::Rewrite("run_command")),
"run_phase" | "run_lifecycle" | "lifecycle_run" => Some(AliasOutcome::Rewrite("lifecycle")),
"str_replace_editor" | "str_replace" | "str-replace-editor" | "apply_patch" | "edit"
| "editor" | "replace_in_file" | "search_replace" => Some(AliasOutcome::Correct(format!(
"'{name}' is not a newt tool. To change an existing file, call \
edit_file with {{\"path\", \"old_string\", \"new_string\"}} \
(replaces one exact occurrence). For a new file or a full \
rewrite, call write_file with {{\"path\", \"content\"}}."
))),
"create_file" | "new_file" | "createfile" | "add_file" | "touch" => {
Some(AliasOutcome::Correct(format!(
"'{name}' is not a newt tool. To create or overwrite a file, call \
write_file with {{\"path\", \"content\"}}. To change part of an \
existing file, call edit_file with \
{{\"path\", \"old_string\", \"new_string\"}}."
)))
}
"remove_file" | "delete" | "remove" | "unlink" | "rm_file" => {
Some(AliasOutcome::Correct(format!(
"'{name}' is not a newt tool. To remove one file, call delete_file \
with {{\"path\"}}. It is governed by fs_write permissions and \
prompts the operator when a grant is needed."
)))
}
"mkdir" | "make_dir" | "makedirs" | "mkdirs" | "create_dir" | "create_directory" => {
Some(AliasOutcome::Correct(
"newt has no mkdir/touch tool — call write_file; it creates parent \
directories automatically (create_dir_all). For an empty file, call \
write_file with empty content."
.to_string(),
))
}
"cat" | "open_file" | "view_file" | "view" | "open" => {
Some(AliasOutcome::Correct(format!(
"'{name}' is not a newt tool. To read a file, call read_file with \
{{\"path\"}}. To list a directory, call list_dir with {{\"path\"}}."
)))
}
"make_plan" | "create_plan" | "plan" | "planning" | "todo" | "todos" | "todo_write" => {
Some(AliasOutcome::Correct(format!(
"'{name}' is not a newt tool. To start or revise your plan, call update_plan with \
{{\"plan\":[{{\"step\",\"status\"}}]}} — send the full ordered list each time, \
each step's status one of pending/in_progress/completed (exactly one \
in_progress)."
)))
}
"next_step" | "complete_step" | "finish_step" | "mark_done" | "step_done" => {
Some(AliasOutcome::Correct(format!(
"'{name}' is not a newt tool. To advance your plan, call update_plan with the \
full plan and mark the finished step \"completed\" (and the next one \
\"in_progress\")."
)))
}
"get_plan" | "show_plan" | "read_plan" | "current_plan" | "what_was_i_doing" => {
Some(AliasOutcome::Rewrite("plan_get"))
}
"resume" | "where_were_we" | "where_did_we_leave_off" | "catch_me_up" | "recap" => {
Some(AliasOutcome::Rewrite("resume_context"))
}
"delegate" | "spawn_agent" | "subagent" | "sub_agent" | "crew_dispatch" | "run_crew"
| "dispatch_crew" | "fork_agent" | "assign" | "team" => {
Some(AliasOutcome::Correct(format!(
"'{name}' is not a newt tool. Crew/team delegation is only available once the \
human enables /team this session — you cannot turn it on yourself. When /team \
is on, compose_roster ({{\"mode\"}}) proposes a roster and crew ({{\"task\"}}) \
dispatches it."
)))
}
"find_tool" | "search_tools" | "list_tools" | "which_tool" | "available_tools"
| "what_tools" | "tools" => Some(AliasOutcome::Rewrite("tool_search")),
"workflow" | "run_workflow" | "start_workflow" | "pipeline" => Some(AliasOutcome::Correct(
"newt has no workflow tool; sequence the work with update_plan (the full ordered \
plan with statuses), or delegate subtasks via crew/team (needs /team)."
.to_string(),
)),
"context_remaining" | "tokens_left" | "remaining_tokens" | "budget"
| "how_much_context" | "context_budget" | "token_budget" => {
Some(AliasOutcome::Rewrite("get_context_remaining"))
}
"ask_user" | "ask_human" | "prompt_user" | "get_user_input" | "ask_question"
| "clarify" | "ask" => Some(AliasOutcome::Rewrite("request_user_input")),
_ => None,
}
}
pub(crate) fn is_context_remaining_call(name: &str) -> bool {
name == "get_context_remaining"
|| matches!(
resolve_tool_alias(name),
Some(AliasOutcome::Rewrite("get_context_remaining"))
)
}
pub(crate) fn classify_phantom_reach(
name: &str,
args: &serde_json::Value,
result: &str,
ok: bool,
) -> Option<crate::PhantomResolution> {
let _ = ok;
match resolve_tool_alias(name) {
Some(AliasOutcome::Rewrite(canonical)) => {
return Some(crate::PhantomResolution::Rewrite(canonical.to_string()))
}
Some(AliasOutcome::Correct(msg)) => return Some(crate::PhantomResolution::Correct(msg)),
None => {}
}
if is_hallucination(name, args) {
return Some(crate::PhantomResolution::Unknown);
}
let r = result.trim_start();
if name == "state_get" && r.starts_with("no such key") {
return Some(crate::PhantomResolution::RealToolMiss(
"state_get on an unset key".into(),
));
}
if name == "recall" && r.starts_with("no matches in past conversations") {
return Some(crate::PhantomResolution::RealToolMiss(
"recall returned no matches".into(),
));
}
None
}
pub(crate) fn classify_gated_off_reach(
name: &str,
advertise_team: bool,
) -> Option<crate::PhantomResolution> {
if !advertise_team && (name == "crew" || name == "compose_roster") {
return Some(crate::PhantomResolution::GatedOff(
"crew/team surface off (NEWT_TEAM)".into(),
));
}
None
}
pub(super) fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
cur[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = usize::from(ca != cb);
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
pub(super) fn nearest_tool_name(name: &str) -> Option<&'static str> {
let threshold = (name.chars().count() / 3).max(1);
ALL_TOOL_NAMES
.iter()
.map(|&t| (levenshtein(name, t), t))
.filter(|(d, _)| *d <= threshold)
.min_by_key(|(d, _)| *d)
.map(|(_, t)| t)
}
pub(super) fn unknown_tool_message(name: &str) -> String {
let base = BASE_TOOL_NAMES.join(", ");
match nearest_tool_name(name) {
Some(sugg) => format!(
"unknown tool: {name}. Did you mean '{sugg}'? Available tools include: \
{base} (plus any optional tools enabled this session)."
),
None => format!(
"unknown tool: {name}. Available tools include: {base} (plus any \
optional tools enabled this session)."
),
}
}