Watch
0
0
Fork
You've already forked hyperhive
0

agents can transcribe an audio file: the transcribe_audio tool, through the harness and the speech-to-text service

This commit is contained in:
damocles 2026-11-12 10:14:54 +01:00 • committed by mara
commit eff209f756
11 changed files with 232 additions and 12 deletions

View file

@ -95,7 +95,7 @@ hive_name?, swarm_name?, matrix_accounts? }`. `matrix_accounts` is a
`name` to query self. `name` to query self.
**Always-on, no permission needed**: `set_status(text)`, `compact()`, **Always-on, no permission needed**: `set_status(text)`, `compact()`,
`mark_todos_done(ids)`. `mark_todos_done(ids)`, `transcribe_audio(path)`.
- `set_status` — set a free-text status string visible on the - `set_status` — set a free-text status string visible on the
dashboard. Single line, ≤ 200 chars. Persisted to dashboard. Single line, ≤ 200 chars. Persisted to
@ -112,6 +112,15 @@ hive_name?, swarm_name?, matrix_accounts? }`. `matrix_accounts` is a
notes-checkpoint turn still fires first. Dispatched through the notes-checkpoint turn still fires first. Dispatched through the
in-agent socket (`hive-agent-sock::Request::Compact`), not the in-agent socket (`hive-agent-sock::Request::Compact`), not the
broker — see `hive-agent/src/todo_server.rs`. broker — see `hive-agent/src/todo_server.rs`.
- `transcribe_audio(path)` — the text spoken in an audio file (a matrix
voice memo that `download_file` saved, or any Ogg, WAV, FLAC, MP3, AMR,
MP4/M4A or WebM file up to 6 MiB). Dials the in-agent socket
(`hive-agent-sock::Request::Transcribe`) with a 16-minute deadline; the
harness reads the file and sends it over the swarm queue on this agent's
request subject to the speech-to-text service
([`stt.md`](../swarm/stt.md)), which keeps each transcript for a day by the
audio's hash. An error names why: no service running, a file that isn't
audio, too large, or the service busy.
- `mark_todos_done(ids)` — bulk-clear specific loose-ends-v2 todo rows - `mark_todos_done(ids)` — bulk-clear specific loose-ends-v2 todo rows
by id in one call, instead of `cancel_loose_end`ing each one. by id in one call, instead of `cancel_loose_end`ing each one.
List-based: pass the ids `get_loose_ends` actually showed you reviewed; List-based: pass the ids `get_loose_ends` actually showed you reviewed;

View file

@ -274,7 +274,7 @@ host access it has.
| `metrics` | the metrics store: `swarm-metrics` queries. Checked by `swarm-metrics-agent-auth`, the same binary, for the metrics gateway. Off by default. | | `metrics` | the metrics store: `swarm-metrics` queries. Checked by `swarm-metrics-agent-auth`, the same binary, for the metrics gateway. Off by default. |
**Always-on tools** — `Permission::ALWAYS_ON_TOOLS` exposes `set_status`, **Always-on tools** — `Permission::ALWAYS_ON_TOOLS` exposes `set_status`,
`compact`, and `mark_todos_done` to every agent regardless of its `compact`, `mark_todos_done` and `transcribe_audio` to every agent regardless of its
permissions. The operator dashboard depends on every agent being able to permissions. The operator dashboard depends on every agent being able to
report its status chip, and the server-side `SetStatus` handler has no report its status chip, and the server-side `SetStatus` handler has no
permission check (only length validation), so gating it would only desync permission check (only length validation), so gating it would only desync
@ -282,6 +282,9 @@ the `--allowedTools` list from what the host actually accepts. Revoking
`meta` therefore drops `get_agent_meta` but never `set_status`. `meta` therefore drops `get_agent_meta` but never `set_status`.
`mark_todos_done` is here because producers push todos to an agent whether `mark_todos_done` is here because producers push todos to an agent whether
or not it holds `inbox`, and an agent without it still needs to clear them. or not it holds `inbox`, and an agent without it still needs to clear them.
`transcribe_audio` is here because it only turns an audio file the agent
already holds into text through the swarm's service; it reaches nothing the
agent couldn't read.
**Where they live** — in the swarm, not on the hive: the `permissions` field of **Where they live** — in the swarm, not on the hive: the `permissions` field of
the agent's key in the `agent-permissions` KV bucket the agent's key in the `agent-permissions` KV bucket

