Watch
0
0
Fork
You've already forked hyperhive
0

swarm-stt: the request subject, the service's NATS permissions and the --stt-client flag

This commit is contained in:
damocles 2026-11-11 21:53:10 +01:00 • committed by mara
commit 47bec6bef3
4 changed files with 171 additions and 1 deletions

View file

@ -166,6 +166,11 @@ struct Args {
#[arg(long = "notify-router-client")]
notify_router_clients: Vec<String>,
/// Client ids that answer voice memo transcription requests.
/// Repeatable, empty by default, which denies such a client.
#[arg(long = "stt-client")]
stt_clients: Vec<String>,
/// 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")?;

View file

@ -51,6 +51,7 @@ pub struct Policy {
review_classifiers: Vec<String>,
builders: Vec<String>,
claude_logins: Vec<String>,
stts: Vec<String>,
notify_routers: Vec<String>,
}
@ -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<String>) -> 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<String>) -> 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.

View file

@ -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;

84
swarm-wire/src/stt.rs Normal file
View file

@ -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}.<agent>`. 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::<Response>(&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()
}
);
}
}