Skip to main content

bob_runtime/
router.rs

1//! # Slash Command Router
2//!
3//! Deterministic input router for the Bob Agent Framework.
4//!
5//! All `/`-prefixed inputs are parsed as slash commands and executed
6//! **without** LLM inference — zero latency, deterministic results.
7//! Everything else is treated as natural language for the LLM pipeline.
8//!
9//! ## Supported Commands
10//!
11//! | Command               | Description                          |
12//! |-----------------------|--------------------------------------|
13//! | `/help`               | Show available commands               |
14//! | `/tools`              | List all registered tools             |
15//! | `/tool.describe NAME` | Show full schema for a specific tool  |
16//! | `/tape.search QUERY`  | Search conversation history           |
17//! | `/tape.info`          | Show tape statistics                  |
18//! | `/anchors`            | List all anchors in the tape          |
19//! | `/handoff [NAME]`     | Create a context-window reset point   |
20//! | `/quit`               | Exit the session                      |
21//! | `/COMMAND`             | Execute as shell command (fallback)   |
22
23/// Result of routing a user input string.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum RouteResult {
26    /// A deterministic slash command (bypass LLM).
27    SlashCommand(SlashCommand),
28    /// Natural language input destined for the LLM pipeline.
29    NaturalLanguage(String),
30}
31
32/// Recognized slash commands.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum SlashCommand {
35    /// `/help` — display available commands.
36    Help,
37    /// `/tools` — list all registered tools.
38    Tools,
39    /// `/tool.describe <name>` — describe a specific tool's schema.
40    ToolDescribe { name: String },
41    /// `/tape.search <query>` — full-text search the tape.
42    TapeSearch { query: String },
43    /// `/tape.info` — show tape entry count and last handoff info.
44    TapeInfo,
45    /// `/anchors` — list all anchors in the current session tape.
46    Anchors,
47    /// `/handoff [name]` — create a handoff anchor and reset context window.
48    Handoff { name: Option<String> },
49    /// `/usage` — show cumulative token usage for the current session.
50    Usage,
51    /// `/quit` or `/exit` — signal the channel to close.
52    Quit,
53    /// Fallback: treat unrecognized `/cmd` as a shell command.
54    Shell { command: String },
55}
56
57/// Route raw user input into either a slash command or natural language.
58///
59/// Only inputs starting with `/` are treated as commands. Everything else
60/// goes to the LLM pipeline as natural language.
61#[must_use]
62pub fn route(input: &str) -> RouteResult {
63    let trimmed = input.trim();
64
65    if !trimmed.starts_with('/') {
66        return RouteResult::NaturalLanguage(trimmed.to_string());
67    }
68
69    // Strip the leading `/`
70    let rest = &trimmed[1..];
71
72    // Split into command word and arguments
73    let (cmd, args) = rest
74        .split_once(|c: char| c.is_ascii_whitespace())
75        .map_or((rest, ""), |(c, a)| (c, a.trim()));
76
77    match cmd {
78        "help" | "h" => RouteResult::SlashCommand(SlashCommand::Help),
79        "tools" => RouteResult::SlashCommand(SlashCommand::Tools),
80        "tool.describe" => {
81            RouteResult::SlashCommand(SlashCommand::ToolDescribe { name: args.to_string() })
82        }
83        "tape.search" => {
84            RouteResult::SlashCommand(SlashCommand::TapeSearch { query: args.to_string() })
85        }
86        "tape.info" => RouteResult::SlashCommand(SlashCommand::TapeInfo),
87        "anchors" => RouteResult::SlashCommand(SlashCommand::Anchors),
88        "handoff" => RouteResult::SlashCommand(SlashCommand::Handoff {
89            name: if args.is_empty() { None } else { Some(args.to_string()) },
90        }),
91        "usage" => RouteResult::SlashCommand(SlashCommand::Usage),
92        "quit" | "exit" => RouteResult::SlashCommand(SlashCommand::Quit),
93        _ => {
94            // Unrecognized command → treat as shell command
95            RouteResult::SlashCommand(SlashCommand::Shell { command: rest.to_string() })
96        }
97    }
98}
99
100/// Render the help text for all available slash commands.
101#[must_use]
102pub fn help_text() -> String {
103    "\
104Available commands:
105  /help                 Show this help message
106  /tools                List all registered tools
107  /tool.describe NAME   Show full schema for a tool
108  /tape.search QUERY    Search conversation history
109  /tape.info            Show tape statistics
110  /anchors              List all tape anchors
111  /handoff [NAME]       Reset context window (create handoff point)
112  /usage                Show cumulative session token usage
113  /quit                 Exit the session
114
115Natural language input (without /) goes to the AI model."
116        .to_string()
117}
118
119// ── Tests ────────────────────────────────────────────────────────────
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn natural_language_passthrough() {
127        let result = route("hello world");
128        assert_eq!(result, RouteResult::NaturalLanguage("hello world".to_string()));
129    }
130
131    #[test]
132    fn natural_language_trims_whitespace() {
133        let result = route("  hello  ");
134        assert_eq!(result, RouteResult::NaturalLanguage("hello".to_string()));
135    }
136
137    #[test]
138    fn help_command() {
139        assert_eq!(route("/help"), RouteResult::SlashCommand(SlashCommand::Help));
140        assert_eq!(route("/h"), RouteResult::SlashCommand(SlashCommand::Help));
141    }
142
143    #[test]
144    fn tools_command() {
145        assert_eq!(route("/tools"), RouteResult::SlashCommand(SlashCommand::Tools));
146    }
147
148    #[test]
149    fn tool_describe_command() {
150        assert_eq!(
151            route("/tool.describe file.read"),
152            RouteResult::SlashCommand(SlashCommand::ToolDescribe { name: "file.read".to_string() })
153        );
154    }
155
156    #[test]
157    fn tape_search_command() {
158        assert_eq!(
159            route("/tape.search error handling"),
160            RouteResult::SlashCommand(SlashCommand::TapeSearch {
161                query: "error handling".to_string()
162            })
163        );
164    }
165
166    #[test]
167    fn handoff_with_name() {
168        assert_eq!(
169            route("/handoff phase-2"),
170            RouteResult::SlashCommand(SlashCommand::Handoff { name: Some("phase-2".to_string()) })
171        );
172    }
173
174    #[test]
175    fn handoff_without_name() {
176        assert_eq!(
177            route("/handoff"),
178            RouteResult::SlashCommand(SlashCommand::Handoff { name: None })
179        );
180    }
181
182    #[test]
183    fn usage_command() {
184        assert_eq!(route("/usage"), RouteResult::SlashCommand(SlashCommand::Usage));
185    }
186
187    #[test]
188    fn quit_and_exit_commands() {
189        assert_eq!(route("/quit"), RouteResult::SlashCommand(SlashCommand::Quit));
190        assert_eq!(route("/exit"), RouteResult::SlashCommand(SlashCommand::Quit));
191    }
192
193    #[test]
194    fn unknown_command_becomes_shell() {
195        assert_eq!(
196            route("/git status"),
197            RouteResult::SlashCommand(SlashCommand::Shell { command: "git status".to_string() })
198        );
199    }
200
201    #[test]
202    fn shell_command_preserves_full_text() {
203        assert_eq!(
204            route("/ls -la /tmp"),
205            RouteResult::SlashCommand(SlashCommand::Shell { command: "ls -la /tmp".to_string() })
206        );
207    }
208}