View file

@ -13,6 +13,15 @@ pub struct MarkTodosDoneArgs {
pub ids: Vec<i64>, pub ids: Vec<i64>,
} }
/// MCP tool args for `transcribe_audio`.
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct TranscribeAudioArgs {
/// Absolute path of the audio file to transcribe (a regular file, at most
/// 6 MiB; Ogg, WAV, FLAC, MP3, AMR, `MP4`/`M4A` or `WebM`). For a matrix voice
/// memo, the `path` that `download_file` returned.
pub path: String,
}
/// MCP tool args for `remind`. Exactly one of `delay_seconds` or /// MCP tool args for `remind`. Exactly one of `delay_seconds` or
/// `at_unix_timestamp` must be set; both / neither is a tool-side error. /// `at_unix_timestamp` must be set; both / neither is a tool-side error.
/// Hides the tagged `ReminderTiming` enum behind a flatter schema so the /// Hides the tagged `ReminderTiming` enum behind a flatter schema so the

View file

@ -24,13 +24,14 @@ mod args;
mod render; mod render;
pub use args::{ pub use args::{
CancelLooseEndArgs, CompactArgs, GetAgentMetaArgs, MarkTodosDoneArgs, RemindArgs, SetStatusArgs, CancelLooseEndArgs, CompactArgs, GetAgentMetaArgs, MarkTodosDoneArgs, RemindArgs,
SetStatusArgs, TranscribeAudioArgs,
}; };
pub use render::{annotate_retries, format_ack, format_agent_meta}; pub use render::{annotate_retries, format_ack, format_agent_meta};
use render::{ use render::{
dial_agent_socket, format_matrix_summary, local_reminders, local_todos, mark_local_todo_done, dial_agent_socket, format_matrix_summary, local_reminders, local_todos, mark_local_todo_done,
mark_local_todos_done, matrix_unread_summary, render_loose_ends, reply_err, mark_local_todos_done, matrix_unread_summary, render_loose_ends, reply_err, transcribe_local,
}; };
/// Write (or remove) the status file in the agent's own `state/` directory. /// Write (or remove) the status file in the agent's own `state/` directory.
@ -329,6 +330,37 @@ impl AgentServer {
.await .await
} }
#[tool(
description = "Get the text spoken in an audio file, from the swarm's speech-to-text \
service: a matrix voice memo (download it with the matrix `download_file` tool and pass \
the returned `path`), or any Ogg, WAV, FLAC, MP3, AMR, MP4/M4A or WebM file up to 6 MiB. \
The service keeps each transcript for a day, so the same memo heard by several agents is \
transcribed once. Can take minutes when other memos are queued ahead. Returns the text \
(empty for silence), or why it could not be transcribed."
)]
async fn transcribe_audio(&self, Parameters(args): Parameters<TranscribeAudioArgs>) -> String {
let log = format!("{args:?}");
run_tool_envelope("transcribe_audio", log, async move {
match transcribe_local(args.path).await {
Some(hive_agent_sock::Response::Transcript { text, .. }) => {
if text.trim().is_empty() {
"(no speech found)".to_owned()
} else {
text
}
}
Some(hive_agent_sock::Response::Err { message }) => {
format!("transcribe_audio failed: {message}")
}
Some(other) => format!("transcribe_audio unexpected response: {other:?}"),
None => "transcribe_audio: local agent socket unavailable \
(HIVE_AGENT_SOCKET unset or harness unreachable)"
.to_owned(),
}
})
.await
}
#[tool( #[tool(
description = "Schedule a reminder that lands in this agent's own inbox at a future \ description = "Schedule a reminder that lands in this agent's own inbox at a future \
time (sender will appear as `reminder`). Use for self-paced follow-ups: 'check task \ time (sender will appear as `reminder`). Use for self-paced follow-ups: 'check task \

View file

