| 1 | //! SplitFire's system prompt and slash commands. Converting prompt content is `ed-acp`'s job. |
| 2 | |
| 3 | use std::path::{Path, PathBuf}; |
| 4 | |
| 5 | use agent_client_protocol::schema::v1::{ |
| 6 | AvailableCommand, AvailableCommandInput, UnstructuredCommandInput, |
| 7 | }; |
| 8 | |
| 9 | /// The sentence the "no stems" paragraph starts and ends with. A host that embeds the agent |
| 10 | /// swaps this whole paragraph for its own stems guidance by finding these two markers |
| 11 | /// ([`crate::embed::trim::replace_no_stems`]), so the prompt below and the replacement must |
| 12 | /// stay in step — they share these constants to make that impossible to get wrong. |
| 13 | pub const NO_STEMS_START: &str = "Stem separation is not available in this session."; |
| 14 | pub const NO_STEMS_END: &str = "file or shell tools."; |
| 15 | |
| 16 | pub fn system_prompt( |
| 17 | surface: &str, |
| 18 | cwd: &Path, |
| 19 | roots: &[PathBuf], |
| 20 | stems_available: bool, |
| 21 | ) -> String { |
| 22 | let mut extra: Vec<&PathBuf> = roots.iter().filter(|r| *r != cwd).collect(); |
| 23 | extra.sort(); |
| 24 | extra.dedup(); |
| 25 | let roots_section = if extra.is_empty() { |
| 26 | String::new() |
| 27 | } else { |
| 28 | let list = extra |
| 29 | .iter() |
| 30 | .map(|r| format!("- {}", r.display())) |
| 31 | .collect::<Vec<_>>() |
| 32 | .join("\n"); |
| 33 | format!( |
| 34 | "\nAdditional workspace roots:\n{list}\n\ |
| 35 | Pass a root's absolute path as the `root` parameter of a file tool to work there." |
| 36 | ) |
| 37 | }; |
| 38 | let stems_section = if stems_available { |
| 39 | "\nStem separation: `mcp__demucs__separate_stems` splits a track into stems (drums, bass, \ |
| 40 | vocals, other; guitar and piano with the 6-stem model). It runs locally and can take \ |
| 41 | minutes. When it finishes, tell the user where the stem files are. Separate only music \ |
| 42 | the user says they have the right to work with." |
| 43 | .to_string() |
| 44 | } else { |
| 45 | // Bounded by NO_STEMS_START / NO_STEMS_END so an embedding host can find and replace |
| 46 | // the whole paragraph; keep those markers as the first and last words of it. |
| 47 | format!( |
| 48 | "\n{NO_STEMS_START} If the user asks for stems, tell \ |
| 49 | them it needs the `demucs` command installed and on PATH (or SPLITFIRE_DEMUCS_BIN \ |
| 50 | pointing at it), and that the session must be restarted afterwards. Do not pretend \ |
| 51 | to separate anything, and do not offer a workaround with the {NO_STEMS_END}" |
| 52 | ) |
| 53 | }; |
| 54 | format!( |
| 55 | "You are SplitFire, a music assistant for musicians, producers, engineers, songwriters \ |
| 56 | and fans. You cover music theory, production and DAW workflow, the music industry, \ |
| 57 | music culture and history, and live shows.\n\ |
| 58 | \n\ |
| 59 | Working rules:\n\ |
| 60 | - Use the theory tools (theory_scale, theory_chord, theory_transpose, \ |
| 61 | theory_progression, theory_tempo, theory_pitch) for note spelling, chords, keys, \ |
| 62 | transposition, delay and tempo maths and pitch conversion. Do not compute these from \ |
| 63 | memory; quote the tool's result.\n\ |
| 64 | - Use analyze_audio for audio files. Its tempo and key are signal-analysis estimates: \ |
| 65 | call them estimates, mention the confidence, and mention half/double-time readings \ |
| 66 | when they matter.\n\ |
| 67 | - Use list_separations to see what the SplitFire app has already separated, instead of \ |
| 68 | guessing at stem paths. It is read-only: never edit or delete anything inside those \ |
| 69 | song folders, because the app rebuilds its history from that tree.\n\ |
| 70 | - Be specific and practical: give settings, ranges and reasoning, and say when \ |
| 71 | something is taste rather than rule. Match the user's level.\n\ |
| 72 | - Do not reproduce song lyrics beyond a short quotation; discuss, summarise or \ |
| 73 | analyse instead.\n\ |
| 74 | - On sampling, covers, licensing, splits and royalties explain how things generally \ |
| 75 | work and what to ask a lawyer or collecting society; this is not legal advice.\n\ |
| 76 | - Do not invent release dates, chart positions, credits, gear specs or tour dates. \ |
| 77 | Say so when you are unsure or when facts may have changed.\n\ |
| 78 | - Keep answers concise and use Markdown.\n\ |
| 79 | {stems_section}\n\ |
| 80 | \n\ |
| 81 | Working directory: {cwd}{roots_section}\n\ |
| 82 | File tools are available for the user's project notes, chord charts and lyric sheets. \ |
| 83 | Read before editing and prefer edit_file for small changes.\n\ |
| 84 | When creating git commits, include the trailer:\n\ |
| 85 | Co-Authored-By: SplitFire v{version}-{surface} <noreply@splitfire.ai>", |
| 86 | cwd = cwd.display(), |
| 87 | version = env!("CARGO_PKG_VERSION"), |
| 88 | ) |
| 89 | } |
| 90 | |
| 91 | struct SlashCommand { |
| 92 | name: &'static str, |
| 93 | description: &'static str, |
| 94 | hint: &'static str, |
| 95 | /// Needs the stem separator to be connected. |
| 96 | needs_stems: bool, |
| 97 | } |
| 98 | |
| 99 | const COMMANDS: &[SlashCommand] = &[ |
| 100 | SlashCommand { |
| 101 | name: "analyze", |
| 102 | description: "Analyze an audio file: loudness, tempo and key estimates", |
| 103 | hint: "path to an audio file", |
| 104 | needs_stems: false, |
| 105 | }, |
| 106 | SlashCommand { |
| 107 | name: "chords", |
| 108 | description: "Analyze a chord progression: key, Roman numerals, borrowed chords", |
| 109 | hint: "chords, e.g. Dm7 G7 Cmaj7", |
| 110 | needs_stems: false, |
| 111 | }, |
| 112 | SlashCommand { |
| 113 | name: "library", |
| 114 | description: "List the songs the SplitFire app has already separated", |
| 115 | hint: "optional song name to filter by", |
| 116 | needs_stems: false, |
| 117 | }, |
| 118 | SlashCommand { |
| 119 | name: "practice", |
| 120 | description: "Build a focused practice plan with tempo targets", |
| 121 | hint: "instrument and goal", |
| 122 | needs_stems: false, |
| 123 | }, |
| 124 | SlashCommand { |
| 125 | name: "stems", |
| 126 | description: "Separate a track into drums, bass, vocals and other stems", |
| 127 | hint: "path to an audio file", |
| 128 | needs_stems: true, |
| 129 | }, |
| 130 | ]; |
| 131 | |
| 132 | /// Commands to advertise; `/stems` only when the separator is connected. |
| 133 | pub fn slash_commands(stems_available: bool) -> Vec<AvailableCommand> { |
| 134 | COMMANDS |
| 135 | .iter() |
| 136 | .filter(|c| stems_available || !c.needs_stems) |
| 137 | .map(|c| { |
| 138 | AvailableCommand::new(c.name, c.description).input(AvailableCommandInput::Unstructured( |
| 139 | UnstructuredCommandInput::new(c.hint), |
| 140 | )) |
| 141 | }) |
| 142 | .collect() |
| 143 | } |
| 144 | |
| 145 | /// If `text` starts with a known slash command, the instruction the model should follow. |
| 146 | pub fn expand_slash(text: &str, stems_available: bool) -> Option<String> { |
| 147 | let rest = text.trim_start().strip_prefix('/')?; |
| 148 | let (name, arg) = rest |
| 149 | .split_once(char::is_whitespace) |
| 150 | .map_or((rest, ""), |(n, a)| (n, a.trim())); |
| 151 | let missing = |what: &str| format!("The user ran /{name} without {what}. Ask them for it."); |
| 152 | Some(match name { |
| 153 | "analyze" if arg.is_empty() => missing("a file path"), |
| 154 | "analyze" => format!( |
| 155 | "Analyze the audio file {arg} with analyze_audio, then summarise it: duration, \ |
| 156 | loudness and headroom, tempo and key (labelled as estimates, with confidence), and \ |
| 157 | anything worth knowing before mixing or mastering." |
| 158 | ), |
| 159 | "chords" if arg.is_empty() => missing("a progression"), |
| 160 | "chords" => format!( |
| 161 | "Analyze this progression with theory_progression: {arg}\n\ |
| 162 | Give the key, Roman numerals and functions, point out borrowed chords and \ |
| 163 | secondary dominants, and suggest a few substitutions or voicing ideas." |
| 164 | ), |
| 165 | "library" => { |
| 166 | let scope = if arg.is_empty() { |
| 167 | "Call list_separations".to_string() |
| 168 | } else { |
| 169 | format!("Call list_separations with song={arg}") |
| 170 | }; |
| 171 | format!( |
| 172 | "{scope} and summarise the user's separation library: which songs, the model \ |
| 173 | each was separated with, when, and which stems are on this device. Mention any \ |
| 174 | stem that is listed but not downloaded. If it is empty, say how to separate a \ |
| 175 | track rather than guessing at paths." |
| 176 | ) |
| 177 | } |
| 178 | "practice" if arg.is_empty() => missing("an instrument and goal"), |
| 179 | "practice" => format!( |
| 180 | "Build a focused practice plan for: {arg}\n\ |
| 181 | Include a warm-up, two or three targeted exercises, and tempo targets worked out \ |
| 182 | with theory_tempo. Keep it to one session the user can finish in under an hour." |
| 183 | ), |
| 184 | "stems" if !stems_available => { |
| 185 | "The user ran /stems, but stem separation is not available in this session. Tell \ |
| 186 | them it needs the `demucs` command installed (see the SplitFire README) and to \ |
| 187 | restart the session afterwards." |
| 188 | .to_string() |
| 189 | } |
| 190 | "stems" if arg.is_empty() => missing("a file path"), |
| 191 | "stems" => format!( |
| 192 | "Separate {arg} into stems with mcp__demucs__separate_stems, then list the stem \ |
| 193 | files that were written." |
| 194 | ), |
| 195 | _ => return None, |
| 196 | }) |
| 197 | } |
| 198 | |
| 199 | #[cfg(test)] |
| 200 | mod tests { |
| 201 | use super::*; |
| 202 | |
| 203 | #[test] |
| 204 | fn system_prompt_mentions_stems_only_when_connected() { |
| 205 | let cwd = Path::new("/work"); |
| 206 | let off = system_prompt("acp", cwd, &[], false); |
| 207 | let on = system_prompt("acp", cwd, &[], true); |
| 208 | // Without the separator the prompt must not offer the tool, but must say how to |
| 209 | // enable it rather than going silent. |
| 210 | assert!(!off.contains("mcp__demucs__separate_stems")); |
| 211 | assert!(off.contains("not available") && off.contains("SPLITFIRE_DEMUCS_BIN")); |
| 212 | assert!(on.contains("mcp__demucs__separate_stems")); |
| 213 | // The read-only library tool is offered either way. |
| 214 | assert!(off.contains("list_separations") && on.contains("list_separations")); |
| 215 | assert!(off.contains("theory_scale") && off.contains("analyze_audio")); |
| 216 | assert!( |
| 217 | off.contains("Co-Authored-By: SplitFire v") |
| 218 | && off.contains("-acp <noreply@splitfire.ai>") |
| 219 | ); |
| 220 | let tui = system_prompt("tui", cwd, &[PathBuf::from("/a")], false); |
| 221 | assert!(tui.contains("-tui <") && tui.contains("- /a")); |
| 222 | } |
| 223 | |
| 224 | #[test] |
| 225 | fn commands_depend_on_stems() { |
| 226 | let names = |s| { |
| 227 | slash_commands(s) |
| 228 | .into_iter() |
| 229 | .map(|c| c.name) |
| 230 | .collect::<Vec<_>>() |
| 231 | }; |
| 232 | // `/library` only reads the app's sidecars, so it is offered either way. |
| 233 | assert_eq!(names(false), ["analyze", "chords", "library", "practice"]); |
| 234 | assert_eq!( |
| 235 | names(true), |
| 236 | ["analyze", "chords", "library", "practice", "stems"] |
| 237 | ); |
| 238 | } |
| 239 | |
| 240 | #[test] |
| 241 | fn slash_expansion() { |
| 242 | assert!( |
| 243 | expand_slash("/analyze /a/b.wav", false) |
| 244 | .unwrap() |
| 245 | .contains("/a/b.wav") |
| 246 | ); |
| 247 | assert!( |
| 248 | expand_slash("/chords Dm7 G7", false) |
| 249 | .unwrap() |
| 250 | .contains("theory_progression") |
| 251 | ); |
| 252 | assert!( |
| 253 | expand_slash("/analyze", false) |
| 254 | .unwrap() |
| 255 | .contains("without a file path") |
| 256 | ); |
| 257 | assert!( |
| 258 | expand_slash("/stems x.wav", true) |
| 259 | .unwrap() |
| 260 | .contains("mcp__demucs__separate_stems") |
| 261 | ); |
| 262 | assert!( |
| 263 | expand_slash("/stems x.wav", false) |
| 264 | .unwrap() |
| 265 | .contains("not available") |
| 266 | ); |
| 267 | assert!(expand_slash("/unknown x", false).is_none()); |
| 268 | assert!(expand_slash("hello /analyze", false).is_none()); |
| 269 | } |
| 270 | } |