From 47bec6bef31223180a36e074789dd1731a23683a Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 11 Nov 2026 21:53:10 +0100 Subject: [PATCH] swarm-stt: the request subject, the service's NATS permissions and the --stt-client flag --- swarm-nats-auth/src/main.rs | 8 +++- swarm-nats-auth/src/policy.rs | 79 ++++++++++++++++++++++++++++++++ swarm-wire/src/lib.rs | 1 + swarm-wire/src/stt.rs | 84 +++++++++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 swarm-wire/src/stt.rs diff --git a/swarm-nats-auth/src/main.rs b/swarm-nats-auth/src/main.rs index 59ec2bf5e..f3fe7a639 100644 --- a/swarm-nats-auth/src/main.rs +++ b/swarm-nats-auth/src/main.rs @@ -166,6 +166,11 @@ struct Args { #[arg(long = "notify-router-client")] notify_router_clients: Vec, + /// Client ids that answer voice memo transcription requests. + /// Repeatable, empty by default, which denies such a client. + #[arg(long = "stt-client")] + stt_clients: Vec, + /// Role on the secret store's `cert` auth mount this responder logs in /// as to read the callout keys, its own OIDC client secret and agent /// credentials. The store is found @@ -232,7 +237,8 @@ async fn serve(args: Args) -> anyhow::Result<()> { .with_review_classifiers(args.review_classifier_clients.clone()) .with_builders(args.builder_clients.clone()) .with_claude_logins(args.claude_login_clients.clone()) - .with_notify_routers(args.notify_router_clients.clone()); + .with_notify_routers(args.notify_router_clients.clone()) + .with_stts(args.stt_clients.clone()); let http = reqwest::Client::new(); let settings = swarm_secret_client::client::Settings::from_env() .context("reading the secret store's address and this responder's identity")?; diff --git a/swarm-nats-auth/src/policy.rs b/swarm-nats-auth/src/policy.rs index 39ac68180..e3234cf36 100644 --- a/swarm-nats-auth/src/policy.rs +++ b/swarm-nats-auth/src/policy.rs @@ -51,6 +51,7 @@ pub struct Policy { review_classifiers: Vec, builders: Vec, claude_logins: Vec, + stts: Vec, notify_routers: Vec, } @@ -99,6 +100,7 @@ impl Policy { review_classifiers: Vec::new(), builders: Vec::new(), claude_logins: Vec::new(), + stts: Vec::new(), notify_routers: Vec::new(), }) } @@ -142,6 +144,13 @@ impl Policy { self } + /// Set the client ids that answer voice memo transcription requests. + #[must_use] + pub fn with_stts(mut self, clients: Vec) -> Self { + self.stts = clients; + self + } + /// Set the client ids that route forge events to agents' todo queues. #[must_use] pub fn with_notify_routers(mut self, clients: Vec) -> Self { @@ -223,9 +232,25 @@ impl Policy { if self.notify_routers.iter().any(|c| c == client_id) { return Some(Self::notify_router_permissions(client_id)); } + if self.stts.iter().any(|c| c == client_id) { + return Some(Self::stt_permissions(client_id)); + } None } + /// What the speech-to-text service may do: hear every agent's request + /// subject and its own inbox, and answer each request on its reply + /// subject. It publishes only into its own inbox, which reaches no other + /// client; the replies need no publish grant. + fn stt_permissions(client_id: &str) -> Permissions { + let inbox = Self::inbox(client_id); + Permissions { + publish: vec![inbox.clone()], + subscribe: Some(vec![swarm_wire::stt::subject_wildcard(), inbox]), + responses: Some(swarm_wire::stt::REPLY_DEADLINE), + } + } + /// What the review classifier may do: hear try-out requests and answer /// each on its reply subject, for as long as a run may take. /// @@ -1931,6 +1956,60 @@ mod tests { } } + const STT: &str = "swarm-stt"; + + #[test] + fn the_stt_service_hears_every_agents_subject_and_its_inbox_and_answers_by_reply() { + use swarm_queue_client::client_inbox_prefix; + use swarm_wire::stt::{REPLY_DEADLINE, request_subject}; + + let p = policy() + .with_stts(vec![STT.to_owned()]) + .permissions(STT) + .expect("a configured service is admitted"); + let inbox = client_inbox_prefix(STT); + assert!(may_subscribe(&p, &request_subject("iris"))); + assert!(may_subscribe(&p, &request_subject("damocles"))); + assert!(may_subscribe(&p, &reply(&inbox))); + for refused in [ + ">".to_owned(), + "$SWARM.>".to_owned(), + swarm_wire::claude_login::SUBJECT.to_owned(), + reply(&client_inbox_prefix("swarm-controller")), + ] { + assert!( + !may_subscribe(&p, &refused), + "the service may subscribe to {refused}" + ); + } + assert_eq!(p.publish, vec![format!("{inbox}.>")]); + assert_eq!(p.responses, Some(REPLY_DEADLINE)); + // The service's id is nothing until it is configured. + assert_eq!(policy().permissions(STT), None); + } + + #[test] + fn an_agent_token_sends_audio_on_its_own_subject_only() { + use swarm_wire::stt::{SUBJECT_PREFIX, request_subject}; + + let p = policy() + .with_agent_token_subjects(vec![format!("{SUBJECT_PREFIX}.{{agent}}")]) + .expect("the template names the agent"); + let grant = p.agent_token_permissions("iris").expect("configured"); + assert!( + grant + .publish + .iter() + .any(|g| covers(g, &request_subject("iris"))) + ); + assert!( + !grant + .publish + .iter() + .any(|g| covers(g, &request_subject("argus"))) + ); + } + const NOTIFY_ROUTER: &str = "swarm-notify-router"; /// The three router-todo templates as `swarm-nats.nix` spells them. diff --git a/swarm-wire/src/lib.rs b/swarm-wire/src/lib.rs index 179c0474b..ba2fc3ad4 100644 --- a/swarm-wire/src/lib.rs +++ b/swarm-wire/src/lib.rs @@ -28,6 +28,7 @@ pub mod review_classifier; pub mod router_api; pub mod router_sink; pub mod router_todo; +pub mod stt; pub mod subagent_term; pub mod wanted; diff --git a/swarm-wire/src/stt.rs b/swarm-wire/src/stt.rs new file mode 100644 index 000000000..0e0d5a525 --- /dev/null +++ b/swarm-wire/src/stt.rs @@ -0,0 +1,84 @@ +//! The speech-to-text service's request: the subject an agent sends a voice +//! memo on, and what comes back. +//! +//! Three crates name these: an agent's harness sends the audio, `swarm-stt` +//! answers, and the auth-callout responder grants each its side. The request +//! body is the raw bytes of the audio file, nothing wrapped around them, so +//! the service hashes exactly what the sender held. The answer is a +//! [`Response`] as JSON on the request's reply subject. + +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +/// The subject an agent sends its audio on, `{prefix}.`. The agent's own +/// name ends it, so an agent's token can publish only to its own subject, and +/// the service learns from the subject who asked. +pub const SUBJECT_PREFIX: &str = "$SWARM.stt.transcribe"; + +/// The subject agent `agent` sends audio on. +#[must_use] +pub fn request_subject(agent: &str) -> String { + format!("{SUBJECT_PREFIX}.{agent}") +} + +/// What the service subscribes to: every agent's request subject. +#[must_use] +pub fn subject_wildcard() -> String { + format!("{SUBJECT_PREFIX}.*") +} + +/// How long a sender waits for the answer, and the queue lets the service +/// reply: the longest run, plus the time a request may wait for the engine's +/// one slot. +pub const REPLY_DEADLINE: Duration = Duration::from_mins(10); + +/// The service's answer to one request. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum Response { + /// The audio was transcribed, now or earlier. + Ok { + /// The text spoken in the audio (empty for silence). + text: String, + /// `true` when no engine run was started for this request. + cached: bool, + }, + /// The service could not transcribe it; the message says why and is safe + /// to show to the sender. + Failed { + /// Why. + message: String, + }, +} + +#[cfg(test)] +mod tests { + use super::{Response, request_subject, subject_wildcard}; + + #[test] + fn an_agent_sends_on_its_own_subject_and_the_service_hears_them_all() { + assert_eq!(request_subject("iris"), "$SWARM.stt.transcribe.iris"); + assert_eq!(subject_wildcard(), "$SWARM.stt.transcribe.*"); + } + + #[test] + fn a_response_travels_as_tagged_json() { + let ok = Response::Ok { + text: "hello".into(), + cached: true, + }; + let json = serde_json::to_string(&ok).unwrap(); + assert_eq!(json, r#"{"status":"ok","text":"hello","cached":true}"#); + assert_eq!(serde_json::from_str::(&json).unwrap(), ok); + + let failed: Response = + serde_json::from_str(r#"{"status":"failed","message":"too large"}"#).unwrap(); + assert_eq!( + failed, + Response::Failed { + message: "too large".into() + } + ); + } +}