@ -302,6 +302,26 @@ pub(super) async fn mark_local_todos_done(ids: Vec<i64>) -> Option<hive_agent_so
dial_agent_socket(&hive_agent_sock::Request::MarkTodosDone { ids }).await dial_agent_socket(&hive_agent_sock::Request::MarkTodosDone { ids }).await
} }
/// Ask the harness for the text of the audio file at `path`. The harness
/// waits on the swarm's speech-to-text service, which queues requests behind
/// one engine slot, so this waits much longer than any other socket call.
pub(super) async fn transcribe_local(path: String) -> Option<hive_agent_sock::Response> {
let socket = std::env::var_os("HIVE_AGENT_SOCKET").map(std::path::PathBuf::from)?;
if !socket.exists() {
return None;
}
dial_socket(
&socket,
&hive_agent_sock::Request::Transcribe { path },
TRANSCRIBE_DEADLINE,
)
.await
}
/// Longest a `transcribe_audio` call waits: the service's reply deadline (15
/// minutes, `swarm_wire::stt::REPLY_DEADLINE`) plus a margin for the harness.
const TRANSCRIBE_DEADLINE: std::time::Duration = std::time::Duration::from_mins(16);
/// Format a `Vec<MatrixRoomUnread>` into a per-room summary string. /// Format a `Vec<MatrixRoomUnread>` into a per-room summary string.
/// Single room / single message collapses to one line; multi-room /// Single room / single message collapses to one line; multi-room
/// expands to a bulleted list. Returns an empty string for empty input. /// expands to a bulleted list. Returns an empty string for empty input.

View file

@ -135,6 +135,13 @@ pub enum Request {
/// through this variant, since a human watching the dashboard isn't /// through this variant, since a human watching the dashboard isn't
/// waiting on a wake. /// waiting on a wake.
Compact { wake_prompt: Option<String> }, Compact { wake_prompt: Option<String> },
/// Ask the swarm's speech-to-text service for the text of the audio file
/// at `path`. The harness reads the file (a regular file under the size
/// cap), sends the bytes on this agent's request subject
/// (`swarm_wire::stt`), and answers [`Response::Transcript`], or
/// [`Response::Err`] with the service's short reason. Can take minutes:
/// the service queues requests behind its one engine slot.
Transcribe { path: String },
} }
/// A response on the in-agent socket. Serialised with a `kind` tag, /// A response on the in-agent socket. Serialised with a `kind` tag,
@ -155,6 +162,9 @@ pub enum Response {
ReminderRollup { ReminderRollup {
stats: hive_sh4re::inbox::ReminderStats, stats: hive_sh4re::inbox::ReminderStats,
}, },
/// `Transcribe` result: the text spoken in the audio (empty for silence),
/// and whether the service had it already.
Transcript { text: String, cached: bool },
/// Op failed; `message` is operator-facing. /// Op failed; `message` is operator-facing.
Err { message: String }, Err { message: String },
} }

View file

@ -48,6 +48,7 @@ mod term_msg;
mod todo_server; mod todo_server;
mod todos; mod todos;
mod todos_gauge; mod todos_gauge;
mod transcribe;
mod turn; mod turn;
mod turn_stats; mod turn_stats;
mod vacuum; mod vacuum;

View file

