@splitfireai / agent / commits / 77691fa

Add embed module for Tauri hosts, release 1.2.0

ed-acp-tauri now carries the app-neutral half of embedding the agent in a Tauri app. The SplitFire-only pieces it leaves out move here, behind a new off-by-default `embed` feature so the plain library and the binary don't pull in reqwest: - embed::fetch_key, the SplitFire /agent/key call, with SIGN_IN_REQUIRED and UNAVAILABLE - embed::splitfire_mcp, the SplitFire API's MCP server for a session - embed::trim, which drops run_command and swaps the stems paragraph on platforms that can't start processes These came from the splitfire_agent_host crate in the splitfire monorepo, which this retires. The module has no tauri dependency; the app identifier comes in as a &str. The NO_STEMS_START/NO_STEMS_END markers trim keys off now live in prompt.rs, which writes the paragraph, so the two can't drift. Bump ed-acp, ed-mcp, ed-acp-tui and ed-acp-testkit to 1.3.0 (the SplitFire profile builds unchanged against it), and the crate plus registry agent.json to 1.2.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Seto committed Oct 13, 2026 at 12:05 UTC 77691fad7be389e9c0392955ea5235601410ee84
9 files changed +494 -33
.agents/AGENTS.md
+2 -1
@@ -26,6 +26,7 @@ This crate holds only the *music* part of the agent. The ACP server, session/tur
26 - `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()`.
27 - `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#).
28 - `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.
29 +- `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.
30 - 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.
31
32 ## Tests
@@ -35,7 +36,7 @@ Integration tests run the real binary against a scripted mock model and a test M
36 ## Conventions
37
38 - Edition 2024. Per `.agents/skills/rust-coding-standard`: no `.unwrap()`/`.expect()` outside `#[cfg(test)]`, don't silently discard errors with `let _ =`.
38 -- `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).
39 +- `registry/` holds the ACP registry submission (`agent.json`); keep its `version` in step with `Cargo.toml` when releasing (both at 1.2.0).
40
41 ## Remembered notes
42
Cargo.lock
+81 -22
@@ -220,6 +220,12 @@ dependencies = [
220 "pkg-config",
221 ]
222
223 +[[package]]
224 +name = "base64"
225 +version = "0.22.1"
226 +source = "registry+https://github.com/rust-lang/crates.io-index"
227 +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
228 +
229 [[package]]
230 name = "base64"
231 version = "0.23.1"
@@ -635,18 +641,18 @@ dependencies = [
641
642 [[package]]
643 name = "ed-acp"
638 -version = "1.2.2"
644 +version = "1.3.0"
645 source = "registry+https://github.com/rust-lang/crates.io-index"
640 -checksum = "ed473861f41489b1d7489d6ac52d5901dac094e5667d614a41f3207639a7cfac"
646 +checksum = "399009729e0200f68a63bc2a38123c2dd8b962e71abcf06793f660b93ed554f3"
647 dependencies = [
648 "agent-client-protocol",
649 "anyhow",
650 "async-trait",
645 - "base64",
651 + "base64 0.23.1",
652 "directories",
653 "ed-mcp",
654 "futures",
649 - "reqwest",
655 + "reqwest 0.13.5",
656 "serde",
657 "serde_json",
658 "tokio",
@@ -658,9 +664,9 @@ dependencies = [
664
665 [[package]]
666 name = "ed-acp-testkit"
661 -version = "1.2.2"
667 +version = "1.3.0"
668 source = "registry+https://github.com/rust-lang/crates.io-index"
663 -checksum = "17dfc37dbdae642048e9d6359bddefb013ce88ba694a590934359e0bfd14f246"
669 +checksum = "1705037211a9e22b02f62fda7ac6bd8bcc25e07a0ba526477c638ec6584eeb18"
670 dependencies = [
671 "serde_json",
672 "tokio",
@@ -669,9 +675,9 @@ dependencies = [
675
676 [[package]]
677 name = "ed-acp-tui"
672 -version = "1.2.2"
678 +version = "1.3.0"
679 source = "registry+https://github.com/rust-lang/crates.io-index"
674 -checksum = "0bc0f4306887815c70238e61bc45bfcc46ec5986179e48e5176de28db30a5001"
680 +checksum = "9667be1b834f4ca1d5ae55730e4b69376d9cd2949b9cc3deead3ea6130761b9f"
681 dependencies = [
682 "agent-client-protocol",
683 "anyhow",
@@ -683,9 +689,9 @@ dependencies = [
689
690 [[package]]
691 name = "ed-mcp"
686 -version = "1.2.2"
692 +version = "1.3.0"
693 source = "registry+https://github.com/rust-lang/crates.io-index"
688 -checksum = "3ab72e819848e7a6f0f0b0f348d55ece76190f86ffc16dab5bc3d6bfe8e5c167"
694 +checksum = "c91065ba526284ae3dce156a932343ba7409351ef93ea6a4181fdc3287a7a5bb"
695 dependencies = [
696 "agent-client-protocol",
697 "anyhow",
@@ -717,7 +723,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
723 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
724 dependencies = [
725 "libc",
720 - "windows-sys 0.52.0",
726 + "windows-sys 0.61.2",
727 ]
728
729 [[package]]
@@ -1047,6 +1053,7 @@ dependencies = [
1053 "hyper",
1054 "hyper-util",
1055 "rustls",
1056 + "rustls-native-certs",
1057 "tokio",
1058 "tokio-rustls",
1059 "tower-service",
@@ -1058,7 +1065,7 @@ version = "0.1.21"
1065 source = "registry+https://github.com/rust-lang/crates.io-index"
1066 checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
1067 dependencies = [
1061 - "base64",
1068 + "base64 0.23.1",
1069 "bytes",
1070 "futures-channel",
1071 "futures-util",
@@ -1844,7 +1851,7 @@ dependencies = [
1851 "once_cell",
1852 "socket2",
1853 "tracing",
1847 - "windows-sys 0.52.0",
1854 + "windows-sys 0.61.2",
1855 ]
1856
1857 [[package]]
@@ -2026,13 +2033,51 @@ version = "0.8.11"
2033 source = "registry+https://github.com/rust-lang/crates.io-index"
2034 checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
2035
2036 +[[package]]
2037 +name = "reqwest"
2038 +version = "0.12.28"
2039 +source = "registry+https://github.com/rust-lang/crates.io-index"
2040 +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
2041 +dependencies = [
2042 + "base64 0.22.1",
2043 + "bytes",
2044 + "futures-core",
2045 + "http",
2046 + "http-body",
2047 + "http-body-util",
2048 + "hyper",
2049 + "hyper-rustls",
2050 + "hyper-util",
2051 + "js-sys",
2052 + "log",
2053 + "percent-encoding",
2054 + "pin-project-lite",
2055 + "quinn",
2056 + "rustls",
2057 + "rustls-native-certs",
2058 + "rustls-pki-types",
2059 + "serde",
2060 + "serde_json",
2061 + "serde_urlencoded",
2062 + "sync_wrapper",
2063 + "tokio",
2064 + "tokio-rustls",
2065 + "tower",
2066 + "tower-http",
2067 + "tower-service",
2068 + "url",
2069 + "wasm-bindgen",
2070 + "wasm-bindgen-futures",
2071 + "web-sys",
2072 +]
2073 +
2074 [[package]]
2075 name = "reqwest"
2076 version = "0.13.5"
2077 source = "registry+https://github.com/rust-lang/crates.io-index"
2078 checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
2079 dependencies = [
2035 - "base64",
2080 + "base64 0.23.1",
2081 "bytes",
2082 "futures-core",
2083 "futures-util",
@@ -2086,7 +2131,7 @@ version = "3.5.1"
2131 source = "registry+https://github.com/rust-lang/crates.io-index"
2132 checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350"
2133 dependencies = [
2089 - "base64",
2134 + "base64 0.23.1",
2135 "bytes",
2136 "chrono",
2137 "futures",
@@ -2094,7 +2139,7 @@ dependencies = [
2139 "indexmap 2.14.2",
2140 "pin-project-lite",
2141 "process-wrap",
2097 - "reqwest",
2142 + "reqwest 0.13.5",
2143 "serde",
2144 "serde_json",
2145 "sse-stream",
@@ -2144,7 +2189,7 @@ dependencies = [
2189 "errno",
2190 "libc",
2191 "linux-raw-sys",
2147 - "windows-sys 0.52.0",
2192 + "windows-sys 0.61.2",
2193 ]
2194
2195 [[package]]
@@ -2155,6 +2200,7 @@ checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
2200 dependencies = [
2201 "aws-lc-rs",
2202 "once_cell",
2203 + "ring",
2204 "rustls-pki-types",
2205 "rustls-webpki",
2206 "subtle",
@@ -2201,7 +2247,7 @@ dependencies = [
2247 "security-framework",
2248 "security-framework-sys",
2249 "webpki-root-certs",
2204 - "windows-sys 0.52.0",
2250 + "windows-sys 0.61.2",
2251 ]
2252
2253 [[package]]
@@ -2379,13 +2425,25 @@ dependencies = [
2425 "zmij",
2426 ]
2427
2428 +[[package]]
2429 +name = "serde_urlencoded"
2430 +version = "0.7.1"
2431 +source = "registry+https://github.com/rust-lang/crates.io-index"
2432 +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
2433 +dependencies = [
2434 + "form_urlencoded",
2435 + "itoa",
2436 + "ryu",
2437 + "serde",
2438 +]
2439 +
2440 [[package]]
2441 name = "serde_with"
2442 version = "3.24.0"
2443 source = "registry+https://github.com/rust-lang/crates.io-index"
2444 checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f"
2445 dependencies = [
2388 - "base64",
2446 + "base64 0.23.1",
2447 "bs58",
2448 "chrono",
2449 "hex",
@@ -2504,12 +2562,12 @@ dependencies = [
2562
2563 [[package]]
2564 name = "splitfire-agent"
2507 -version = "1.1.1"
2565 +version = "1.2.0"
2566 dependencies = [
2567 "agent-client-protocol",
2568 "anyhow",
2569 "async-trait",
2512 - "base64",
2570 + "base64 0.23.1",
2571 "directories",
2572 "ebur128",
2573 "ed-acp",
@@ -2518,6 +2576,7 @@ dependencies = [
2576 "ed-mcp",
2577 "hound",
2578 "realfft",
2579 + "reqwest 0.12.28",
2580 "serde",
2581 "serde_json",
2582 "symphonia",
@@ -3301,7 +3360,7 @@ version = "0.1.11"
3360 source = "registry+https://github.com/rust-lang/crates.io-index"
3361 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
3362 dependencies = [
3304 - "windows-sys 0.52.0",
3363 + "windows-sys 0.61.2",
3364 ]
3365
3366 [[package]]
Cargo.toml
+14 -5
@@ -1,6 +1,6 @@
1 [package]
2 name = "splitfire-agent"
3 -version = "1.1.1"
3 +version = "1.2.0"
4 edition = "2024"
5 license = "Apache-2.0"
6 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"]
11 # The `splitfire-agent` binary: its terminal UI, plus the MCP stub the integration tests run.
12 # A host embedding the library turns this off.
13 cli = ["dep:ed-acp-tui", "ed-mcp/test-server"]
14 +# The `embed` module: the SplitFire-only pieces a Tauri host needs beside `ed-acp-tauri`
15 +# (key fetch, the SplitFire MCP server, the iOS prompt/toolset trimming). Off by default so
16 +# the plain library and the binary don't pull in `reqwest`.
17 +embed = ["dep:reqwest"]
18
19 [[bin]]
20 name = "splitfire-agent"
@@ -24,15 +28,20 @@ required-features = ["cli"]
28
29 [dependencies]
30 # Onde Agent Platform: the ACP server, terminal UI and MCP layer, from the onde-ed workspace.
27 -ed-acp = { version = "1.2.2" }
28 -ed-acp-tui = { version = "1.2.2", optional = true }
29 -ed-mcp = { version = "1.2.2" }
31 +ed-acp = { version = "1.3.0" }
32 +ed-acp-tui = { version = "1.3.0", optional = true }
33 +ed-mcp = { version = "1.3.0" }
34 agent-client-protocol = "2.2"
35 anyhow = "1.0.104"
36 async-trait = "0.1"
37 directories = "6.0.0"
38 ebur128 = "0.1.10"
39 realfft = "3.5.0"
40 +# Only for `embed::fetch_key` and `embed::splitfire_mcp`, gated behind the `embed` feature.
41 +reqwest = { version = "0.12", default-features = false, features = [
42 + "json",
43 + "rustls-tls-native-roots",
44 +], optional = true }
45 serde = { version = "1.0.229", features = ["derive"] }
46 serde_json = "1.0.151"
47 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"
50 uuid = { version = "1.27.0", features = ["v4"] }
51
52 [dev-dependencies]
44 -ed-acp-testkit = { version = "1.2.2" }
53 +ed-acp-testkit = { version = "1.3.0" }
54 base64 = "0.23.1"
55 hound = "3.5.1"
README.md
+28
@@ -59,6 +59,34 @@ as `mcp__<server>__<tool>`, ask for permission unless the server marks them read
59 progress into the tool call, and are cancelled when you cancel the turn. A server that fails to
60 start is skipped.
61
62 +## Embedding
63 +
64 +A sandboxed app (an App Store build) can't launch the agent as a subprocess, so it runs it
65 +in-process: it serves [`SplitFire`] with `ed_acp::serve` over an in-memory channel and talks to
66 +it as a regular ACP client. The app-neutral half of that — the client, sessions, permissions,
67 +the key store — lives in the published [`ed-acp-tauri`](https://crates.io/crates/ed-acp-tauri)
68 +crate. The SplitFire-only pieces live here, behind the off-by-default `embed` feature:
69 +
70 +```toml
71 +splitfire-agent = { version = "1.2", default-features = false, features = ["embed"] }
72 +ed-acp-tauri = "1.3"
73 +ed-acp = "1.3"
74 +```
75 +
76 +`splitfire_agent::embed` has no `tauri` dependency; anything that needs the app's identifier
77 +takes it as a `&str`:
78 +
79 +- `embed::fetch_key(url, access_token, app_identifier)` asks the SplitFire API for the signed-in
80 + user's Onde key, rejecting with `SIGN_IN_REQUIRED` or `UNAVAILABLE`.
81 +- `embed::splitfire_mcp(url, access_token, app_identifier)` builds the SplitFire API's MCP server
82 + for a session (nothing without a token).
83 +- `embed::trim` drops `run_command` from the toolsets (`without_commands`) and swaps the stems
84 + paragraph (`replace_no_stems`) for a profile that runs where it can't start processes, e.g.
85 + iOS.
86 +
87 +All embedding apps must resolve a single `ed-acp` 1.3.x, so an `Arc<dyn Profile>` from this crate
88 +matches the one `ed-acp-tauri` expects.
89 +
90 ## Install and set up
91
92 ```sh
registry/agent.json
+1 -1
@@ -1,7 +1,7 @@
1 {
2 "id": "splitfire-agent",
3 "name": "SplitFire AI",
4 - "version": "1.1.1",
4 + "version": "1.2.0",
5 "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.",
6 "repository": "https://github.com/splitfireai/splitfire-agent",
7 "authors": [
src/embed/mod.rs new
+241
@@ -0,0 +1,241 @@
1 +//! The SplitFire-only pieces a Tauri host needs beside [`ed-acp-tauri`], which carries the
2 +//! app-neutral half of embedding (the in-process ACP client, sessions, permissions, the key
3 +//! store). These three stay here because they are specific to SplitFire's backend and apps:
4 +//!
5 +//! - [`fetch_key`]: ask the SplitFire API for the signed-in user's Onde key.
6 +//! - [`splitfire_mcp`]: the SplitFire API's MCP server for a session.
7 +//! - [`trim`]: drop `run_command` and swap the stems guidance on iOS.
8 +//!
9 +//! Nothing here depends on `tauri`. Anything that needed an `AppHandle` in the old
10 +//! `splitfire_agent_host` crate takes the app identifier as a `&str` instead, so the host
11 +//! passes `app.config().identifier`.
12 +//!
13 +//! [`ed-acp-tauri`]: https://crates.io/crates/ed-acp-tauri
14 +
15 +pub mod trim;
16 +
17 +use ed_acp::agent_client_protocol::schema::v1::{HttpHeader, McpServer, McpServerHttp};
18 +use serde::Deserialize;
19 +use tracing::warn;
20 +
21 +/// What [`fetch_key`] rejects with when the SplitFire session is missing or
22 +/// expired: the user signs in again.
23 +pub const SIGN_IN_REQUIRED: &str = "sign_in_required";
24 +
25 +/// What [`fetch_key`] rejects with when SplitFire can't issue a key now: the
26 +/// service is off, or the account isn't allowed one.
27 +pub const UNAVAILABLE: &str = "unavailable";
28 +
29 +/// Name of the SplitFire API's MCP server in agent sessions.
30 +pub const SPLITFIRE_MCP: &str = "splitfire";
31 +
32 +/// The agent key SplitFire hands a signed-in user, and the model tier it goes with.
33 +#[derive(Debug, Clone, PartialEq, Eq)]
34 +pub struct AgentKey {
35 + /// The Onde key, `app-id:app-secret`. Never logged.
36 + pub key: String,
37 + /// The tier SplitFire chose for this app. Older servers don't send it.
38 + pub model: Option<String>,
39 +}
40 +
41 +#[derive(Deserialize)]
42 +struct KeyResponse {
43 + key: String,
44 + model: Option<String>,
45 +}
46 +
47 +/// Ask SplitFire for the signed-in user's Onde key and the model to use with it. `url` is the
48 +/// app's `/api/v1/agent/key`; `app_identifier` goes out as sent, so a debug build is told
49 +/// apart from the release app and SplitFire picks its model. The key is never logged.
50 +pub async fn fetch_key(
51 + url: &str,
52 + access_token: &str,
53 + app_identifier: &str,
54 +) -> Result<AgentKey, String> {
55 + let response = reqwest::Client::new()
56 + .post(url)
57 + .bearer_auth(access_token)
58 + .header("X-App-Identifier", app_identifier)
59 + .timeout(std::time::Duration::from_secs(30))
60 + .send()
61 + .await
62 + .map_err(|e| {
63 + warn!("Asking SplitFire for an agent key failed: {e}");
64 + "Couldn't reach SplitFire. Check your connection and try again.".to_string()
65 + })?;
66 + let status = response.status();
67 + match status.as_u16() {
68 + 200 => {
69 + let body = response
70 + .bytes()
71 + .await
72 + .map_err(|e| format!("Reading SplitFire's answer failed: {e}"))?;
73 + serde_json::from_slice::<KeyResponse>(&body)
74 + .map(|parsed| AgentKey {
75 + key: parsed.key,
76 + model: parsed.model,
77 + })
78 + .map_err(|_| "SplitFire answered with something unexpected.".to_string())
79 + }
80 + 401 => Err(SIGN_IN_REQUIRED.to_string()),
81 + 403 | 503 => Err(UNAVAILABLE.to_string()),
82 + other => Err(format!(
83 + "SplitFire answered {other}. Try again in a moment."
84 + )),
85 + }
86 +}
87 +
88 +/// The SplitFire API's MCP server for a signed-in user, as an ACP HTTP MCP server. `url` is the
89 +/// app's `/api/v1/mcp`. ed-acp connects to it per session; its tools reach the model as
90 +/// `mcp__splitfire__*`. The server answers with the tool list for the app named in
91 +/// `X-App-Identifier` (the app's own identifier, debug builds as sent). Nothing without a token.
92 +pub fn splitfire_mcp(
93 + url: String,
94 + access_token: Option<String>,
95 + app_identifier: &str,
96 +) -> Vec<McpServer> {
97 + let Some(token) = access_token.filter(|t| !t.is_empty()) else {
98 + return Vec::new();
99 + };
100 + vec![McpServer::Http(
101 + McpServerHttp::new(SPLITFIRE_MCP, url).headers(vec![
102 + HttpHeader::new("Authorization", format!("Bearer {token}")),
103 + HttpHeader::new("X-App-Identifier", app_identifier.to_string()),
104 + ]),
105 + )]
106 +}
107 +
108 +#[cfg(test)]
109 +mod tests {
110 + use super::*;
111 +
112 + /// Serve one canned HTTP answer on a free port. Returns the URL and a handle
113 + /// that yields the request the server saw.
114 + fn stub(status: &str, body: &str) -> (String, std::thread::JoinHandle<String>) {
115 + use std::io::{Read, Write};
116 + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
117 + let url = format!("http://{}/api/v1/agent/key", listener.local_addr().unwrap());
118 + let answer = format!(
119 + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
120 + body.len()
121 + );
122 + let handle = std::thread::spawn(move || {
123 + let (mut stream, _) = listener.accept().unwrap();
124 + let mut seen = vec![0u8; 4096];
125 + let n = stream.read(&mut seen).unwrap();
126 + stream.write_all(answer.as_bytes()).unwrap();
127 + String::from_utf8_lossy(&seen[..n]).to_string()
128 + });
129 + (url, handle)
130 + }
131 +
132 + fn ask(status: &str, body: &str) -> (Result<AgentKey, String>, String) {
133 + let (url, server) = stub(status, body);
134 + let result = tokio::runtime::Builder::new_current_thread()
135 + .enable_all()
136 + .build()
137 + .unwrap()
138 + .block_on(fetch_key(
139 + &url,
140 + "user-token",
141 + "ai.splitfire.KaroKoweJam-Debug",
142 + ));
143 + (result, server.join().unwrap())
144 + }
145 +
146 + #[test]
147 + fn the_key_comes_from_a_post_with_the_users_token() {
148 + let (result, request) = ask("200 OK", r#"{"key":"101:secret","model":"onde-balanced"}"#);
149 + assert_eq!(
150 + result,
151 + Ok(AgentKey {
152 + key: "101:secret".to_string(),
153 + model: Some("onde-balanced".to_string()),
154 + })
155 + );
156 + assert!(request.starts_with("POST /api/v1/agent/key"), "{request}");
157 + assert!(
158 + request
159 + .to_lowercase()
160 + .contains("authorization: bearer user-token"),
161 + "{request}"
162 + );
163 + // The app id goes out unchanged, so a debug build is told apart.
164 + assert!(
165 + request
166 + .to_lowercase()
167 + .contains("x-app-identifier: ai.splitfire.karokowejam-debug"),
168 + "{request}"
169 + );
170 + }
171 +
172 + #[test]
173 + fn an_older_server_sends_no_model() {
174 + let (result, _) = ask("200 OK", r#"{"key":"101:secret"}"#);
175 + assert_eq!(
176 + result,
177 + Ok(AgentKey {
178 + key: "101:secret".to_string(),
179 + model: None,
180 + })
181 + );
182 + }
183 +
184 + #[test]
185 + fn a_missing_session_and_an_unavailable_service_have_their_own_answers() {
186 + assert_eq!(
187 + ask("401 Unauthorized", "{}").0,
188 + Err(SIGN_IN_REQUIRED.to_string())
189 + );
190 + assert_eq!(ask("403 Forbidden", "{}").0, Err(UNAVAILABLE.to_string()));
191 + assert_eq!(
192 + ask("503 Service Unavailable", "{}").0,
193 + Err(UNAVAILABLE.to_string())
194 + );
195 + }
196 +
197 + #[test]
198 + fn other_failures_say_so_without_leaking_the_body() {
199 + let error = ask("502 Bad Gateway", r#"{"error":"secret-detail"}"#)
200 + .0
201 + .unwrap_err();
202 + assert!(error.contains("502"), "{error}");
203 + assert!(!error.contains("secret-detail"), "{error}");
204 + let error = ask("200 OK", "not json").0.unwrap_err();
205 + assert!(error.contains("unexpected"), "{error}");
206 + }
207 +
208 + #[test]
209 + fn mcp_needs_a_non_empty_token() {
210 + assert!(splitfire_mcp("https://api/x".to_string(), None, "app").is_empty());
211 + assert!(splitfire_mcp("https://api/x".to_string(), Some(String::new()), "app").is_empty());
212 + }
213 +
214 + #[test]
215 + fn mcp_carries_the_token_and_app_identifier() {
216 + let servers = splitfire_mcp(
217 + "https://api.splitfire.ai/api/v1/mcp".to_string(),
218 + Some("user-token".to_string()),
219 + "ai.splitfire.KaroKoweJam",
220 + );
221 + let McpServer::Http(http) = servers.into_iter().next().expect("one server") else {
222 + panic!("expected an HTTP MCP server");
223 + };
224 + assert_eq!(http.name, SPLITFIRE_MCP);
225 + assert_eq!(http.url, "https://api.splitfire.ai/api/v1/mcp");
226 + let header = |name: &str| {
227 + http.headers
228 + .iter()
229 + .find(|h| h.name == name)
230 + .map(|h| h.value.clone())
231 + };
232 + assert_eq!(
233 + header("Authorization"),
234 + Some("Bearer user-token".to_string())
235 + );
236 + assert_eq!(
237 + header("X-App-Identifier"),
238 + Some("ai.splitfire.KaroKoweJam".to_string())
239 + );
240 + }
241 +}
src/embed/trim.rs new
+109
@@ -0,0 +1,109 @@
1 +//! What a host takes out of the agent on a platform that can't start processes,
2 +//! and the stems paragraph an app swaps in when it has its own answer.
3 +//!
4 +//! iOS apps can't start other programs, so `run_command` would only ever fail
5 +//! there and the stock "install demucs" advice is no use. Each app's iOS
6 +//! profile wraps its desktop one with these.
7 +
8 +use std::sync::Arc;
9 +
10 +use async_trait::async_trait;
11 +use ed_acp::agent_client_protocol::schema::v1::{ToolCallLocation, ToolKind};
12 +use ed_acp::{DescribeCtx, ToolCtx, ToolOutcome, Toolset};
13 +use serde_json::Value;
14 +
15 +// The markers the "no stems" paragraph starts and ends with live in `prompt.rs`, which writes
16 +// the paragraph, so the prompt and the replacement below can't drift apart.
17 +pub use crate::prompt::{NO_STEMS_END, NO_STEMS_START};
18 +
19 +/// The workspace tool that starts a process.
20 +pub const RUN_COMMAND: &str = "run_command";
21 +
22 +/// Swap the stock "no stems" paragraph for `note`; if the stock prompt is
23 +/// worded differently, append the note rather than lose it.
24 +pub fn replace_no_stems(prompt: String, note: &str) -> String {
25 + if let Some(start) = prompt.find(NO_STEMS_START)
26 + && let Some(rel) = prompt[start..].find(NO_STEMS_END)
27 + {
28 + let end = start + rel + NO_STEMS_END.len();
29 + return format!("{}{}{}", &prompt[..start], note, &prompt[end..]);
30 + }
31 + format!("{prompt}\n\n{note}")
32 +}
33 +
34 +/// The name of a tool definition, as the model is offered it.
35 +pub fn tool_name(definition: &Value) -> Option<&str> {
36 + definition.pointer("/function/name").and_then(Value::as_str)
37 +}
38 +
39 +/// `toolsets` with `run_command` taken out of what the model is offered.
40 +pub fn without_commands(toolsets: Vec<Arc<dyn Toolset>>) -> Vec<Arc<dyn Toolset>> {
41 + toolsets
42 + .into_iter()
43 + .map(|inner| Arc::new(NoCommands(inner)) as Arc<dyn Toolset>)
44 + .collect()
45 +}
46 +
47 +struct NoCommands(Arc<dyn Toolset>);
48 +
49 +#[async_trait]
50 +impl Toolset for NoCommands {
51 + fn definitions(&self) -> Vec<Value> {
52 + self.0
53 + .definitions()
54 + .into_iter()
55 + .filter(|d| tool_name(d) != Some(RUN_COMMAND))
56 + .collect()
57 + }
58 +
59 + fn handles(&self, name: &str) -> bool {
60 + name != RUN_COMMAND && self.0.handles(name)
61 + }
62 +
63 + fn describe(
64 + &self,
65 + name: &str,
66 + args: &Value,
67 + ctx: &DescribeCtx<'_>,
68 + ) -> (String, ToolKind, Vec<ToolCallLocation>) {
69 + self.0.describe(name, args, ctx)
70 + }
71 +
72 + async fn execute(
73 + &self,
74 + ctx: &ToolCtx,
75 + tool_call_id: &str,
76 + name: &str,
77 + args: Value,
78 + ) -> anyhow::Result<ToolOutcome> {
79 + self.0.execute(ctx, tool_call_id, name, args).await
80 + }
81 +}
82 +
83 +#[cfg(test)]
84 +mod tests {
85 + use super::*;
86 +
87 + #[test]
88 + fn replaces_the_stock_paragraph() {
89 + let stock =
90 + format!("before\n{NO_STEMS_START} tell them to install demucs, {NO_STEMS_END}\nafter");
91 + let out = replace_no_stems(stock, "NOTE");
92 + assert_eq!(out, "before\nNOTE\nafter");
93 + }
94 +
95 + #[test]
96 + fn appends_when_the_wording_changed() {
97 + let out = replace_no_stems("something else".to_string(), "NOTE");
98 + assert_eq!(out, "something else\n\nNOTE");
99 + }
100 +
101 + #[test]
102 + fn the_markers_bound_the_real_prompt_paragraph() {
103 + // The prompt the agent ships must still contain the paragraph these markers point at,
104 + // or a host's `replace_no_stems` would silently append instead of replacing.
105 + let prompt = crate::prompt::system_prompt("acp", std::path::Path::new("/tmp"), &[], false);
106 + let start = prompt.find(NO_STEMS_START).expect("start marker present");
107 + assert!(prompt[start..].contains(NO_STEMS_END), "end marker present");
108 + }
109 +}
src/lib.rs
+2
@@ -6,6 +6,8 @@
6 //! a subprocess. Build with `default-features = false` to leave out the terminal UI.
7
8 mod audio;
9 +#[cfg(feature = "embed")]
10 +pub mod embed;
11 pub mod profile;
12 mod prompt;
13 mod theory;
src/prompt.rs
+16 -4
@@ -6,6 +6,13 @@ 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,
@@ -33,11 +40,16 @@ pub fn system_prompt(
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 {
37 - "\nStem separation is not available in this session. If the user asks for stems, tell \
38 - them it needs the `demucs` command installed and on PATH (or SPLITFIRE_DEMUCS_BIN \
39 - pointing at it), and that the session must be restarted afterwards. Do not pretend to \
40 - separate anything, and do not offer a workaround with the file or shell tools."
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 \