fabro/lib/components/fabro-agent/src/native_tool.rs
Bryan Helmkamp 3c755a7d4e
refactor(agent): give Claude 5 subagent tools fabro canonical names
`NativeTool` documents itself as "an identity, not a name" whose canonical
form is fabro's own vocabulary, with harness names layered on as aliases:
`to_string = "read_file", serialize = "Read"`.

The four Claude 5 subagent tools inverted that. `ClaudeAgent` declared
`to_string = "Agent"`, making the Anthropic wire name the identity and
leaving `name(ToolVocabulary::Fabro)` returning `"Agent"` -- and pairing a
provider-specific variant name with a generic wire name. It also meant the
`Claude5` arm listed none of them: they fell through to
`canonical_name()` and were correct only by accident.

Rename to `BackgroundAgent` / `AgentOutput` / `StopAgent` / `MessageAgent`
with fabro canonical names, keep the harness names as `serialize` aliases
so `from_any_name` still resolves them, and name them explicitly in the
`Claude5` vocabulary arm. Also map `Grep`/`Glob` there: that arm describes
the vocabulary rather than the profile's registry, and if either were ever
registered it would otherwise reach the harness lowercased.

Records why these are separate identities from
`spawn_agent`/`wait`/`close_agent`/`send_input` rather than aliases of
them, since the capabilities genuinely differ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 07:57:42 -04:00

402 lines
15 KiB
Rust