@ -183,8 +183,11 @@ async fn handle_conn(
reminders: Option<&Reminders>, reminders: Option<&Reminders>,
bus: &Bus, bus: &Bus,
) -> Result<()> { ) -> Result<()> {
serve_one(stream, REQUEST_DEADLINES, |req| { serve_one(stream, REQUEST_DEADLINES, async |req| match req {
dispatch(req, store, wake, reminders, bus) // Waits on the swarm's speech-to-text service, so it is the one
// request that does not answer from the local store at once.
Request::Transcribe { path } => crate::transcribe::run(&path).await,
req => dispatch(req, store, wake, reminders, bus),
}) })
.await .await
} }
@ -201,7 +204,7 @@ const REQUEST_DEADLINES: LineDeadlines = LineDeadlines {
async fn serve_one( async fn serve_one(
stream: UnixStream, stream: UnixStream,
deadlines: LineDeadlines, deadlines: LineDeadlines,
apply: impl FnOnce(Request) -> Response, apply: impl AsyncFnOnce(Request) -> Response,
) -> Result<()> { ) -> Result<()> {
let (read, mut write) = stream.into_split(); let (read, mut write) = stream.into_split();
let mut reader = BufReader::new(read); let mut reader = BufReader::new(read);
@ -214,7 +217,7 @@ async fn serve_one(
Response::Err { message } Response::Err { message }
} else { } else {
match serde_json::from_slice::<Request>(line.trim_ascii()) { match serde_json::from_slice::<Request>(line.trim_ascii()) {
Ok(req) => apply(req), Ok(req) => apply(req).await,
Err(e) => Response::Err { Err(e) => Response::Err {
message: format!("bad request: {e}"), message: format!("bad request: {e}"),
}, },
@ -276,6 +279,11 @@ fn dispatch(
Request::CountPendingReminders => count_pending_reminders(reminders), Request::CountPendingReminders => count_pending_reminders(reminders),
Request::ReminderRollup { since_secs } => reminder_rollup(reminders, since_secs), Request::ReminderRollup { since_secs } => reminder_rollup(reminders, since_secs),
Request::Compact { wake_prompt } => compact(bus, wake_prompt), Request::Compact { wake_prompt } => compact(bus, wake_prompt),
// Answered by `handle_conn` before this runs: it waits on the queue,
// which this store-only function never does.
Request::Transcribe { .. } => Response::Err {
message: "transcribe is answered by the connection handler, not the store".to_owned(),
},
} }
} }
@ -695,7 +703,7 @@ mod tests {
/// Send `input` (then keep the write half open), return the one response line. /// Send `input` (then keep the write half open), return the one response line.
async fn exchange(input: Vec<u8>, deadlines: LineDeadlines) -> (Response, Result<()>) { async fn exchange(input: Vec<u8>, deadlines: LineDeadlines) -> (Response, Result<()>) {
let (client, server) = UnixStream::pair().expect("socketpair"); let (client, server) = UnixStream::pair().expect("socketpair");
let handler = tokio::spawn(serve_one(server, deadlines, |_| Response::Ok)); let handler = tokio::spawn(serve_one(server, deadlines, async |_| Response::Ok));
let (read, mut write) = client.into_split(); let (read, mut write) = client.into_split();
let _writer = tokio::spawn(async move { let _writer = tokio::spawn(async move {
let _ = write.write_all(&input).await; let _ = write.write_all(&input).await;

View file

@ -0,0 +1,119 @@
//! `Request::Transcribe`: the text of an audio file, from the swarm's
//! speech-to-text service.
//!
//! The harness holds the swarm queue connection, so an agent's tool call
//! reaches the service through it: read the file, publish the bytes on this
//! agent's request subject (`swarm_wire::stt`), and wait for the service's
//! JSON answer.
use std::path::Path;
use hive_agent_sock::Response;
use swarm_wire::stt;
/// The largest file sent. The queue carries 8 MiB a message; the service
/// refuses what it was configured not to take, so this only keeps the harness
/// from reading something huge into memory.
const MAX_BYTES: u64 = 6 * 1024 * 1024;
/// Ask the service for the text of the audio file at `path`.
pub async fn run(path: &str) -> Response {
let agent = crate::identity::label();
if agent.is_empty() {
return err("this agent has no label, so the service has no subject to answer on");
}
let bytes = match read_audio(Path::new(path)).await {
Ok(bytes) => bytes,
Err(message) => return err(message),
};
let client = crate::swarm_queue::client().await;
let request = client.request(stt::request_subject(&agent), bytes.into());
match tokio::time::timeout(stt::REPLY_DEADLINE, request).await {
Err(_) => err("the speech-to-text service did not answer in time"),
Ok(Err(e)) => err(request_failure(&e)),
Ok(Ok(reply)) => from_wire(&reply.payload),
}
}
/// The file's bytes, if it is a regular file small enough to send.
async fn read_audio(path: &Path) -> Result<Vec<u8>, String> {
let meta = tokio::fs::metadata(path)
.await
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
if !meta.is_file() {
return Err(format!("{} is not a regular file", path.display()));
}
if meta.len() > MAX_BYTES {
return Err(format!(
"{} is {} bytes, over the {MAX_BYTES} byte limit",
path.display(),
meta.len()
));
}
tokio::fs::read(path)
.await
.map_err(|e| format!("cannot read {}: {e}", path.display()))
}
/// What a failed publish-and-wait means to the agent.
fn request_failure(e: &async_nats::RequestError) -> &'static str {
if e.kind() == async_nats::client::RequestErrorKind::NoResponders {
"no speech-to-text service is running in this swarm"
} else {
"the speech-to-text service could not be reached"
}
}
/// The service's JSON answer as the socket's response.
fn from_wire(payload: &[u8]) -> Response {
match serde_json::from_slice::<stt::Response>(payload) {
Ok(stt::Response::Ok { text, cached }) => Response::Transcript { text, cached },
Ok(stt::Response::Failed { message }) => Response::Err { message },
Err(_) => err("the speech-to-text service sent an answer this harness does not understand"),
}
}
fn err(message: impl Into<String>) -> Response {
Response::Err {
message: message.into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_services_answers_map_to_socket_responses() {
let ok = br#"{"status":"ok","text":"hello","cached":true}"#;
assert!(matches!(
from_wire(ok),
Response::Transcript { ref text, cached: true } if text == "hello"
));
let failed = br#"{"status":"failed","message":"the audio could not be decoded"}"#;
assert!(matches!(
from_wire(failed),
Response::Err { ref message } if message == "the audio could not be decoded"
));
assert!(matches!(from_wire(b"nonsense"), Response::Err { .. }));
}
#[tokio::test]
async fn only_a_small_regular_file_is_read() {
let dir = tempfile::tempdir().unwrap();
let small = dir.path().join("memo.ogg");
std::fs::write(&small, b"OggS").unwrap();
assert_eq!(read_audio(&small).await.unwrap(), b"OggS");
let missing = read_audio(&dir.path().join("none.ogg")).await.unwrap_err();
assert!(missing.contains("cannot read"), "{missing}");
let directory = read_audio(dir.path()).await.unwrap_err();
assert!(directory.contains("not a regular file"), "{directory}");
let big = dir.path().join("big.ogg");
let file = std::fs::File::create(&big).unwrap();
file.set_len(MAX_BYTES + 1).unwrap();
let too_big = read_audio(&big).await.unwrap_err();
assert!(too_big.contains("over the"), "{too_big}");
}
}

View file

@ -525,7 +525,8 @@ impl MatrixMcp {
first. `download_file` downloads an attachment from a message event \ first. `download_file` downloads an attachment from a message event \
to a local file and returns its path; pair with `read_room` which \ to a local file and returns its path; pair with `read_room` which \
surfaces attachments as `[file: name]`, `[image: name]`, \ surfaces attachments as `[file: name]`, `[image: name]`, \
`[audio: name]`, `[video: name]` markers. \ `[audio: name]`, `[video: name]` markers. To hear a voice memo, \
`download_file` it and pass the returned path to the `transcribe_audio` tool. \
IMPORTANT: `send_message`, `send_dm`, `send_file`, `send_reply` are \ IMPORTANT: `send_message`, `send_dm`, `send_file`, `send_reply` are \
all rejected if the room has unread messages — always call `read_room` \ all rejected if the room has unread messages — always call `read_room` \
then `mark_read` on the latest event before sending to a room you \ then `mark_read` on the latest event before sending to a room you \

View file

@ -110,8 +110,16 @@ impl Permission {
/// holds `Inbox` (which only gates `get_loose_ends`/`cancel_loose_end`/ /// holds `Inbox` (which only gates `get_loose_ends`/`cancel_loose_end`/
/// `remind`), so an agent without `Inbox` must still be able to clear /// `remind`), so an agent without `Inbox` must still be able to clear
/// them. /// them.
pub const ALWAYS_ON_TOOLS: &'static [&'static str] = ///
&["set_status", "compact", "mark_todos_done"]; /// `transcribe_audio` too: it asks the swarm's speech-to-text service for
/// the text of an audio file the agent already holds, and reaches nothing
/// the agent could not already read.
pub const ALWAYS_ON_TOOLS: &'static [&'static str] = &[
"set_status",
"compact",
"mark_todos_done",
"transcribe_audio",
];
/// The Claude built-in tool names this permission enables. Only /// The Claude built-in tool names this permission enables. Only
/// `WebTools` returns a non-empty slice. /// `WebTools` returns a non-empty slice.