diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index 649da70..2d36726 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -26,6 +26,7 @@ This crate holds only the *music* part of the agent. The ACP server, session/tur - `src/tools/`: `MusicTools` is one `Toolset` exposing six `theory_*` tools, `analyze_audio` and `list_separations`. All are read-only and never prompt for approval. New tools need an entry in `definitions()`, `handles()` and `describe()`. - `src/theory/` (pitch, interval, scale, chord, key, tempo) and `src/audio/` (symphonia decode, loudness/tempo/key analysis) are the pure computation behind the tools. The design principle is that the model calls these instead of recalling answers, so results must be exact (correct enharmonic spelling, e.g. C# major has E# and B#). - `src/lib.rs` vs `src/main.rs`: the crate is both a library (`Profile` for hosts that embed the agent over an in-memory channel with `ed_acp::serve`) and the `splitfire-agent` binary. The `cli` feature (default) pulls in `ed-acp-tui` and the MCP test stub; build with `--no-default-features` to check the embeddable library alone. +- `src/embed/` (behind the off-by-default `embed` feature) is the SplitFire-only glue a Tauri host needs beside the published `ed-acp-tauri` crate: `embed::fetch_key` (SplitFire's `/agent/key`), `embed::splitfire_mcp` (the SplitFire API's MCP server), and `embed::trim` (drop `run_command` and swap the stems paragraph on platforms that can't start processes). It has no `tauri` dependency — the app identifier comes in as a `&str`. The `NO_STEMS_START`/`NO_STEMS_END` markers `embed::trim::replace_no_stems` keys off are defined in `src/prompt.rs`, which writes the paragraph, so the two can't drift. - The binary has three modes: TUI when stdin is a terminal; ACP over stdio with `--acp` or when stdin is not a terminal; plus `--setup` / `--list-models`. The TUI is itself an ACP client that spawns `splitfire-agent --acp` as a subprocess. ## Tests @@ -35,7 +36,7 @@ Integration tests run the real binary against a scripted mock model and a test M ## Conventions - Edition 2024. Per `.agents/skills/rust-coding-standard`: no `.unwrap()`/`.expect()` outside `#[cfg(test)]`, don't silently discard errors with `let _ =`. -- `registry/` holds the ACP registry submission (`agent.json`) (its `version` is currently 1.0.0, behind `Cargo.toml`'s 1.1.0, so check it when releasing). +- `registry/` holds the ACP registry submission (`agent.json`); keep its `version` in step with `Cargo.toml` when releasing (both at 1.2.0). ## Remembered notes diff --git a/Cargo.lock b/Cargo.lock index 8a360f8..5e13012 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -220,6 +220,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64" version = "0.23.1" @@ -635,18 +641,18 @@ dependencies = [ [[package]] name = "ed-acp" -version = "1.2.2" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed473861f41489b1d7489d6ac52d5901dac094e5667d614a41f3207639a7cfac" +checksum = "399009729e0200f68a63bc2a38123c2dd8b962e71abcf06793f660b93ed554f3" dependencies = [ "agent-client-protocol", "anyhow", "async-trait", - "base64", + "base64 0.23.1", "directories", "ed-mcp", "futures", - "reqwest", + "reqwest 0.13.5", "serde", "serde_json", "tokio", @@ -658,9 +664,9 @@ dependencies = [ [[package]] name = "ed-acp-testkit" -version = "1.2.2" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17dfc37dbdae642048e9d6359bddefb013ce88ba694a590934359e0bfd14f246" +checksum = "1705037211a9e22b02f62fda7ac6bd8bcc25e07a0ba526477c638ec6584eeb18" dependencies = [ "serde_json", "tokio", @@ -669,9 +675,9 @@ dependencies = [ [[package]] name = "ed-acp-tui" -version = "1.2.2" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bc0f4306887815c70238e61bc45bfcc46ec5986179e48e5176de28db30a5001" +checksum = "9667be1b834f4ca1d5ae55730e4b69376d9cd2949b9cc3deead3ea6130761b9f" dependencies = [ "agent-client-protocol", "anyhow", @@ -683,9 +689,9 @@ dependencies = [ [[package]] name = "ed-mcp" -version = "1.2.2" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ab72e819848e7a6f0f0b0f348d55ece76190f86ffc16dab5bc3d6bfe8e5c167" +checksum = "c91065ba526284ae3dce156a932343ba7409351ef93ea6a4181fdc3287a7a5bb" dependencies = [ "agent-client-protocol", "anyhow", @@ -717,7 +723,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1047,6 +1053,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -1058,7 +1065,7 @@ version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", @@ -1844,7 +1851,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2026,13 +2033,51 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "reqwest" version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "futures-core", "futures-util", @@ -2086,7 +2131,7 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "chrono", "futures", @@ -2094,7 +2139,7 @@ dependencies = [ "indexmap 2.14.2", "pin-project-lite", "process-wrap", - "reqwest", + "reqwest 0.13.5", "serde", "serde_json", "sse-stream", @@ -2144,7 +2189,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2155,6 +2200,7 @@ checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -2201,7 +2247,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2379,13 +2425,25 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" dependencies = [ - "base64", + "base64 0.23.1", "bs58", "chrono", "hex", @@ -2504,12 +2562,12 @@ dependencies = [ [[package]] name = "splitfire-agent" -version = "1.1.1" +version = "1.2.0" dependencies = [ "agent-client-protocol", "anyhow", "async-trait", - "base64", + "base64 0.23.1", "directories", "ebur128", "ed-acp", @@ -2518,6 +2576,7 @@ dependencies = [ "ed-mcp", "hound", "realfft", + "reqwest 0.12.28", "serde", "serde_json", "symphonia", @@ -3301,7 +3360,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1709617..8e581b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "splitfire-agent" -version = "1.1.1" +version = "1.2.0" edition = "2024" license = "Apache-2.0" description = "Music-domain ACP agent: theory, production, industry, culture and live shows, backed by any OpenAI-compatible chat completions API" @@ -11,6 +11,10 @@ default = ["cli"] # The `splitfire-agent` binary: its terminal UI, plus the MCP stub the integration tests run. # A host embedding the library turns this off. cli = ["dep:ed-acp-tui", "ed-mcp/test-server"] +# The `embed` module: the SplitFire-only pieces a Tauri host needs beside `ed-acp-tauri` +# (key fetch, the SplitFire MCP server, the iOS prompt/toolset trimming). Off by default so +# the plain library and the binary don't pull in `reqwest`. +embed = ["dep:reqwest"] [[bin]] name = "splitfire-agent" @@ -24,15 +28,20 @@ required-features = ["cli"] [dependencies] # Onde Agent Platform: the ACP server, terminal UI and MCP layer, from the onde-ed workspace. -ed-acp = { version = "1.2.2" } -ed-acp-tui = { version = "1.2.2", optional = true } -ed-mcp = { version = "1.2.2" } +ed-acp = { version = "1.3.0" } +ed-acp-tui = { version = "1.3.0", optional = true } +ed-mcp = { version = "1.3.0" } agent-client-protocol = "2.2" anyhow = "1.0.104" async-trait = "0.1" directories = "6.0.0" ebur128 = "0.1.10" realfft = "3.5.0" +# Only for `embed::fetch_key` and `embed::splitfire_mcp`, gated behind the `embed` feature. +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "rustls-tls-native-roots", +], optional = true } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" symphonia = { version = "0.6.1", features = ["mp3", "flac", "ogg", "vorbis", "wav", "pcm", "aac", "isomp4", "aiff", "alac", "mkv"] } @@ -41,6 +50,6 @@ tracing = "0.1.44" uuid = { version = "1.27.0", features = ["v4"] } [dev-dependencies] -ed-acp-testkit = { version = "1.2.2" } +ed-acp-testkit = { version = "1.3.0" } base64 = "0.23.1" hound = "3.5.1" diff --git a/README.md b/README.md index 6c156de..47bf199 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,34 @@ as `mcp____`, ask for permission unless the server marks them read progress into the tool call, and are cancelled when you cancel the turn. A server that fails to start is skipped. +## Embedding + +A sandboxed app (an App Store build) can't launch the agent as a subprocess, so it runs it +in-process: it serves [`SplitFire`] with `ed_acp::serve` over an in-memory channel and talks to +it as a regular ACP client. The app-neutral half of that — the client, sessions, permissions, +the key store — lives in the published [`ed-acp-tauri`](https://crates.io/crates/ed-acp-tauri) +crate. The SplitFire-only pieces live here, behind the off-by-default `embed` feature: + +```toml +splitfire-agent = { version = "1.2", default-features = false, features = ["embed"] } +ed-acp-tauri = "1.3" +ed-acp = "1.3" +``` + +`splitfire_agent::embed` has no `tauri` dependency; anything that needs the app's identifier +takes it as a `&str`: + +- `embed::fetch_key(url, access_token, app_identifier)` asks the SplitFire API for the signed-in + user's Onde key, rejecting with `SIGN_IN_REQUIRED` or `UNAVAILABLE`. +- `embed::splitfire_mcp(url, access_token, app_identifier)` builds the SplitFire API's MCP server + for a session (nothing without a token). +- `embed::trim` drops `run_command` from the toolsets (`without_commands`) and swaps the stems + paragraph (`replace_no_stems`) for a profile that runs where it can't start processes, e.g. + iOS. + +All embedding apps must resolve a single `ed-acp` 1.3.x, so an `Arc` from this crate +matches the one `ed-acp-tauri` expects. + ## Install and set up ```sh diff --git a/registry/agent.json b/registry/agent.json index 9acf890..6870ad8 100644 --- a/registry/agent.json +++ b/registry/agent.json @@ -1,7 +1,7 @@ { "id": "splitfire-agent", "name": "SplitFire AI", - "version": "1.1.1", + "version": "1.2.0", "description": "Music assistant for musicians, producers and engineers: exact music theory tools (scales, chords, keys, tempo maths), audio analysis (loudness, tempo and key estimates), optional stem separation, and project file tools.", "repository": "https://github.com/splitfireai/splitfire-agent", "authors": [ diff --git a/src/embed/mod.rs b/src/embed/mod.rs new file mode 100644 index 0000000..b2921c5 --- /dev/null +++ b/src/embed/mod.rs @@ -0,0 +1,241 @@ +//! The SplitFire-only pieces a Tauri host needs beside [`ed-acp-tauri`], which carries the +//! app-neutral half of embedding (the in-process ACP client, sessions, permissions, the key +//! store). These three stay here because they are specific to SplitFire's backend and apps: +//! +//! - [`fetch_key`]: ask the SplitFire API for the signed-in user's Onde key. +//! - [`splitfire_mcp`]: the SplitFire API's MCP server for a session. +//! - [`trim`]: drop `run_command` and swap the stems guidance on iOS. +//! +//! Nothing here depends on `tauri`. Anything that needed an `AppHandle` in the old +//! `splitfire_agent_host` crate takes the app identifier as a `&str` instead, so the host +//! passes `app.config().identifier`. +//! +//! [`ed-acp-tauri`]: https://crates.io/crates/ed-acp-tauri + +pub mod trim; + +use ed_acp::agent_client_protocol::schema::v1::{HttpHeader, McpServer, McpServerHttp}; +use serde::Deserialize; +use tracing::warn; + +/// What [`fetch_key`] rejects with when the SplitFire session is missing or +/// expired: the user signs in again. +pub const SIGN_IN_REQUIRED: &str = "sign_in_required"; + +/// What [`fetch_key`] rejects with when SplitFire can't issue a key now: the +/// service is off, or the account isn't allowed one. +pub const UNAVAILABLE: &str = "unavailable"; + +/// Name of the SplitFire API's MCP server in agent sessions. +pub const SPLITFIRE_MCP: &str = "splitfire"; + +/// The agent key SplitFire hands a signed-in user, and the model tier it goes with. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentKey { + /// The Onde key, `app-id:app-secret`. Never logged. + pub key: String, + /// The tier SplitFire chose for this app. Older servers don't send it. + pub model: Option, +} + +#[derive(Deserialize)] +struct KeyResponse { + key: String, + model: Option, +} + +/// Ask SplitFire for the signed-in user's Onde key and the model to use with it. `url` is the +/// app's `/api/v1/agent/key`; `app_identifier` goes out as sent, so a debug build is told +/// apart from the release app and SplitFire picks its model. The key is never logged. +pub async fn fetch_key( + url: &str, + access_token: &str, + app_identifier: &str, +) -> Result { + let response = reqwest::Client::new() + .post(url) + .bearer_auth(access_token) + .header("X-App-Identifier", app_identifier) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + .map_err(|e| { + warn!("Asking SplitFire for an agent key failed: {e}"); + "Couldn't reach SplitFire. Check your connection and try again.".to_string() + })?; + let status = response.status(); + match status.as_u16() { + 200 => { + let body = response + .bytes() + .await + .map_err(|e| format!("Reading SplitFire's answer failed: {e}"))?; + serde_json::from_slice::(&body) + .map(|parsed| AgentKey { + key: parsed.key, + model: parsed.model, + }) + .map_err(|_| "SplitFire answered with something unexpected.".to_string()) + } + 401 => Err(SIGN_IN_REQUIRED.to_string()), + 403 | 503 => Err(UNAVAILABLE.to_string()), + other => Err(format!( + "SplitFire answered {other}. Try again in a moment." + )), + } +} + +/// The SplitFire API's MCP server for a signed-in user, as an ACP HTTP MCP server. `url` is the +/// app's `/api/v1/mcp`. ed-acp connects to it per session; its tools reach the model as +/// `mcp__splitfire__*`. The server answers with the tool list for the app named in +/// `X-App-Identifier` (the app's own identifier, debug builds as sent). Nothing without a token. +pub fn splitfire_mcp( + url: String, + access_token: Option, + app_identifier: &str, +) -> Vec { + let Some(token) = access_token.filter(|t| !t.is_empty()) else { + return Vec::new(); + }; + vec![McpServer::Http( + McpServerHttp::new(SPLITFIRE_MCP, url).headers(vec![ + HttpHeader::new("Authorization", format!("Bearer {token}")), + HttpHeader::new("X-App-Identifier", app_identifier.to_string()), + ]), + )] +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Serve one canned HTTP answer on a free port. Returns the URL and a handle + /// that yields the request the server saw. + fn stub(status: &str, body: &str) -> (String, std::thread::JoinHandle) { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/api/v1/agent/key", listener.local_addr().unwrap()); + let answer = format!( + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut seen = vec![0u8; 4096]; + let n = stream.read(&mut seen).unwrap(); + stream.write_all(answer.as_bytes()).unwrap(); + String::from_utf8_lossy(&seen[..n]).to_string() + }); + (url, handle) + } + + fn ask(status: &str, body: &str) -> (Result, String) { + let (url, server) = stub(status, body); + let result = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(fetch_key( + &url, + "user-token", + "ai.splitfire.KaroKoweJam-Debug", + )); + (result, server.join().unwrap()) + } + + #[test] + fn the_key_comes_from_a_post_with_the_users_token() { + let (result, request) = ask("200 OK", r#"{"key":"101:secret","model":"onde-balanced"}"#); + assert_eq!( + result, + Ok(AgentKey { + key: "101:secret".to_string(), + model: Some("onde-balanced".to_string()), + }) + ); + assert!(request.starts_with("POST /api/v1/agent/key"), "{request}"); + assert!( + request + .to_lowercase() + .contains("authorization: bearer user-token"), + "{request}" + ); + // The app id goes out unchanged, so a debug build is told apart. + assert!( + request + .to_lowercase() + .contains("x-app-identifier: ai.splitfire.karokowejam-debug"), + "{request}" + ); + } + + #[test] + fn an_older_server_sends_no_model() { + let (result, _) = ask("200 OK", r#"{"key":"101:secret"}"#); + assert_eq!( + result, + Ok(AgentKey { + key: "101:secret".to_string(), + model: None, + }) + ); + } + + #[test] + fn a_missing_session_and_an_unavailable_service_have_their_own_answers() { + assert_eq!( + ask("401 Unauthorized", "{}").0, + Err(SIGN_IN_REQUIRED.to_string()) + ); + assert_eq!(ask("403 Forbidden", "{}").0, Err(UNAVAILABLE.to_string())); + assert_eq!( + ask("503 Service Unavailable", "{}").0, + Err(UNAVAILABLE.to_string()) + ); + } + + #[test] + fn other_failures_say_so_without_leaking_the_body() { + let error = ask("502 Bad Gateway", r#"{"error":"secret-detail"}"#) + .0 + .unwrap_err(); + assert!(error.contains("502"), "{error}"); + assert!(!error.contains("secret-detail"), "{error}"); + let error = ask("200 OK", "not json").0.unwrap_err(); + assert!(error.contains("unexpected"), "{error}"); + } + + #[test] + fn mcp_needs_a_non_empty_token() { + assert!(splitfire_mcp("https://api/x".to_string(), None, "app").is_empty()); + assert!(splitfire_mcp("https://api/x".to_string(), Some(String::new()), "app").is_empty()); + } + + #[test] + fn mcp_carries_the_token_and_app_identifier() { + let servers = splitfire_mcp( + "https://api.splitfire.ai/api/v1/mcp".to_string(), + Some("user-token".to_string()), + "ai.splitfire.KaroKoweJam", + ); + let McpServer::Http(http) = servers.into_iter().next().expect("one server") else { + panic!("expected an HTTP MCP server"); + }; + assert_eq!(http.name, SPLITFIRE_MCP); + assert_eq!(http.url, "https://api.splitfire.ai/api/v1/mcp"); + let header = |name: &str| { + http.headers + .iter() + .find(|h| h.name == name) + .map(|h| h.value.clone()) + }; + assert_eq!( + header("Authorization"), + Some("Bearer user-token".to_string()) + ); + assert_eq!( + header("X-App-Identifier"), + Some("ai.splitfire.KaroKoweJam".to_string()) + ); + } +} diff --git a/src/embed/trim.rs b/src/embed/trim.rs new file mode 100644 index 0000000..db9c2c6 --- /dev/null +++ b/src/embed/trim.rs @@ -0,0 +1,109 @@ +//! What a host takes out of the agent on a platform that can't start processes, +//! and the stems paragraph an app swaps in when it has its own answer. +//! +//! iOS apps can't start other programs, so `run_command` would only ever fail +//! there and the stock "install demucs" advice is no use. Each app's iOS +//! profile wraps its desktop one with these. + +use std::sync::Arc; + +use async_trait::async_trait; +use ed_acp::agent_client_protocol::schema::v1::{ToolCallLocation, ToolKind}; +use ed_acp::{DescribeCtx, ToolCtx, ToolOutcome, Toolset}; +use serde_json::Value; + +// The markers the "no stems" paragraph starts and ends with live in `prompt.rs`, which writes +// the paragraph, so the prompt and the replacement below can't drift apart. +pub use crate::prompt::{NO_STEMS_END, NO_STEMS_START}; + +/// The workspace tool that starts a process. +pub const RUN_COMMAND: &str = "run_command"; + +/// Swap the stock "no stems" paragraph for `note`; if the stock prompt is +/// worded differently, append the note rather than lose it. +pub fn replace_no_stems(prompt: String, note: &str) -> String { + if let Some(start) = prompt.find(NO_STEMS_START) + && let Some(rel) = prompt[start..].find(NO_STEMS_END) + { + let end = start + rel + NO_STEMS_END.len(); + return format!("{}{}{}", &prompt[..start], note, &prompt[end..]); + } + format!("{prompt}\n\n{note}") +} + +/// The name of a tool definition, as the model is offered it. +pub fn tool_name(definition: &Value) -> Option<&str> { + definition.pointer("/function/name").and_then(Value::as_str) +} + +/// `toolsets` with `run_command` taken out of what the model is offered. +pub fn without_commands(toolsets: Vec>) -> Vec> { + toolsets + .into_iter() + .map(|inner| Arc::new(NoCommands(inner)) as Arc) + .collect() +} + +struct NoCommands(Arc); + +#[async_trait] +impl Toolset for NoCommands { + fn definitions(&self) -> Vec { + self.0 + .definitions() + .into_iter() + .filter(|d| tool_name(d) != Some(RUN_COMMAND)) + .collect() + } + + fn handles(&self, name: &str) -> bool { + name != RUN_COMMAND && self.0.handles(name) + } + + fn describe( + &self, + name: &str, + args: &Value, + ctx: &DescribeCtx<'_>, + ) -> (String, ToolKind, Vec) { + self.0.describe(name, args, ctx) + } + + async fn execute( + &self, + ctx: &ToolCtx, + tool_call_id: &str, + name: &str, + args: Value, + ) -> anyhow::Result { + self.0.execute(ctx, tool_call_id, name, args).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn replaces_the_stock_paragraph() { + let stock = + format!("before\n{NO_STEMS_START} tell them to install demucs, {NO_STEMS_END}\nafter"); + let out = replace_no_stems(stock, "NOTE"); + assert_eq!(out, "before\nNOTE\nafter"); + } + + #[test] + fn appends_when_the_wording_changed() { + let out = replace_no_stems("something else".to_string(), "NOTE"); + assert_eq!(out, "something else\n\nNOTE"); + } + + #[test] + fn the_markers_bound_the_real_prompt_paragraph() { + // The prompt the agent ships must still contain the paragraph these markers point at, + // or a host's `replace_no_stems` would silently append instead of replacing. + let prompt = crate::prompt::system_prompt("acp", std::path::Path::new("/tmp"), &[], false); + let start = prompt.find(NO_STEMS_START).expect("start marker present"); + assert!(prompt[start..].contains(NO_STEMS_END), "end marker present"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 724f558..ed43757 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,8 @@ //! a subprocess. Build with `default-features = false` to leave out the terminal UI. mod audio; +#[cfg(feature = "embed")] +pub mod embed; pub mod profile; mod prompt; mod theory; diff --git a/src/prompt.rs b/src/prompt.rs index bd6db5d..1abf0bd 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -6,6 +6,13 @@ use agent_client_protocol::schema::v1::{ AvailableCommand, AvailableCommandInput, UnstructuredCommandInput, }; +/// The sentence the "no stems" paragraph starts and ends with. A host that embeds the agent +/// swaps this whole paragraph for its own stems guidance by finding these two markers +/// ([`crate::embed::trim::replace_no_stems`]), so the prompt below and the replacement must +/// stay in step — they share these constants to make that impossible to get wrong. +pub const NO_STEMS_START: &str = "Stem separation is not available in this session."; +pub const NO_STEMS_END: &str = "file or shell tools."; + pub fn system_prompt( surface: &str, cwd: &Path, @@ -33,11 +40,16 @@ pub fn system_prompt( vocals, other; guitar and piano with the 6-stem model). It runs locally and can take \ minutes. When it finishes, tell the user where the stem files are. Separate only music \ the user says they have the right to work with." + .to_string() } else { - "\nStem separation is not available in this session. If the user asks for stems, tell \ - them it needs the `demucs` command installed and on PATH (or SPLITFIRE_DEMUCS_BIN \ - pointing at it), and that the session must be restarted afterwards. Do not pretend to \ - separate anything, and do not offer a workaround with the file or shell tools." + // Bounded by NO_STEMS_START / NO_STEMS_END so an embedding host can find and replace + // the whole paragraph; keep those markers as the first and last words of it. + format!( + "\n{NO_STEMS_START} If the user asks for stems, tell \ + them it needs the `demucs` command installed and on PATH (or SPLITFIRE_DEMUCS_BIN \ + pointing at it), and that the session must be restarted afterwards. Do not pretend \ + to separate anything, and do not offer a workaround with the {NO_STEMS_END}" + ) }; format!( "You are SplitFire, a music assistant for musicians, producers, engineers, songwriters \