//! The built-in tools fabro implements, and the names they can be expressed
//! under.
//!
//! Tool names reach this crate from two very different places. The tools fabro
//! implements are a fixed set known at compile time; MCP, skill, and
//! run-scoped tools are open-ended and named by whatever registered them. This
//! module covers the first group, so anything reasoning about a built-in tool
//! is checked by the compiler instead of matched on string literals.
//!
//! A [`NativeTool`] is an identity, not a name. The same tool is expressed
//! under different names depending on the [`ToolVocabulary`] a profile speaks:
//! fabro's own names by default, Anthropic's names for Claude 5, Kimi Code's
//! names for the Kimi profile, and Codex's names for the GPT-5.6 profile.
//! Permissions, categories, and telemetry resolve any name back to the
//! identity, so behavior never depends on which vocabulary is in play.
//!
//! `ToolDefinition.name` and [`crate::tool_registry::ToolRegistry`] keys stay
//! `String`, because they carry both groups.
use fabro_types::AgentToolCategory;
use strum::{Display, EnumString, IntoStaticStr, VariantArray};
/// A naming scheme for built-in tools.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, VariantArray)]
pub enum ToolVocabulary {
/// Fabro's own names, and the canonical identity used internally.
#[default]
Fabro,
/// The names Anthropic's Claude 5 coding harness exposes.
Claude5,
/// The names Kimi Code exposes, for models trained against that harness.
KimiCode,
/// The names Codex exposes, for the GPT-5.6 models trained against it.
Codex,
}
/// A tool fabro implements itself.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, Display, EnumString, IntoStaticStr, VariantArray,
)]
pub enum NativeTool {
#[strum(to_string = "read_file", serialize = "Read")]
ReadFile,
#[strum(to_string = "read_many_files")]
ReadManyFiles,
#[strum(to_string = "write_file", serialize = "Write")]
WriteFile,
#[strum(to_string = "edit_file", serialize = "Edit")]
EditFile,
#[strum(to_string = "apply_patch")]
ApplyPatch,
#[strum(to_string = "list_dir")]
ListDir,
#[strum(to_string = "grep", serialize = "Grep")]
Grep,
#[strum(to_string = "glob", serialize = "Glob")]
Glob,
#[strum(to_string = "shell", serialize = "Bash", serialize = "shell_command")]
Shell,
#[strum(to_string = "web_search", serialize = "WebSearch")]
WebSearch,
#[strum(
to_string = "web_fetch",
serialize = "FetchURL",
serialize = "WebFetch"
)]
WebFetch,
#[strum(to_string = "spawn_agent")]
SpawnAgent,
#[strum(to_string = "send_input")]
SendInput,
#[strum(to_string = "wait")]
Wait,
#[strum(to_string = "close_agent")]
CloseAgent,
// Claude 5 drives one background agent through four tools, where fabro's
// own vocabulary uses `spawn_agent`/`wait`/`close_agent`/`send_input`.
// They are separate identities rather than aliases of those because the
// capabilities differ: `Agent` runs in the background or inline depending
// on `run_in_background`, and `TaskOutput` both polls and waits. Mapping
// them onto the fabro four would promise semantics those tools do not
// have -- the same reason Kimi Code's `Agent` is deliberately unmapped.
#[strum(to_string = "background_agent", serialize = "Agent")]
BackgroundAgent,
#[strum(to_string = "agent_output", serialize = "TaskOutput")]
AgentOutput,
#[strum(to_string = "stop_agent", serialize = "TaskStop")]
StopAgent,
#[strum(to_string = "message_agent", serialize = "SendMessage")]
MessageAgent,
#[strum(to_string = "use_skill", serialize = "Skill")]
UseSkill,
#[strum(to_string = "update_plan")]
UpdatePlan,
// Task and question tools are already PascalCase on the wire; they came
// from the Claude Code vocabulary rather than fabro's own.
#[strum(to_string = "TaskCreate")]
TaskCreate,
#[strum(to_string = "TaskUpdate")]
TaskUpdate,
#[strum(to_string = "TaskGet")]
TaskGet,
#[strum(to_string = "TaskList")]
TaskList,
#[strum(to_string = "TodoList")]
TodoList,
#[strum(to_string = "AskUserQuestion")]
AskUserQuestion,
#[strum(to_string = "request_user_input")]
RequestUserInput,
}
impl NativeTool {
/// The canonical name: how fabro refers to this tool internally.
#[must_use]
pub fn canonical_name(self) -> &'static str {
self.into()
}
/// Resolve a canonical fabro name to its built-in identity.
///
/// Unlike [`Self::from_any_name`], this deliberately ignores provider
/// aliases. Registries use it while registering tools so an unrelated
/// extension named `Read` is not silently treated as fabro's file reader.
#[must_use]
pub fn from_canonical_name(name: &str) -> Option<Self> {
Self::VARIANTS
.iter()
.copied()
.find(|tool| tool.canonical_name() == name)
}
/// The name this tool is exposed under in `vocabulary`.
///
/// A tool with no counterpart in the vocabulary keeps its canonical name.
#[must_use]
pub fn name(self, vocabulary: ToolVocabulary) -> &'static str {
match vocabulary {
ToolVocabulary::Fabro => self.canonical_name(),
ToolVocabulary::Claude5 => match self {
Self::ReadFile => "Read",
Self::WriteFile => "Write",
Self::EditFile => "Edit",
Self::Shell => "Bash",
// Named for completeness: this arm describes the vocabulary,
// not the profile's registry, and the Claude 5 profile
// deliberately registers neither.
Self::Grep => "Grep",
Self::Glob => "Glob",
Self::WebSearch => "WebSearch",
Self::WebFetch => "WebFetch",
Self::UseSkill => "Skill",
Self::BackgroundAgent => "Agent",
Self::AgentOutput => "TaskOutput",
Self::StopAgent => "TaskStop",
Self::MessageAgent => "SendMessage",
other => other.canonical_name(),
},
ToolVocabulary::KimiCode => match self {
Self::ReadFile => "Read",
Self::WriteFile => "Write",
Self::EditFile => "Edit",
Self::Shell => "Bash",
Self::Grep => "Grep",
Self::Glob => "Glob",
Self::WebSearch => "WebSearch",
Self::WebFetch => "FetchURL",
Self::UseSkill => "Skill",
// Deliberately unmapped. Kimi Code's `Agent` launches a
// subagent and returns its result; fabro's spawn_agent returns
// a handle that send_input, wait, and close_agent then drive.
// Borrowing the name without the semantics would promise a
// result the tool does not return -- the same mistake as
// exposing incremental task tools under a whole-list name.
Self::SpawnAgent | Self::SendInput | Self::Wait | Self::CloseAgent => {
self.canonical_name()
}
other => other.canonical_name(),
},
// Codex names its shell `shell_command`. Its remaining tools that
// fabro also implements -- apply_patch, update_plan,
// request_user_input -- already agree with fabro's names, and the
// tools fabro has that Codex does not keep fabro's names.
//
// Deliberately unmapped: Codex's sub-agent tools differ by
// multi-agent protocol version rather than by name alone
// (`resume_agent` has no fabro counterpart), and its `web.run` is a
// namespaced tool, which fabro's registry cannot express.
ToolVocabulary::Codex => match self {
Self::Shell => "shell_command",
other => other.canonical_name(),
},
}
}
/// Resolve a name in any known vocabulary back to the tool it identifies.
///
/// Returns `None` for MCP, skill, and run-scoped tools, whose names are
/// not drawn from this set.
#[must_use]
pub fn from_any_name(name: &str) -> Option<Self> {
name.parse().ok()
}
/// Coarse access category, or `None` when the tool is not part of the
/// permission taxonomy.
///
/// Matched exhaustively so a new built-in tool has to state its answer.
/// `None` is a real answer, and callers disagree about what it means: the
/// CLI gate treats an uncategorized tool as `Shell` (requiring approval),
/// while projection metadata reports `Other`.
#[must_use]
pub fn category(self) -> Option<AgentToolCategory> {
match self {
Self::ReadFile | Self::ReadManyFiles | Self::Grep | Self::Glob | Self::ListDir => {
Some(AgentToolCategory::Read)
}
Self::WriteFile | Self::EditFile | Self::ApplyPatch => Some(AgentToolCategory::Write),
Self::Shell => Some(AgentToolCategory::Shell),
Self::SpawnAgent
| Self::SendInput
| Self::Wait
| Self::CloseAgent
| Self::BackgroundAgent
| Self::AgentOutput
| Self::StopAgent
| Self::MessageAgent => Some(AgentToolCategory::Subagent),
// Uncategorized today. Giving these a category would change the CLI
// permission gate, which is a behavior change rather than a
// classification cleanup, so they keep their existing answer.
Self::WebSearch
| Self::WebFetch
| Self::UseSkill
| Self::UpdatePlan
| Self::TaskCreate
| Self::TaskUpdate
| Self::TaskGet
| Self::TaskList
| Self::TodoList
| Self::AskUserQuestion
| Self::RequestUserInput => None,
}
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::*;
#[test]
fn canonical_names_round_trip() {
for tool in NativeTool::VARIANTS {
assert_eq!(NativeTool::from_str(tool.canonical_name()).unwrap(), *tool);
}
}
#[test]
fn every_name_in_every_vocabulary_resolves_back_to_its_tool() {
for tool in NativeTool::VARIANTS {
for vocabulary in ToolVocabulary::VARIANTS {
let name = tool.name(*vocabulary);
assert_eq!(
NativeTool::from_any_name(name),
Some(*tool),
"{name} ({vocabulary:?}) should resolve back to {tool}"
);
}
}
}
/// Two tools resolving to the same name would make `from_any_name`
/// ambiguous and silently mis-categorize one of them.
#[test]
fn vocabularies_do_not_collide() {
let mut seen: Vec<(&str, NativeTool)> = Vec::new();
for tool in NativeTool::VARIANTS {
for vocabulary in ToolVocabulary::VARIANTS {
let name = tool.name(*vocabulary);
if let Some((_, other)) = seen.iter().find(|(seen, _)| *seen == name) {
assert_eq!(*other, *tool, "name '{name}' is claimed by two tools");
} else {
seen.push((name, *tool));
}
}
}
}
#[test]
fn kimi_vocabulary_renames_only_where_kimi_code_differs() {
assert_eq!(NativeTool::ReadFile.name(ToolVocabulary::KimiCode), "Read");
assert_eq!(NativeTool::Shell.name(ToolVocabulary::KimiCode), "Bash");
assert_eq!(
NativeTool::WebFetch.name(ToolVocabulary::KimiCode),
"FetchURL"
);
// No Kimi Code counterpart: keeps fabro's name.
assert_eq!(
NativeTool::TaskCreate.name(ToolVocabulary::KimiCode),
"TaskCreate"
);
assert_eq!(
NativeTool::SpawnAgent.name(ToolVocabulary::KimiCode),
"spawn_agent"
);
}
#[test]
fn claude5_vocabulary_uses_anthropic_harness_names() {
assert_eq!(NativeTool::ReadFile.name(ToolVocabulary::Claude5), "Read");
assert_eq!(NativeTool::Shell.name(ToolVocabulary::Claude5), "Bash");
assert_eq!(
NativeTool::WebFetch.name(ToolVocabulary::Claude5),
"WebFetch"
);
assert_eq!(
NativeTool::BackgroundAgent.name(ToolVocabulary::Claude5),
"Agent"
);
assert_eq!(
NativeTool::AgentOutput.name(ToolVocabulary::Claude5),
"TaskOutput"
);
assert_eq!(
NativeTool::StopAgent.name(ToolVocabulary::Claude5),
"TaskStop"
);
assert_eq!(
NativeTool::MessageAgent.name(ToolVocabulary::Claude5),
"SendMessage"
);
}
/// The harness name is how a tool is expressed, not what it is: the
/// identity keeps a fabro name, and the harness name resolves back to it.
#[test]
fn claude5_subagent_tools_keep_fabro_canonical_names() {
for (tool, canonical, claude5) in [
(NativeTool::BackgroundAgent, "background_agent", "Agent"),
(NativeTool::AgentOutput, "agent_output", "TaskOutput"),
(NativeTool::StopAgent, "stop_agent", "TaskStop"),
(NativeTool::MessageAgent, "message_agent", "SendMessage"),
] {
assert_eq!(tool.canonical_name(), canonical);
assert_eq!(tool.name(ToolVocabulary::Fabro), canonical);
assert_eq!(tool.name(ToolVocabulary::Claude5), claude5);
assert_eq!(NativeTool::from_any_name(canonical), Some(tool));
assert_eq!(NativeTool::from_any_name(claude5), Some(tool));
}
}
#[test]
fn codex_vocabulary_renames_only_the_shell() {
assert_eq!(
NativeTool::Shell.name(ToolVocabulary::Codex),
"shell_command"
);
// Already agree with Codex's names.
assert_eq!(
NativeTool::ApplyPatch.name(ToolVocabulary::Codex),
"apply_patch"
);
assert_eq!(
NativeTool::UpdatePlan.name(ToolVocabulary::Codex),
"update_plan"
);
assert_eq!(
NativeTool::RequestUserInput.name(ToolVocabulary::Codex),
"request_user_input"
);
// No Codex counterpart: keeps fabro's name.
assert_eq!(
NativeTool::ReadFile.name(ToolVocabulary::Codex),
"read_file"
);
}
/// The canonical name is what permissions, categories, and telemetry key
/// on, so adding `shell_command` as a parse alias must not change it.
#[test]
fn shell_keeps_its_canonical_name_alongside_the_codex_alias() {
assert_eq!(NativeTool::Shell.canonical_name(), "shell");
assert_eq!(NativeTool::Shell.to_string(), "shell");
assert_eq!(
NativeTool::from_any_name("shell_command"),
Some(NativeTool::Shell)
);
assert_eq!(NativeTool::from_canonical_name("shell_command"), None);
}
#[test]
fn categories_are_vocabulary_independent() {
for tool in NativeTool::VARIANTS {
for vocabulary in ToolVocabulary::VARIANTS {
let resolved = NativeTool::from_any_name(tool.name(*vocabulary))
.expect("known name should resolve");
assert_eq!(resolved.category(), tool.category());
}
}
}
}