From 3c755a7d4e7244ea61ecc6bc6221543698f4424c Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 26 Jul 2026 07:57:42 -0400 Subject: [PATCH] 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) --- lib/components/fabro-agent/src/native_tool.rs | 72 +++++++++++++++---- .../fabro-agent/src/profiles/claude5.rs | 2 +- .../fabro-agent/src/profiles/claude5_tools.rs | 8 +-- 3 files changed, 64 insertions(+), 18 deletions(-) diff --git a/lib/components/fabro-agent/src/native_tool.rs b/lib/components/fabro-agent/src/native_tool.rs index 83b1890ff..84061fbc8 100644 --- a/lib/components/fabro-agent/src/native_tool.rs +++ b/lib/components/fabro-agent/src/native_tool.rs @@ -73,14 +73,21 @@ pub enum NativeTool { Wait, #[strum(to_string = "close_agent")] CloseAgent, - #[strum(to_string = "Agent")] - ClaudeAgent, - #[strum(to_string = "TaskOutput")] - TaskOutput, - #[strum(to_string = "TaskStop")] - TaskStop, - #[strum(to_string = "SendMessage")] - SendMessage, + // 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")] @@ -135,9 +142,18 @@ impl NativeTool { 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 { @@ -205,10 +221,10 @@ impl NativeTool { | Self::SendInput | Self::Wait | Self::CloseAgent - | Self::ClaudeAgent - | Self::TaskOutput - | Self::TaskStop - | Self::SendMessage => Some(AgentToolCategory::Subagent), + | 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. @@ -299,9 +315,39 @@ mod tests { "WebFetch" ); assert_eq!( - NativeTool::ClaudeAgent.name(ToolVocabulary::Claude5), + 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] diff --git a/lib/components/fabro-agent/src/profiles/claude5.rs b/lib/components/fabro-agent/src/profiles/claude5.rs index 43a2db179..875001c17 100644 --- a/lib/components/fabro-agent/src/profiles/claude5.rs +++ b/lib/components/fabro-agent/src/profiles/claude5.rs @@ -123,7 +123,7 @@ impl AgentProfile for Claude5Profile { "has_agent", self.base .registry - .get_native(NativeTool::ClaudeAgent) + .get_native(NativeTool::BackgroundAgent) .is_some(), ) .with_bool( diff --git a/lib/components/fabro-agent/src/profiles/claude5_tools.rs b/lib/components/fabro-agent/src/profiles/claude5_tools.rs index 663161bab..58278fa1c 100644 --- a/lib/components/fabro-agent/src/profiles/claude5_tools.rs +++ b/lib/components/fabro-agent/src/profiles/claude5_tools.rs @@ -187,7 +187,7 @@ pub(crate) fn make_agent_tool( ) -> RegisteredTool { RegisteredTool { definition: definition( - NativeTool::ClaudeAgent, + NativeTool::BackgroundAgent, "Launch a child agent for an independent task. Agents run in the background by \ default and notify the parent when they finish. Set run_in_background to false to \ wait for the result synchronously.", @@ -281,7 +281,7 @@ fn finished_output( pub(crate) fn make_task_output_tool(supervisor: SubAgentSupervisor) -> RegisteredTool { RegisteredTool { definition: definition( - NativeTool::TaskOutput, + NativeTool::AgentOutput, "Get a background agent's current status or wait for its final output. Automatic \ completion notifications make ordinary polling unnecessary.", serde_json::json!({ @@ -368,7 +368,7 @@ pub(crate) fn make_task_output_tool(supervisor: SubAgentSupervisor) -> Registere pub(crate) fn make_task_stop_tool(supervisor: SubAgentSupervisor) -> RegisteredTool { RegisteredTool { definition: definition( - NativeTool::TaskStop, + NativeTool::StopAgent, "Stop a running background agent by task ID.", serde_json::json!({ "type": "object", @@ -401,7 +401,7 @@ pub(crate) fn make_task_stop_tool(supervisor: SubAgentSupervisor) -> RegisteredT pub(crate) fn make_send_message_tool(supervisor: SubAgentSupervisor) -> RegisteredTool { RegisteredTool { definition: definition( - NativeTool::SendMessage, + NativeTool::MessageAgent, "Send additional instructions to a running background agent by its task ID.", serde_json::json!({ "type": "object",