swarm-stt: the request subject, the service's NATS permissions and the --stt-client flag
This commit is contained in:
parent
40ec5ac9e0
commit
47bec6bef3
4 changed files with 171 additions and 1 deletions
|
|
@ -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")?;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
84
swarm-wire/src/stt.rs
Normal 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()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue