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    /// `/quit` or `/exit` — signal the channel to close.
50    Quit,
51    /// Fallback: treat unrecognized `/cmd` as a shell command.
52    Shell { command: String },
53}
54
55/// Route raw user input into either a slash command or natural language.
56///
57/// Only inputs starting with `/` are treated as commands. Everything else
58/// goes to the LLM pipeline as natural language.
59#[must_use]
60pub fn route(input: &str) -> RouteResult {
61    let trimmed = input.trim();
62
63    if !trimmed.starts_with('/') {
64        return RouteResult::NaturalLanguage(trimmed.to_string());
65    }
66
67    // Strip the leading `/`
68    let rest = &trimmed[1..];
69
70    // Split into command word and arguments
71    let (cmd, args) = rest
72        .split_once(|c: char| c.is_ascii_whitespace())
73        .map_or((rest, ""), |(c, a)| (c, a.trim()));
74
75    match cmd {
76        "help" | "h" => RouteResult::SlashCommand(SlashCommand::Help),
77        "tools" => RouteResult::SlashCommand(SlashCommand::Tools),
78        "tool.describe" => {
79            RouteResult::SlashCommand(SlashCommand::ToolDescribe { name: args.to_string() })
80        }
81        "tape.search" => {
82            RouteResult::SlashCommand(SlashCommand::TapeSearch { query: args.to_string() })
83        }
84        "tape.info" => RouteResult::SlashCommand(SlashCommand::TapeInfo),
85        "anchors" => RouteResult::SlashCommand(SlashCommand::Anchors),
86        "handoff" => RouteResult::SlashCommand(SlashCommand::Handoff {
87            name: if args.is_empty() { None } else { Some(args.to_string()) },
88        }),
89        "quit" | "exit" => RouteResult::SlashCommand(SlashCommand::Quit),
90        _ => {
91            // Unrecognized command → treat as shell command
92            RouteResult::SlashCommand(SlashCommand::Shell { command: rest.to_string() })
93        }
94    }
95}
96
97/// Render the help text for all available slash commands.
98#[must_use]
99pub fn help_text() -> String {
100    "\
101Available commands:
102  /help                 Show this help message
103  /tools                List all registered tools
104  /tool.describe NAME   Show full schema for a tool
105  /tape.search QUERY    Search conversation history
106  /tape.info            Show tape statistics
107  /anchors              List all tape anchors
108  /handoff [NAME]       Reset context window (create handoff point)
109  /quit                 Exit the session
110
111Natural language input (without /) goes to the AI model."
112        .to_string()
113}
114
115// ── Tests ────────────────────────────────────────────────────────────
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn natural_language_passthrough() {
123        let result = route("hello world");
124        assert_eq!(result, RouteResult::NaturalLanguage("hello world".to_string()));
125    }
126
127    #[test]
128    fn natural_language_trims_whitespace() {
129        let result = route("  hello  ");
130        assert_eq!(result, RouteResult::NaturalLanguage("hello".to_string()));
131    }
132
133    #[test]
134    fn help_command() {
135        assert_eq!(route("/help"), RouteResult::SlashCommand(SlashCommand::Help));
136        assert_eq!(route("/h"), RouteResult::SlashCommand(SlashCommand::Help));
137    }
138
139    #[test]
140    fn tools_command() {
141        assert_eq!(route("/tools"), RouteResult::SlashCommand(SlashCommand::Tools));
142    }
143
144    #[test]
145    fn tool_describe_command() {
146        assert_eq!(
147            route("/tool.describe file.read"),
148            RouteResult::SlashCommand(SlashCommand::ToolDescribe { name: "file.read".to_string() })
149        );
150    }
151
152    #[test]
153    fn tape_search_command() {
154        assert_eq!(
155            route("/tape.search error handling"),
156            RouteResult::SlashCommand(SlashCommand::TapeSearch {
157                query: "error handling".to_string()
158            })
159        );
160    }
161
162    #[test]
163    fn handoff_with_name() {
164        assert_eq!(
165            route("/handoff phase-2"),
166            RouteResult::SlashCommand(SlashCommand::Handoff { name: Some("phase-2".to_string()) })
167        );
168    }
169
170    #[test]
171    fn handoff_without_name() {
172        assert_eq!(
173            route("/handoff"),
174            RouteResult::SlashCommand(SlashCommand::Handoff { name: None })
175        );
176    }
177
178    #[test]
179    fn quit_and_exit_commands() {
180        assert_eq!(route("/quit"), RouteResult::SlashCommand(SlashCommand::Quit));
181        assert_eq!(route("/exit"), RouteResult::SlashCommand(SlashCommand::Quit));
182    }
183
184    #[test]
185    fn unknown_command_becomes_shell() {
186        assert_eq!(
187            route("/git status"),
188            RouteResult::SlashCommand(SlashCommand::Shell { command: "git status".to_string() })
189        );
190    }
191
192    #[test]
193    fn shell_command_preserves_full_text() {
194        assert_eq!(
195            route("/ls -la /tmp"),
196            RouteResult::SlashCommand(SlashCommand::Shell { command: "ls -la /tmp".to_string() })
197        );
198    }
199}