From 948071f1b502176d753fc81edf3d05f70d118b86 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 19:58:40 +0300 Subject: [PATCH 01/59] chore(vendor): add tinyagents submodule Add the tinyagents repository as a git submodule under vendor so it can be consumed alongside the existing vendored dependencies. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .gitmodules | 3 +++ vendor/tinyagents | 1 + 2 files changed, 4 insertions(+) create mode 160000 vendor/tinyagents diff --git a/.gitmodules b/.gitmodules index da09a74..659c9d4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,3 +2,6 @@ path = vendor/tinybus url = https://github.com/tinyhumansai/tinybus branch = main +[submodule "vendor/tinyagents"] + path = vendor/tinyagents + url = https://github.com/tinyhumansai/tinyagents diff --git a/vendor/tinyagents b/vendor/tinyagents new file mode 160000 index 0000000..3ed8fce --- /dev/null +++ b/vendor/tinyagents @@ -0,0 +1 @@ +Subproject commit 3ed8fce4862969b386080bcdbdd82e592b39ee55 From f62330304793bbd493d8659f76434bb7b9081679 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 19:59:29 +0300 Subject: [PATCH 02/59] docs: add live module runtime plan and spec Document the intended design and rollout plan for the live module runtime so the implementation work has a shared reference. Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/plans/live-module-runtime.md | 22 ++++++++++++++ docs/specs/live-module-runtime.md | 48 +++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 docs/plans/live-module-runtime.md create mode 100644 docs/specs/live-module-runtime.md diff --git a/docs/plans/live-module-runtime.md b/docs/plans/live-module-runtime.md new file mode 100644 index 0000000..8569711 --- /dev/null +++ b/docs/plans/live-module-runtime.md @@ -0,0 +1,22 @@ +# Live module runtime implementation + +Specification: ../specs/live-module-runtime.md. + +1. Add an actual bus missing-operation regression for live frame preparation. + Move portable wire vocabulary and event projection into the bus/library, + preserving serialized golden frames and persistence ordering. +2. Consume TinyAgentsLive from its canonical recorded source as a module + implementation dependency. Add a callback tool adapter with explicit host + context and current-policy enforcement at the host endpoint. +3. Add bounded random session handles and duplex open/send/read/close/shutdown + operations. Replay event batches until acknowledged and retain terminal + events until explicit release. +4. Verify a real compiled native module against a local provider and a + policy-aware callback; migrate websocket and lifecycle fixtures. +5. Add the portable relay coordinator with host timing/completion ports where + extraction preserves the existing acknowledgment and deferred delivery + semantics. Keep product model, progress filtering and delivery choices out + of the module. +6. Run owner workspace/module formatting, strict Clippy, tests, builds, + documentation, native artifact verification and per-file coverage. + Publish the owner prerequisite separately from released host adoption. diff --git a/docs/specs/live-module-runtime.md b/docs/specs/live-module-runtime.md new file mode 100644 index 0000000..b43ddad --- /dev/null +++ b/docs/specs/live-module-runtime.md @@ -0,0 +1,48 @@ +# Compiled live voice runtime + +The voice module owns portable live connection and duplex session machinery. +It uses TinyAgentsLive's existing provider/tool loop rather than implementing +another agent loop. A host supplies prepared provider credentials, an explicit +live configuration and tool declarations. Provider ticket minting, credential +lookup and provider selection remain host capabilities; the module never calls +a product backend to obtain them. + +The bus vocabulary preserves the existing live websocket JSON frames: start, +text, interrupt and stop inputs; ready, transcript, tool_started, tool_finished, +interrupted, turn_complete, error and closed outputs. Audio remains PCM16. +Final transcripts emit their client frame before a separate persistence +projection. The host applies that projection to its own transcript store. +Ready uses the provider's session id when present and the host fallback +otherwise. Terminal error closes emit error before closed. Unknown provider +events produce no invented client event. + +A live open request supplies an opaque tool callback endpoint and host context +token. The module builds a TinyAgentsLive harness over callback tools. The host +callback invokes its existing current policy-aware harness with its saved +approval, origin and thread context. A tool declaration grants no permission. +Neither this module nor the callback may fabricate an executor for an unknown +tool or restore a revoked tool. Provider cancellation and tool timeouts retain +the TinyAgentsLive semantics. + +Session handles are opaque and random. A bounded table owns sessions and their +tasks. Audio and serialized control requests have bounded sizes. Event reads +return ordered bounded batches with replay until acknowledgment, so a lost +reply does not erase transcript or terminal events. Stop asks the provider to +close and retains its terminal events; explicit close drops the session and +its tasks. Shutdown cancels and joins owned tasks. A disconnected client must +explicitly close its handle; capacity limits prevent leaked clients from +growing the table indefinitely. + +The realtime relay's local brain, progress selection, correlation admission, +spoken acknowledgment deadline and delivery policy remain host choices. +Portable turn preparation already uses PrepareRelayTurn. A reusable relay +coordinator must accept explicit host timing and completion callbacks and +preserve delta-before-done and deferred-result delivery ordering; it may not +select a product model, thread, acknowledgment phrase or destination itself. + +Tests must exercise actual compiled module operations, a local scripted +provider and a real bus callback. Cover initial frame validation, all existing +event projections, final transcript order, bounded input/event queues, retry +batches, interruptions, cancellation, callback failures, remote closure, +explicit close and shutdown. No test calls an external provider. Host adoption +waits for accepted source and an available released module. From a469077a1db7233ba2a9c055f5765c22eee98ee4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:00:27 +0300 Subject: [PATCH 03/59] test(service): cover live first frame metadata defaults Add a test asserting that parsing a live start frame keeps the default input sample rate and carries the thread and client identifiers through the reply. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyvoice-module/src/service/mod_tests.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/tinyvoice-module/src/service/mod_tests.rs b/crates/tinyvoice-module/src/service/mod_tests.rs index f4d7795..2d99d03 100644 --- a/crates/tinyvoice-module/src/service/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/mod_tests.rs @@ -1059,3 +1059,16 @@ async fn oversized_relay_request_returns_an_error_without_breaking_the_bus() { .unwrap(); assert_eq!(reply["prompt"], "hello"); } + +#[tokio::test] +async fn live_first_frame_parsing_preserves_default_rate_and_host_metadata() { + let proxy = connect().await.unwrap(); + let reply: serde_json::Value = proxy.call("PrepareLiveInput", serde_json::json!({ + "awaiting_start": true, + "frame": {"kind": "text", "text": "{\"type\":\"start\",\"thread_id\":\"thread\",\"client_id\":\"client\"}"} + })).await.unwrap(); + assert_eq!(reply["type"], "start"); + assert_eq!(reply["input_sample_rate"], 16000); + assert_eq!(reply["thread_id"], "thread"); + assert_eq!(reply["client_id"], "client"); +} From 80c479f6cf40b6b4087278b65fec47813574972f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:01:33 +0300 Subject: [PATCH 04/59] feat(bus): add live input control frame method Add a PrepareLiveInput bus method and bump the contract version to 1.7 so hosts can parse portable live control frames without applying host policy. A new LiveProtocol error reports frames that violate the control protocol. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyvoice-bus/src/lib.rs | 1 + crates/tinyvoice-bus/src/live.rs | 91 ++++++++++++++++++++++ crates/tinyvoice-bus/src/names/mod.rs | 3 + crates/tinyvoice-bus/src/version/mod.rs | 2 +- crates/tinyvoice-module/src/service/mod.rs | 8 ++ crates/tinyvoice/src/error/mod.rs | 4 + crates/tinyvoice/src/lib.rs | 1 + crates/tinyvoice/src/live/mod.rs | 48 ++++++++++++ 8 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 crates/tinyvoice-bus/src/live.rs create mode 100644 crates/tinyvoice/src/live/mod.rs diff --git a/crates/tinyvoice-bus/src/lib.rs b/crates/tinyvoice-bus/src/lib.rs index f1474ab..9a09d2c 100644 --- a/crates/tinyvoice-bus/src/lib.rs +++ b/crates/tinyvoice-bus/src/lib.rs @@ -13,6 +13,7 @@ pub mod capture; pub mod hotkey; pub mod intent; +pub mod live; pub mod names; pub mod relay; pub mod transcript; diff --git a/crates/tinyvoice-bus/src/live.rs b/crates/tinyvoice-bus/src/live.rs new file mode 100644 index 0000000..075e700 --- /dev/null +++ b/crates/tinyvoice-bus/src/live.rs @@ -0,0 +1,91 @@ +//! Portable live websocket control vocabulary. + +use serde::{Deserialize, Serialize}; + +/// A client control frame; audio travels separately as PCM16 bytes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ClientFrame { + /// First-frame connection request; identities remain opaque host metadata. + Start { + /// Optional host provider selection. + #[serde(default)] + provider: Option, + /// Optional host transcript identity. + #[serde(default)] + thread_id: Option, + /// Optional host approval-routing identity. + #[serde(default)] + client_id: Option, + /// Mono PCM16 input rate, defaulting to sixteen kHz. + #[serde(default = "default_input_rate")] + input_sample_rate: u32, + }, + /// Typed user input. + Text { + /// Original user text. + text: String, + }, + /// Interrupt the current provider reply. + Interrupt, + /// Ask the provider to close. + Stop, +} + +fn default_input_rate() -> u32 { + 16_000 +} + +/// Transport facts supplied to the module's control parser. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum TransportFrame { + /// UTF-8 JSON control input. + Text { + /// The original websocket text. + text: String, + }, + /// Mono PCM16 little-endian input. + Audio { + /// PCM16 bytes. + pcm16: Vec, + }, + /// The client disconnected. + Closed, + /// Ping, pong and other non-command frames. + Other, +} + +/// Parse one control frame under the current session phase. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PrepareLiveInputRequest { + /// Whether the connection still requires its first start frame. + pub awaiting_start: bool, + /// Original transport frame. + pub frame: TransportFrame, +} + +/// A parsed command; malformed later frames are deliberately ignored. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum PreparedLiveInput { + /// An accepted control command. + Control(ClientFrame), + /// A non-control transport action. + Transport(LiveTransportAction), +} + +/// A transport action requiring no JSON control parsing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum LiveTransportAction { + /// Forward input audio. + Audio { + /// Original PCM16 bytes. + pcm16: Vec, + }, + /// Close the provider sender. + Closed, + /// No provider action. + Ignore, +} diff --git a/crates/tinyvoice-bus/src/names/mod.rs b/crates/tinyvoice-bus/src/names/mod.rs index dab5956..566b985 100644 --- a/crates/tinyvoice-bus/src/names/mod.rs +++ b/crates/tinyvoice-bus/src/names/mod.rs @@ -8,6 +8,8 @@ pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinyvoice/Voice"; /// One constant per member of [`BUS_NAME`]. pub mod methods { + /// Parse an initial or later portable live control frame. + pub const PREPARE_LIVE_INPUT: &str = "PrepareLiveInput"; /// Extracts and classifies a provider-relayed conversational turn. pub const PREPARE_RELAY_TURN: &str = "PrepareRelayTurn"; /// Reserves a hotkey lease before native startup. @@ -104,6 +106,7 @@ pub const METHODS: &[&str] = &[ methods::LIST_INPUT_DEVICES, methods::PREPARE_CAPTURE, methods::PREPARE_FRAMES, + methods::PREPARE_LIVE_INPUT, methods::PREPARE_RELAY_TURN, methods::READ_AUDIO_OUTPUT, methods::RECORDING_CANCEL, diff --git a/crates/tinyvoice-bus/src/version/mod.rs b/crates/tinyvoice-bus/src/version/mod.rs index f141097..8b8025a 100644 --- a/crates/tinyvoice-bus/src/version/mod.rs +++ b/crates/tinyvoice-bus/src/version/mod.rs @@ -1,7 +1,7 @@ //! The `TinyVoice` `TinyBus` contract version and its binding rule. /// The wire contract version this crate defines. -pub const CONTRACT_VERSION: (u32, u32) = (1, 6); +pub const CONTRACT_VERSION: (u32, u32) = (1, 7); /// Returns whether a host using [`CONTRACT_VERSION`] can bind to `module`. /// diff --git a/crates/tinyvoice-module/src/service/mod.rs b/crates/tinyvoice-module/src/service/mod.rs index 7e83f25..831bf4f 100644 --- a/crates/tinyvoice-module/src/service/mod.rs +++ b/crates/tinyvoice-module/src/service/mod.rs @@ -187,6 +187,14 @@ fn to_json(value: &T) -> TinyBusResult { )] #[tinybus::interface(name = "ai.tinyhumans.tinyvoice.Voice")] impl VoiceService { + /// Parse portable live control frames without applying host policy. + async fn prepare_live_input( + &self, + request: tinyvoice_bus::live::PrepareLiveInputRequest, + ) -> TinyBusResult { + tinyvoice::live::prepare_input(request) + .map_err(|error| tinybus::Error::failed(error.to_string())) + } /// Prepare provider-relayed text without executing the host agent. async fn prepare_relay_turn( &self, diff --git a/crates/tinyvoice/src/error/mod.rs b/crates/tinyvoice/src/error/mod.rs index 07f549d..85be001 100644 --- a/crates/tinyvoice/src/error/mod.rs +++ b/crates/tinyvoice/src/error/mod.rs @@ -12,6 +12,10 @@ mod test; #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[non_exhaustive] pub enum Error { + /// A live connection's first frame did not satisfy its control protocol. + #[error("{0}")] + LiveProtocol(String), + /// A sample rate of zero was supplied. /// /// Every duration in this crate is derived by dividing by the sample rate, diff --git a/crates/tinyvoice/src/lib.rs b/crates/tinyvoice/src/lib.rs index d1eab83..98042d2 100644 --- a/crates/tinyvoice/src/lib.rs +++ b/crates/tinyvoice/src/lib.rs @@ -60,6 +60,7 @@ pub mod capture; #[cfg(feature = "hotkey")] pub mod hotkey; pub mod intent; +pub mod live; pub mod relay; pub mod transcript; pub mod vad; diff --git a/crates/tinyvoice/src/live/mod.rs b/crates/tinyvoice/src/live/mod.rs new file mode 100644 index 0000000..1240472 --- /dev/null +++ b/crates/tinyvoice/src/live/mod.rs @@ -0,0 +1,48 @@ +//! Portable live frame parsing without sockets, identity or provider policy. + +use tinyvoice_bus::live::{ + ClientFrame, LiveTransportAction, PrepareLiveInputRequest, PreparedLiveInput, TransportFrame, +}; + +use crate::{Error, Result}; + +/// Parse the initial start frame or a later live command. +/// +/// Initial errors preserve the wire-visible diagnostic. Later malformed and +/// duplicate start frames are ignored, matching the established duplex protocol. +/// This function does not validate provider choice, identity or sample rate. +/// +/// # Errors +/// +/// A missing, malformed or non-start initial text frame. +pub fn prepare_input(request: PrepareLiveInputRequest) -> Result { + if request.awaiting_start { + let TransportFrame::Text { text } = request.frame else { + return Err(Error::LiveProtocol( + "the first frame must be a start frame".into(), + )); + }; + let frame = serde_json::from_str::(&text) + .map_err(|error| Error::LiveProtocol(format!("bad start frame: {error}")))?; + return if matches!(frame, ClientFrame::Start { .. }) { + Ok(PreparedLiveInput::Control(frame)) + } else { + Err(Error::LiveProtocol( + "the first frame must be a start frame".into(), + )) + }; + } + Ok(match request.frame { + TransportFrame::Text { text } => match serde_json::from_str::(&text) { + Ok(frame @ (ClientFrame::Text { .. } | ClientFrame::Interrupt | ClientFrame::Stop)) => { + PreparedLiveInput::Control(frame) + } + _ => PreparedLiveInput::Transport(LiveTransportAction::Ignore), + }, + TransportFrame::Audio { pcm16 } => { + PreparedLiveInput::Transport(LiveTransportAction::Audio { pcm16 }) + } + TransportFrame::Closed => PreparedLiveInput::Transport(LiveTransportAction::Closed), + TransportFrame::Other => PreparedLiveInput::Transport(LiveTransportAction::Ignore), + }) +} From 99c9f8e1fcc2aeffef60a665ddb3e9dcb9c01d08 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:02:59 +0300 Subject: [PATCH 05/59] chore: files changed crates/tinyvoice-module/Cargo.lock,crates/tinyvoice-module/Cargo.toml,crates/ti Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyvoice-module/Cargo.lock | 1552 ++++++++++++++++++++++-- crates/tinyvoice-module/Cargo.toml | 8 + crates/tinyvoice/src/live/mod.rs | 4 + crates/tinyvoice/src/live/mod_tests.rs | 101 ++ 4 files changed, 1583 insertions(+), 82 deletions(-) create mode 100644 crates/tinyvoice/src/live/mod_tests.rs diff --git a/crates/tinyvoice-module/Cargo.lock b/crates/tinyvoice-module/Cargo.lock index 40e6201..2193bf1 100644 --- a/crates/tinyvoice-module/Cargo.lock +++ b/crates/tinyvoice-module/Cargo.lock @@ -8,6 +8,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "alsa" version = "0.11.0" @@ -30,6 +39,21 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "arbitrary" version = "1.4.2" @@ -50,6 +74,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" @@ -95,6 +125,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -132,6 +171,37 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "cocoa" version = "0.22.0" @@ -157,6 +227,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.7.0" @@ -266,6 +342,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.1" @@ -291,12 +376,27 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "dasp_sample" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -314,8 +414,40 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", ] [[package]] @@ -387,6 +519,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foreign-types" version = "0.3.2" @@ -402,12 +540,80 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -420,8 +626,13 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -453,8 +664,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", ] [[package]] @@ -464,8 +689,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", - "r-efi", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] @@ -485,114 +732,362 @@ dependencies = [ ] [[package]] -name = "httparse" -version = "1.10.1" +name = "http-body" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] [[package]] -name = "indexmap" -version = "2.14.0" +name = "http-body-util" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ - "equivalent", - "hashbrown", + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "httparse" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] -name = "jni" -version = "0.22.4" +name = "httpdate" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys 0.4.1", - "log", - "simd_cesu8", - "thiserror 2.0.20", - "walkdir", - "windows-link", -] +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] -name = "jni-macros" -version = "0.22.4" +name = "hybrid-array" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn 2.0.119", + "typenum", ] [[package]] -name = "jni-sys" -version = "0.3.1" +name = "hyper" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ - "jni-sys 0.4.1", + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", ] [[package]] -name = "jni-sys" -version = "0.4.1" +name = "hyper-rustls" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ - "jni-sys-macros", + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.9", ] [[package]] -name = "jni-sys-macros" -version = "0.4.1" +name = "hyper-util" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "quote", - "syn 2.0.119", + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", ] [[package]] -name = "js-sys" -version = "0.3.106" +name = "iana-time-zone" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ - "cfg-if", - "futures-util", + "android_system_properties", + "core-foundation-sys 0.8.7", + "iana-time-zone-haiku", + "js-sys", + "log", "wasm-bindgen", + "windows-core", ] [[package]] -name = "lazy_static" -version = "1.5.1" +name = "iana-time-zone-haiku" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" - -[[package]] +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "lock_api" version = "0.4.14" @@ -608,6 +1103,12 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "mach2" version = "0.6.0" @@ -639,6 +1140,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "ndk" version = "0.9.0" @@ -815,6 +1327,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking_lot" version = "0.12.5" @@ -856,13 +1374,31 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.15+spec-1.1.0", ] [[package]] @@ -874,6 +1410,62 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -883,12 +1475,73 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rdev" version = "0.5.3" @@ -914,6 +1567,87 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots 1.0.9", +] + [[package]] name = "ring" version = "0.17.14" @@ -928,6 +1662,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -971,6 +1711,7 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] @@ -991,6 +1732,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -1055,6 +1802,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -1064,6 +1820,29 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1071,8 +1850,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -1081,6 +1871,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -1115,6 +1915,22 @@ version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "subtle" version = "2.6.1" @@ -1143,6 +1959,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "tar" version = "0.4.46" @@ -1207,6 +2043,60 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "tinyagents-definition" +version = "2.1.4" +dependencies = [ + "anyhow", + "async-trait", + "serde", + "serde_json", + "tinytools 0.5.0", + "toml 0.8.23", + "tracing", +] + +[[package]] +name = "tinyagents-harness" +version = "2.1.4" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "chrono", + "dirs", + "futures", + "regex", + "reqwest", + "rustix", + "serde", + "serde_json", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.20", + "tinyagents-definition", + "tinyinference-core", + "tinyinference-embeddings", + "tinyinference-llm", + "tinytools 0.5.0", + "tinytools-agent 0.5.0", + "tokio", + "tracing", + "uuid", + "wait-timeout", +] + +[[package]] +name = "tinyagents-live" +version = "2.1.4" +dependencies = [ + "serde_json", + "tinyagents-harness", + "tinyliveagents", + "tokio", + "tracing", +] + [[package]] name = "tinybus" version = "0.1.2" @@ -1215,39 +2105,160 @@ dependencies = [ "flate2", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tar", "tempfile", "thiserror 2.0.20", "tinybus-macros", "tokio", - "toml", + "toml 1.1.6+spec-1.1.0", "tracing", "ureq", "zip", ] [[package]] -name = "tinybus-macros" -version = "0.1.2" +name = "tinybus-macros" +version = "0.1.2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tinybus-module" +version = "0.1.2" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "tinybus", + "tokio", + "tracing", +] + +[[package]] +name = "tinyinference-core" +version = "0.3.1" +dependencies = [ + "anyhow", + "httpdate", + "regex", + "url", +] + +[[package]] +name = "tinyinference-embeddings" +version = "0.3.1" +dependencies = [ + "async-trait", + "once_cell", + "regex", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.20", + "tinyinference-core", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "tinyinference-llm" +version = "0.3.1" +dependencies = [ + "async-trait", + "base64 0.23.1", + "bytes", + "futures", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.20", + "tinyinference-core", + "tinytools-agent 0.5.0 (git+https://github.com/tinyhumansai/tinytools?rev=bd60b9b52f1998b4483b9cf107a72d3338e672f8)", + "tokio", + "tracing", +] + +[[package]] +name = "tinyliveagents" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "futures-util", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tokio-tungstenite", + "tracing", + "url", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinytools" +version = "0.5.0" +dependencies = [ + "anyhow", + "async-trait", + "serde", + "serde_json", +] + +[[package]] +name = "tinytools" +version = "0.5.0" +source = "git+https://github.com/tinyhumansai/tinytools?rev=bd60b9b52f1998b4483b9cf107a72d3338e672f8#bd60b9b52f1998b4483b9cf107a72d3338e672f8" +dependencies = [ + "anyhow", + "async-trait", + "serde", + "serde_json", +] + +[[package]] +name = "tinytools-agent" +version = "0.5.0" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "regex", + "serde", + "serde_json", + "tinytools 0.5.0", ] [[package]] -name = "tinybus-module" -version = "0.1.2" +name = "tinytools-agent" +version = "0.5.0" +source = "git+https://github.com/tinyhumansai/tinytools?rev=bd60b9b52f1998b4483b9cf107a72d3338e672f8#bd60b9b52f1998b4483b9cf107a72d3338e672f8" dependencies = [ - "async-trait", + "regex", "serde", "serde_json", - "tinybus", - "tokio", - "tracing", + "tinytools 0.5.0 (git+https://github.com/tinyhumansai/tinytools?rev=bd60b9b52f1998b4483b9cf107a72d3338e672f8)", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tinyvoice" version = "0.1.16" @@ -1282,13 +2293,18 @@ dependencies = [ name = "tinyvoice-module" version = "0.1.16" dependencies = [ + "anyhow", + "async-trait", "base64 0.22.1", "getrandom 0.4.3", "rustix", "serde", "serde_json", + "tinyagents-harness", + "tinyagents-live", "tinybus", "tinybus-module", + "tinytools 0.5.0", "tinyvoice", "tinyvoice-bus", "tinyvoice-hotkey-win", @@ -1303,8 +2319,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", + "libc", + "mio", "pin-project-lite", + "signal-hook-registry", + "socket2", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] @@ -1318,6 +2339,58 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + [[package]] name = "toml" version = "1.1.6+spec-1.1.0" @@ -1326,11 +2399,20 @@ checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", ] [[package]] @@ -1342,6 +2424,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.15+spec-1.1.0" @@ -1349,9 +2445,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.4", ] [[package]] @@ -1360,15 +2456,66 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow", + "winnow 1.0.4", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -1400,6 +2547,30 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.20", +] + [[package]] name = "typenum" version = "1.20.1" @@ -1432,7 +2603,7 @@ dependencies = [ "rustls-pki-types", "ureq-proto", "utf8-zero", - "webpki-roots", + "webpki-roots 1.0.9", ] [[package]] @@ -1447,18 +2618,56 @@ dependencies = [ "log", ] +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "utf8-zero" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -1469,12 +2678,30 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.129" @@ -1488,6 +2715,17 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.129" @@ -1520,6 +2758,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.106" @@ -1530,6 +2781,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -1762,6 +3032,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.4" @@ -1771,6 +3050,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "x11" version = "2.21.0" @@ -1808,12 +3099,109 @@ dependencies = [ "rustix", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", + "synstructure", +] + [[package]] name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "zip" version = "2.4.2" diff --git a/crates/tinyvoice-module/Cargo.toml b/crates/tinyvoice-module/Cargo.toml index 0b485b8..2a30279 100644 --- a/crates/tinyvoice-module/Cargo.toml +++ b/crates/tinyvoice-module/Cargo.toml @@ -19,6 +19,14 @@ static-link = [] native-hotkeys = [] [dependencies] +# Native generic provider/tool-loop execution belongs inside the compiled module. +# The recorded canonical TinyAgents submodule supplies its own recursive libraries. +tinyagents-live = { path = "../../vendor/tinyagents/crates/tinyagents-live" } +tinyagents-harness = { path = "../../vendor/tinyagents/crates/tinyagents-harness" } +tinytools = { path = "../../vendor/tinyagents/vendor/tinytools/crates/tinytools" } +async-trait = "0.1" +anyhow = "1" + # Opaque native recording/output leases must not rebind after a module reload. getrandom = "0.4" diff --git a/crates/tinyvoice/src/live/mod.rs b/crates/tinyvoice/src/live/mod.rs index 1240472..a0fedc4 100644 --- a/crates/tinyvoice/src/live/mod.rs +++ b/crates/tinyvoice/src/live/mod.rs @@ -46,3 +46,7 @@ pub fn prepare_input(request: PrepareLiveInputRequest) -> Result PreparedLiveInput::Transport(LiveTransportAction::Ignore), }) } + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/crates/tinyvoice/src/live/mod_tests.rs b/crates/tinyvoice/src/live/mod_tests.rs new file mode 100644 index 0000000..b586552 --- /dev/null +++ b/crates/tinyvoice/src/live/mod_tests.rs @@ -0,0 +1,101 @@ +use super::*; + +#[test] +fn only_a_start_text_frame_can_open_the_live_protocol() -> Result<()> { + for frame in [ + TransportFrame::Audio { pcm16: vec![0, 1] }, + TransportFrame::Closed, + TransportFrame::Other, + TransportFrame::Text { + text: r#"{"type":"stop"}"#.into(), + }, + ] { + assert_eq!( + prepare_input(PrepareLiveInputRequest { + awaiting_start: true, + frame + }), + Err(Error::LiveProtocol( + "the first frame must be a start frame".into() + )) + ); + } + let malformed = prepare_input(PrepareLiveInputRequest { + awaiting_start: true, + frame: TransportFrame::Text { + text: "invalid".into(), + }, + }); + assert!( + matches!(malformed, Err(Error::LiveProtocol(message)) if message.starts_with("bad start frame: ")) + ); + let started = prepare_input(PrepareLiveInputRequest { + awaiting_start: true, + frame: TransportFrame::Text { + text: r#"{"type":"start","provider":"custom","input_sample_rate":0}"#.into(), + }, + })?; + assert!( + matches!(started, PreparedLiveInput::Control(ClientFrame::Start { + provider: Some(provider), input_sample_rate: 0, .. + }) if provider == "custom") + ); + Ok(()) +} + +#[test] +fn later_control_frames_keep_text_and_ignore_duplicate_start_or_bad_json() -> Result<()> { + for (text, expected) in [ + ( + r#"{"type":"text","text":" hello 世界 "}"#, + ClientFrame::Text { + text: " hello 世界 ".into(), + }, + ), + (r#"{"type":"interrupt"}"#, ClientFrame::Interrupt), + (r#"{"type":"stop"}"#, ClientFrame::Stop), + ] { + assert_eq!( + prepare_input(PrepareLiveInputRequest { + awaiting_start: false, + frame: TransportFrame::Text { text: text.into() }, + })?, + PreparedLiveInput::Control(expected) + ); + } + for text in [r#"{"type":"start"}"#, "invalid", r#"{"type":"unknown"}"#] { + assert_eq!( + prepare_input(PrepareLiveInputRequest { + awaiting_start: false, + frame: TransportFrame::Text { text: text.into() }, + })?, + PreparedLiveInput::Transport(LiveTransportAction::Ignore) + ); + } + Ok(()) +} + +#[test] +fn audio_disconnect_and_non_command_frames_preserve_transport_actions() -> Result<()> { + for (frame, expected) in [ + ( + TransportFrame::Audio { + pcm16: vec![0, 255], + }, + LiveTransportAction::Audio { + pcm16: vec![0, 255], + }, + ), + (TransportFrame::Closed, LiveTransportAction::Closed), + (TransportFrame::Other, LiveTransportAction::Ignore), + ] { + assert_eq!( + prepare_input(PrepareLiveInputRequest { + awaiting_start: false, + frame + })?, + PreparedLiveInput::Transport(expected) + ); + } + Ok(()) +} From a5a1002bb8c3a449814fa65d2bfc226031a3cf4d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:07:28 +0300 Subject: [PATCH 06/59] refactor(bus): move live control vocabulary into a live module The live websocket control types now live under a live submodule instead of a single file, and the module service advertises the PrepareLiveInput method so hosts can reach it. The verify_module example exercises a start frame to confirm the default sixteen kHz input rate is preserved. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/{live.rs => live/mod.rs} | 75 ++++++++ crates/tinyvoice-bus/src/live/session.rs | 162 ++++++++++++++++++ .../examples/verify_module.rs | 20 +++ crates/tinyvoice-module/src/service/mod.rs | 1 + 4 files changed, 258 insertions(+) rename crates/tinyvoice-bus/src/{live.rs => live/mod.rs} (55%) create mode 100644 crates/tinyvoice-bus/src/live/session.rs diff --git a/crates/tinyvoice-bus/src/live.rs b/crates/tinyvoice-bus/src/live/mod.rs similarity index 55% rename from crates/tinyvoice-bus/src/live.rs rename to crates/tinyvoice-bus/src/live/mod.rs index 075e700..b34f8d8 100644 --- a/crates/tinyvoice-bus/src/live.rs +++ b/crates/tinyvoice-bus/src/live/mod.rs @@ -1,5 +1,7 @@ //! Portable live websocket control vocabulary. +pub mod session; + use serde::{Deserialize, Serialize}; /// A client control frame; audio travels separately as PCM16 bytes. @@ -89,3 +91,76 @@ pub enum LiveTransportAction { /// No provider action. Ignore, } + +/// Who a final transcript belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TranscriptRole { + /// Spoken or typed user input. + User, + /// Spoken assistant output. + Agent, +} + +/// A live server JSON frame; audio remains a separate binary transport action. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ServerFrame { + /// The provider connected. + Ready { + /// Provider session identity or the host fallback. + session_id: Option, + /// Host-selected provider identifier. + provider: String, + /// Mono PCM16 output sample rate. + output_sample_rate: u32, + /// Opaque host transcript identity. + thread_id: Option, + }, + /// Partial or final speech transcript. + Transcript { + /// User or agent speech. + role: TranscriptRole, + /// Original transcript text. + text: String, + /// Whether this completes the utterance. + #[serde(rename = "final")] + is_final: bool, + }, + /// A provider tool call entered the harness. + ToolStarted { + /// Provider call identity. + call_id: String, + /// Declared tool name. + name: String, + }, + /// A tool call completed or was cancelled. + ToolFinished { + /// Provider call identity. + call_id: String, + /// Declared tool name. + name: String, + /// Whether the result was successful. + ok: bool, + /// Whether the provider cancelled the result. + cancelled: bool, + }, + /// Provider playback was interrupted. + Interrupted, + /// The provider completed its turn. + TurnComplete, + /// A provider or connection error. + Error { + /// Stable portable error classification. + code: String, + /// Provider diagnostic. + message: String, + /// Whether the provider is terminating. + fatal: bool, + }, + /// The provider connection ended. + Closed { + /// Established close diagnostic. + reason: String, + }, +} diff --git a/crates/tinyvoice-bus/src/live/session.rs b/crates/tinyvoice-bus/src/live/session.rs new file mode 100644 index 0000000..785c9ce --- /dev/null +++ b/crates/tinyvoice-bus/src/live/session.rs @@ -0,0 +1,162 @@ +//! Bounded compiled live session and host tool callback vocabulary. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::{ServerFrame, TranscriptRole}; + +/// Fixed interface served by a host's policy-aware tool callback. +pub const TOOL_CALLBACK_INTERFACE: &str = "ai.tinyhumans.tinyvoice.LiveTools"; +/// The only method invoked on a live tool callback endpoint. +pub const TOOL_CALLBACK_METHOD: &str = "ExecuteLiveTool"; + +/// An opaque live lease, reserved before provider connection begins. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct LiveHandle { + /// Unpredictable module-generated lease identity. + pub id: u128, +} + +/// Connection state, independent of host permissions or UI. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LiveState { + /// Reserved before provider startup. + Reserved, + /// Provider connection is still opening. + Starting, + /// Ready for commands. + Running, + /// Terminal events remain readable. + Closed, +} + +/// Result of reserving or changing a session. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LiveStatus { + /// The referenced lease. + pub handle: LiveHandle, + /// Current lifecycle state. + pub state: LiveState, +} + +/// Ordered effects projected from native provider events. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum LiveOutput { + /// Send a JSON frame to the client. + Json { + /// Stable client-facing frame. + frame: ServerFrame, + }, + /// Send PCM16 bytes as a binary client frame. + Audio { + /// Base64 transports bytes within the bounded JSON bus frame. + pcm16_base64: String, + }, + /// Persist final speech after its JSON frame has been delivered. + Persist { + /// Transcript speaker. + role: TranscriptRole, + /// Original transcript text. + text: String, + }, +} + +/// Replayable bounded event batch. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LiveBatch { + /// Lease being read. + pub handle: LiveHandle, + /// Monotonic batch identity, retained until acknowledged. + pub batch: u64, + /// Current lifecycle state. + pub state: LiveState, + /// Ordered effects; final transcripts retain frame-before-persist order. + pub outputs: Vec, +} + +/// Read a session, optionally acknowledging the preceding delivered batch. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LiveReadRequest { + /// Opaque lease identity. + pub handle: LiveHandle, + /// The last batch fully applied by the host. + pub acknowledged_batch: Option, +} + +/// A host-approved declaration; execution still consults current host policy. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LiveToolDeclaration { + /// Canonical tool name. + pub name: String, + /// Provider-visible description. + pub description: String, + /// Provider-visible JSON argument schema. + pub parameters: Value, +} + +/// Port into the host's saved policy and approval context. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LiveToolPort { + /// Host bus name. + pub bus_name: String, + /// Dedicated host callback object. + pub object_path: String, + /// Opaque authority token checked by the host, never model arguments. + pub context_token: String, +} + +impl std::fmt::Debug for LiveToolPort { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LiveToolPort") + .field("bus_name", &self.bus_name) + .field("object_path", &self.object_path) + .field("context_token", &"[redacted]") + .finish() + } +} + +/// One call to the host's current policy-aware harness. +#[derive(Clone, PartialEq, Serialize, Deserialize)] +pub struct ExecuteLiveToolRequest { + /// Saved host authority, checked independently of the declaration. + pub context_token: String, + /// Admitted provider call identity. + pub call_id: String, + /// Declared tool name. + pub name: String, + /// Provider-supplied arguments. + pub arguments: Value, + /// Requested rendering; the host still owns output screening. + pub prefer_markdown: bool, +} + +impl std::fmt::Debug for ExecuteLiveToolRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ExecuteLiveToolRequest") + .field("context_token", &"[redacted]") + .field("call_id", &self.call_id) + .field("name", &self.name) + .field("arguments", &"[redacted]") + .field("prefer_markdown", &self.prefer_markdown) + .finish() + } +} + +/// A canonical tool result serialized mechanically by the host. +/// +/// It uses TinyTools' complete result JSON, including its reported-error flag. +/// The module decodes that existing vocabulary rather than inventing success. +#[derive(Clone, PartialEq, Serialize, Deserialize)] +pub struct ExecuteLiveToolReply { + /// Serialized canonical tool result. + pub result: Value, +} + +impl std::fmt::Debug for ExecuteLiveToolReply { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ExecuteLiveToolReply") + .finish_non_exhaustive() + } +} diff --git a/crates/tinyvoice-module/examples/verify_module.rs b/crates/tinyvoice-module/examples/verify_module.rs index 8362fa9..19a92a7 100644 --- a/crates/tinyvoice-module/examples/verify_module.rs +++ b/crates/tinyvoice-module/examples/verify_module.rs @@ -68,6 +68,26 @@ async fn main() -> Result<(), Box> { return Err(io::Error::other("capture manifest differs from contract").into()); } verify_relay_turn(&proxy).await?; + let start: tinyvoice_bus::live::PreparedLiveInput = proxy + .call( + tinyvoice_bus::names::methods::PREPARE_LIVE_INPUT, + (tinyvoice_bus::live::PrepareLiveInputRequest { + awaiting_start: true, + frame: tinyvoice_bus::live::TransportFrame::Text { + text: r#"{"type":"start","client_id":"client"}"#.into(), + }, + },), + ) + .await?; + if !matches!( + start, + tinyvoice_bus::live::PreparedLiveInput::Control(tinyvoice_bus::live::ClientFrame::Start { + input_sample_rate: 16_000, + .. + }) + ) { + return Err(io::Error::other("module changed default live start framing").into()); + } verify_recording_summary(&proxy).await?; // Default-denied permission must reject before any native device is opened. let denied: tinyvoice_bus::capture::CaptureResult = diff --git a/crates/tinyvoice-module/src/service/mod.rs b/crates/tinyvoice-module/src/service/mod.rs index 831bf4f..8948266 100644 --- a/crates/tinyvoice-module/src/service/mod.rs +++ b/crates/tinyvoice-module/src/service/mod.rs @@ -696,6 +696,7 @@ tinybus_module::module_export_optional_static! { "VadClose", "PrepareFrames", "PrepareRelayTurn", + "PrepareLiveInput", "FrameEnergies", "CaptureStart", "CapturePoll", From 94bdc77dd72af914dff064a8c8aad509b335e0c3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:08:38 +0300 Subject: [PATCH 07/59] refactor(tinyvoice-module): extract live input verification into a helper The verify_module example now calls a dedicated verify_live_input function instead of inlining the live start framing check, and the service module registers the new live submodule. This keeps the example's main flow readable and gives the live projection code a place to live. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../examples/verify_module.rs | 45 ++--- .../tinyvoice-module/src/service/live/mod.rs | 3 + .../src/service/live/projection.rs | 121 +++++++++++++ .../src/service/live/projection_tests.rs | 167 ++++++++++++++++++ crates/tinyvoice-module/src/service/mod.rs | 1 + 5 files changed, 317 insertions(+), 20 deletions(-) create mode 100644 crates/tinyvoice-module/src/service/live/mod.rs create mode 100644 crates/tinyvoice-module/src/service/live/projection.rs create mode 100644 crates/tinyvoice-module/src/service/live/projection_tests.rs diff --git a/crates/tinyvoice-module/examples/verify_module.rs b/crates/tinyvoice-module/examples/verify_module.rs index 19a92a7..9241c92 100644 --- a/crates/tinyvoice-module/examples/verify_module.rs +++ b/crates/tinyvoice-module/examples/verify_module.rs @@ -68,26 +68,7 @@ async fn main() -> Result<(), Box> { return Err(io::Error::other("capture manifest differs from contract").into()); } verify_relay_turn(&proxy).await?; - let start: tinyvoice_bus::live::PreparedLiveInput = proxy - .call( - tinyvoice_bus::names::methods::PREPARE_LIVE_INPUT, - (tinyvoice_bus::live::PrepareLiveInputRequest { - awaiting_start: true, - frame: tinyvoice_bus::live::TransportFrame::Text { - text: r#"{"type":"start","client_id":"client"}"#.into(), - }, - },), - ) - .await?; - if !matches!( - start, - tinyvoice_bus::live::PreparedLiveInput::Control(tinyvoice_bus::live::ClientFrame::Start { - input_sample_rate: 16_000, - .. - }) - ) { - return Err(io::Error::other("module changed default live start framing").into()); - } + verify_live_input(&proxy).await?; verify_recording_summary(&proxy).await?; // Default-denied permission must reject before any native device is opened. let denied: tinyvoice_bus::capture::CaptureResult = @@ -395,3 +376,27 @@ async fn verify_relay_turn(proxy: &tinybus::Proxy) -> Result<(), Box Result<(), Box> { + let start: tinyvoice_bus::live::PreparedLiveInput = proxy + .call( + tinyvoice_bus::names::methods::PREPARE_LIVE_INPUT, + (tinyvoice_bus::live::PrepareLiveInputRequest { + awaiting_start: true, + frame: tinyvoice_bus::live::TransportFrame::Text { + text: r#"{"type":"start","client_id":"client"}"#.into(), + }, + },), + ) + .await?; + if !matches!( + start, + tinyvoice_bus::live::PreparedLiveInput::Control(tinyvoice_bus::live::ClientFrame::Start { + input_sample_rate: 16_000, + .. + }) + ) { + return Err(io::Error::other("module changed default live start framing").into()); + } + Ok(()) +} diff --git a/crates/tinyvoice-module/src/service/live/mod.rs b/crates/tinyvoice-module/src/service/live/mod.rs new file mode 100644 index 0000000..40729db --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/mod.rs @@ -0,0 +1,3 @@ +//! Compiled live provider runtime and bounded session lifecycle. + +mod projection; diff --git a/crates/tinyvoice-module/src/service/live/projection.rs b/crates/tinyvoice-module/src/service/live/projection.rs new file mode 100644 index 0000000..031eccb --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/projection.rs @@ -0,0 +1,121 @@ +//! Provider-event projection preserving client wire and transcript ordering. + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use tinyagents_live::{ + LiveAgentEvent, + tinyliveagents::{CloseReason, Error, LiveEvent}, +}; +use tinyvoice_bus::live::session::LiveOutput; +use tinyvoice_bus::live::{ServerFrame, TranscriptRole}; + +/// Convert one native event without applying host persistence or delivery policy. +pub(super) fn map_event( + event: LiveAgentEvent, + session_id: &str, + provider: &str, + thread_id: &str, +) -> Vec { + match event { + LiveAgentEvent::ToolStarted { call_id, name } => { + vec![json(ServerFrame::ToolStarted { call_id, name })] + } + LiveAgentEvent::ToolFinished { + call_id, + name, + is_error, + cancelled, + .. + } => vec![json(ServerFrame::ToolFinished { + call_id, + name, + ok: !is_error, + cancelled, + })], + LiveAgentEvent::Live(event) => match event { + LiveEvent::Ready(info) => vec![json(ServerFrame::Ready { + session_id: Some(info.session_id.unwrap_or_else(|| session_id.to_string())), + provider: provider.to_string(), + output_sample_rate: info.output_format.sample_rate, + thread_id: Some(thread_id.to_string()), + })], + LiveEvent::Audio(pcm) => vec![LiveOutput::Audio { + pcm16_base64: BASE64.encode(pcm), + }], + LiveEvent::InputTranscript { text, is_final } => { + transcript(TranscriptRole::User, text, is_final) + } + LiveEvent::OutputTranscript { text, is_final } => { + transcript(TranscriptRole::Agent, text, is_final) + } + LiveEvent::Interrupted => vec![json(ServerFrame::Interrupted)], + LiveEvent::TurnComplete { .. } => vec![json(ServerFrame::TurnComplete)], + LiveEvent::Error { error, fatal } => { + let error = project_error(error); + vec![json(ServerFrame::Error { + code: error.0.to_string(), + message: error.1, + fatal, + })] + } + LiveEvent::Closed(reason) => { + let mut out = Vec::new(); + let reason = match reason { + CloseReason::Client => "client".to_string(), + CloseReason::Remote { code, reason } => match code { + Some(code) => format!("remote ({code}) {reason}").trim().to_string(), + None => "remote".to_string(), + }, + CloseReason::Error(error) => { + let error = project_error(error); + out.push(json(ServerFrame::Error { + code: error.0.to_string(), + message: error.1, + fatal: true, + })); + "error".to_string() + } + _ => "closed".to_string(), + }; + out.push(json(ServerFrame::Closed { reason })); + out + } + // Tool calls are reported through ToolStarted/ToolFinished; the + // rest has no client-visible meaning. + _ => Vec::new(), + }, + } +} + +fn transcript(role: TranscriptRole, text: String, is_final: bool) -> Vec { + let mut out = vec![json(ServerFrame::Transcript { + role, + text: text.clone(), + is_final, + })]; + if is_final { + out.push(LiveOutput::Persist { role, text }); + } + out +} + +fn json(frame: ServerFrame) -> LiveOutput { + LiveOutput::Json { frame } +} + +fn project_error(error: Error) -> (&'static str, String) { + let code = match &error { + Error::Unauthorized => "unauthorized", + Error::InsufficientCredits => "insufficient_credits", + Error::RateLimited => "rate_limited", + Error::Timeout => "timeout", + Error::InvalidConfig(_) => "invalid_request", + Error::Connect(_) => "connect", + _ => "provider", + }; + (code, error.to_string()) +} + +#[cfg(test)] +#[path = "projection_tests.rs"] +mod tests; diff --git a/crates/tinyvoice-module/src/service/live/projection_tests.rs b/crates/tinyvoice-module/src/service/live/projection_tests.rs new file mode 100644 index 0000000..96a553b --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/projection_tests.rs @@ -0,0 +1,167 @@ +use super::*; +use tinyagents_live::tinyliveagents::{AudioFormat, Error as LiveError, SessionInfo, ToolCall}; + +fn map(event: LiveEvent) -> Vec { + map_event(LiveAgentEvent::Live(event), "sess", "sarvam", "thread") +} + +#[test] +fn ready_uses_the_provider_session_id_or_ours() { + let info = |id: Option<&str>| SessionInfo { + provider: "sarvam".into(), + session_id: id.map(str::to_string), + model: None, + input_format: AudioFormat::default(), + output_format: AudioFormat::pcm16(24_000), + }; + assert_eq!( + map(LiveEvent::Ready(info(None))), + vec![json(ServerFrame::Ready { + session_id: Some("sess".into()), + provider: "sarvam".into(), + output_sample_rate: 24_000, + thread_id: Some("thread".into()) + })] + ); + let json(ServerFrame::Ready { session_id, .. }) = &map(LiveEvent::Ready(info(Some("p"))))[0] + else { + assert!(false, "expected ready"); + return; + }; + assert_eq!(session_id.as_deref(), Some("p")); +} + +#[test] +fn finals_are_persisted_partials_are_not() { + assert_eq!( + map(LiveEvent::InputTranscript { + text: "wha".into(), + is_final: false + }), + vec![json(ServerFrame::Transcript { + role: TranscriptRole::User, + text: "wha".into(), + is_final: false + })] + ); + let out = map(LiveEvent::OutputTranscript { + text: "noon".into(), + is_final: true, + }); + assert_eq!(out.len(), 2); + assert_eq!( + out[1], + LiveOutput::Persist { + role: TranscriptRole::Agent, + text: "noon".into() + } + ); +} + +#[test] +fn maps_audio_turns_errors_and_closes() { + assert_eq!( + map(LiveEvent::Audio(vec![1, 2].into())), + vec![LiveOutput::Audio { + pcm16_base64: "AQI=".into() + }] + ); + assert_eq!( + map(LiveEvent::Interrupted), + vec![json(ServerFrame::Interrupted)] + ); + assert_eq!( + map(LiveEvent::TurnComplete { usage: None }), + vec![json(ServerFrame::TurnComplete)] + ); + assert_eq!( + map(LiveEvent::Error { + error: LiveError::RateLimited, + fatal: false + }), + vec![json(ServerFrame::Error { + code: "rate_limited".into(), + message: LiveError::RateLimited.to_string(), + fatal: false + })] + ); + assert_eq!( + map(LiveEvent::Closed(CloseReason::Client)), + vec![json(ServerFrame::Closed { + reason: "client".into() + })] + ); + assert_eq!( + map(LiveEvent::Closed(CloseReason::Remote { + code: Some(1000), + reason: "bye".into() + })), + vec![json(ServerFrame::Closed { + reason: "remote (1000) bye".into() + })] + ); + assert_eq!( + map(LiveEvent::Closed(CloseReason::Remote { + code: None, + reason: String::new() + })), + vec![json(ServerFrame::Closed { + reason: "remote".into() + })] + ); + let out = map(LiveEvent::Closed(CloseReason::Error( + LiveError::Unauthorized, + ))); + assert_eq!(out.len(), 2); + assert!( + matches!(&out[0], LiveOutput::Json { frame: ServerFrame::Error { code, fatal: true, .. } } if code == "unauthorized") + ); + // Tool calls surface through ToolStarted/ToolFinished instead. + assert!( + map(LiveEvent::ToolCall(ToolCall { + call_id: "c".into(), + name: "n".into(), + args: serde_json::json!({}) + })) + .is_empty() + ); +} + +#[test] +fn maps_tool_progress() { + assert_eq!( + map_event( + LiveAgentEvent::ToolStarted { + call_id: "c".into(), + name: "n".into() + }, + "s", + "p", + "t" + ), + vec![json(ServerFrame::ToolStarted { + call_id: "c".into(), + name: "n".into() + })] + ); + assert_eq!( + map_event( + LiveAgentEvent::ToolFinished { + call_id: "c".into(), + name: "n".into(), + is_error: true, + cancelled: false, + duration: std::time::Duration::from_millis(5) + }, + "s", + "p", + "t" + ), + vec![json(ServerFrame::ToolFinished { + call_id: "c".into(), + name: "n".into(), + ok: false, + cancelled: false + })] + ); +} diff --git a/crates/tinyvoice-module/src/service/mod.rs b/crates/tinyvoice-module/src/service/mod.rs index 8948266..0122048 100644 --- a/crates/tinyvoice-module/src/service/mod.rs +++ b/crates/tinyvoice-module/src/service/mod.rs @@ -4,6 +4,7 @@ #[path = "mod_tests.rs"] mod test; +mod live; mod relay; use base64::Engine as _; From 1a77350f7dd2e9448cb39481c67eba03d87a8156 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:10:22 +0300 Subject: [PATCH 08/59] feat(live): add session start, command and handle request types Add the wire types the host uses to drive a live session: provider connection material, provider configuration, the start request, session commands and the handle request. Credentials and configuration are kept out of Debug output so secrets do not leak into logs. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyvoice-bus/src/live/session.rs | 154 +++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/crates/tinyvoice-bus/src/live/session.rs b/crates/tinyvoice-bus/src/live/session.rs index 785c9ce..70f79c9 100644 --- a/crates/tinyvoice-bus/src/live/session.rs +++ b/crates/tinyvoice-bus/src/live/session.rs @@ -160,3 +160,157 @@ impl std::fmt::Debug for ExecuteLiveToolReply { .finish_non_exhaustive() } } + +/// Provider connection material supplied by the host after credential lookup. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum LiveConnection { + /// Direct Gemini Live connection. + Gemini { + /// Host-provided API key. + api_key: String, + }, + /// Prepared Gemini relay connection. + GeminiRelay { + /// Prepared websocket URL; may contain a credential. + ws_url: String, + /// Prepared provider session identity. + session_id: String, + /// Prepared provider model. + model: String, + }, + /// Prepared ElevenLabs Conversational AI connection. + ElevenLabs { + /// Prepared signed websocket URL. + signed_url: String, + }, + /// Direct Sarvam chained connection. + Sarvam { + /// Host-provided API key. + api_key: String, + }, +} + +impl std::fmt::Debug for LiveConnection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let kind = match self { + Self::Gemini { .. } => "gemini", + Self::GeminiRelay { .. } => "gemini_relay", + Self::ElevenLabs { .. } => "eleven_labs", + Self::Sarvam { .. } => "sarvam", + }; + f.debug_struct("LiveConnection") + .field("kind", &kind) + .finish_non_exhaustive() + } +} + +/// Provider configuration for mono PCM16 live sessions. +#[derive(Clone, PartialEq, Serialize, Deserialize)] +pub struct LiveConfiguration { + /// Optional provider model. + pub model: Option, + /// Host-composed system instruction. + pub system_instruction: Option, + /// Optional voice name. + pub voice: Option, + /// Optional BCP-47 language. + pub language: Option, + /// Optional initial spoken message. + pub first_message: Option, + /// Input PCM16 sample rate. + pub input_sample_rate: u32, + /// Explicit host-approved tool declarations. + pub tools: Vec, + /// Whether user transcripts are requested. + pub input_transcription: bool, + /// Whether agent transcripts are requested. + pub output_transcription: bool, + /// Use host activity markers instead of provider VAD. + pub manual_turn_detection: bool, + /// Optional trailing silence in milliseconds. + pub silence_ms: Option, + /// Optional speech probability threshold. + pub speech_threshold: Option, + /// Provider-specific settings, including prepared hosted identity fields. + pub provider_options: Value, +} + +impl std::fmt::Debug for LiveConfiguration { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LiveConfiguration") + .field("input_sample_rate", &self.input_sample_rate) + .field("tools", &self.tools.len()) + .finish_non_exhaustive() + } +} + +/// Begin connection on a previously reserved handle. +#[derive(Clone, PartialEq, Serialize, Deserialize)] +pub struct LiveStartRequest { + /// Reserved session lease. + pub handle: LiveHandle, + /// Prepared provider credentials. + pub connection: LiveConnection, + /// Explicit provider configuration. + pub configuration: LiveConfiguration, + /// Policy-aware host tool executor port. + pub tool_port: LiveToolPort, + /// Fallback session identity presented to the client. + pub session_id: String, + /// Host provider label presented to the client. + pub provider: String, + /// Host transcript identity presented to the client. + pub thread_id: String, + /// Host-selected outer tool-call timeout in milliseconds. + pub tool_timeout_ms: u64, + /// Host-selected connection timeout in milliseconds. + pub connect_timeout_ms: u64, +} + +impl std::fmt::Debug for LiveStartRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LiveStartRequest") + .field("handle", &self.handle) + .field("connection", &self.connection) + .field("configuration", &self.configuration) + .field("tool_port", &self.tool_port) + .finish_non_exhaustive() + } +} + +/// Session command transported separately from client framing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum LiveCommand { + /// Forward mono PCM16 audio. + Audio { + /// Base64-encoded PCM16 bytes. + pcm16_base64: String, + }, + /// Forward typed input; host persistence precedes this call. + Text { + /// Original input text. + text: String, + }, + /// Interrupt provider playback. + Interrupt, + /// Request graceful provider closure. + Stop, +} + +/// Send a command to a running session. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LiveSendRequest { + /// Running lease. + pub handle: LiveHandle, + /// Input command. + pub command: LiveCommand, +} + +/// Stop, release or inspect a previously reserved session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct LiveHandleRequest { + /// Opaque session lease. + pub handle: LiveHandle, +} From be256286548d0cef12e2ea38f8d4fa8f6ef85d06 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:12:06 +0300 Subject: [PATCH 09/59] feat(live): carry host tool markdown preference through declarations Live tool declarations now include a prefer_markdown flag so the host's compact result rendering preference survives the trip to the provider, and the live service gains a callback module to hold that plumbing. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyvoice-bus/src/live/session.rs | 3 + .../src/service/live/callback.rs | 93 +++++++++++++++++++ .../tinyvoice-module/src/service/live/mod.rs | 1 + 3 files changed, 97 insertions(+) create mode 100644 crates/tinyvoice-module/src/service/live/callback.rs diff --git a/crates/tinyvoice-bus/src/live/session.rs b/crates/tinyvoice-bus/src/live/session.rs index 70f79c9..e921bef 100644 --- a/crates/tinyvoice-bus/src/live/session.rs +++ b/crates/tinyvoice-bus/src/live/session.rs @@ -94,6 +94,9 @@ pub struct LiveToolDeclaration { pub description: String, /// Provider-visible JSON argument schema. pub parameters: Value, + /// Preserve the host tool's compact result rendering preference. + #[serde(default)] + pub prefer_markdown: bool, } /// Port into the host's saved policy and approval context. diff --git a/crates/tinyvoice-module/src/service/live/callback.rs b/crates/tinyvoice-module/src/service/live/callback.rs new file mode 100644 index 0000000..b4ca2f7 --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/callback.rs @@ -0,0 +1,93 @@ +//! Tool dispatch through the host's current policy and approval context. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::Value; +use tinyagents_harness::{context::RunContext, ids::CallId, tool::ToolDispatch}; +use tinytools::{Tool, ToolCallOptions, ToolResult}; +use tinyvoice_bus::live::session::{ + ExecuteLiveToolReply, ExecuteLiveToolRequest, LiveToolDeclaration, LiveToolPort, + TOOL_CALLBACK_INTERFACE, TOOL_CALLBACK_METHOD, +}; + +/// Schema-only tool descriptor; typed dispatch is the only executable path. +struct Declaration(LiveToolDeclaration); + +#[async_trait] +impl Tool for Declaration { + fn name(&self) -> &str { + &self.0.name + } + + fn description(&self) -> &str { + &self.0.description + } + + fn parameters_schema(&self) -> Value { + self.0.parameters.clone() + } + + fn supports_markdown(&self) -> bool { + self.0.prefer_markdown + } + + async fn execute(&self, _args: Value) -> anyhow::Result { + anyhow::bail!("live callback execution requires an admitted provider call context") + } +} + +/// An explicitly registered tool backed by one dedicated callback method. +pub(super) struct CallbackTool { + declaration: Arc, + port: LiveToolPort, + proxy: tinybus::Proxy, +} + +impl CallbackTool { + /// Bind a declaration to the host's saved authority without invoking it. + pub(super) fn new( + declaration: LiveToolDeclaration, + port: LiveToolPort, + connection: &tinybus::Connection, + ) -> tinybus::Result { + let proxy = connection.proxy( + port.bus_name.as_str(), + port.object_path.as_str(), + TOOL_CALLBACK_INTERFACE, + )?; + Ok(Self { + declaration: Arc::new(Declaration(declaration)), + port, + proxy, + }) + } +} + +#[async_trait] +impl ToolDispatch<(), ()> for CallbackTool { + fn tool(&self) -> Arc { + self.declaration.clone() + } + + async fn execute( + &self, + _state: &(), + call_id: CallId, + arguments: Value, + options: ToolCallOptions, + _parent: &RunContext<()>, + ) -> anyhow::Result { + let request = ExecuteLiveToolRequest { + context_token: self.port.context_token.clone(), + call_id: call_id.to_string(), + name: self.declaration.0.name.clone(), + arguments, + prefer_markdown: options.prefer_markdown, + }; + let reply: ExecuteLiveToolReply = self.proxy.call(TOOL_CALLBACK_METHOD, (request,)).await?; + // Preserve the complete canonical result and its reported-error flag. + // A malformed result fails dispatch instead of synthesizing success. + Ok(serde_json::from_value(reply.result)?) + } +} diff --git a/crates/tinyvoice-module/src/service/live/mod.rs b/crates/tinyvoice-module/src/service/live/mod.rs index 40729db..015681a 100644 --- a/crates/tinyvoice-module/src/service/live/mod.rs +++ b/crates/tinyvoice-module/src/service/live/mod.rs @@ -1,3 +1,4 @@ //! Compiled live provider runtime and bounded session lifecycle. +mod callback; mod projection; From e886c03709446cb4d1ef94faa479f5e941b8ea06 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:12:36 +0300 Subject: [PATCH 10/59] test(live): assert full ready frame in projection test The ready projection test now compares the whole mapped frame against an expected value instead of destructuring a single field, so any change to the other fields of the ready frame is caught as well. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/service/live/projection_tests.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/tinyvoice-module/src/service/live/projection_tests.rs b/crates/tinyvoice-module/src/service/live/projection_tests.rs index 96a553b..7ce0905 100644 --- a/crates/tinyvoice-module/src/service/live/projection_tests.rs +++ b/crates/tinyvoice-module/src/service/live/projection_tests.rs @@ -23,12 +23,15 @@ fn ready_uses_the_provider_session_id_or_ours() { thread_id: Some("thread".into()) })] ); - let json(ServerFrame::Ready { session_id, .. }) = &map(LiveEvent::Ready(info(Some("p"))))[0] - else { - assert!(false, "expected ready"); - return; - }; - assert_eq!(session_id.as_deref(), Some("p")); + assert_eq!( + map(LiveEvent::Ready(info(Some("p")))), + vec![json(ServerFrame::Ready { + session_id: Some("p".into()), + provider: "sarvam".into(), + output_sample_rate: 24_000, + thread_id: Some("thread".into()), + })] + ); } #[test] From 37b817fd8c121d27437cb082b02272545f642a5c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:20:33 +0300 Subject: [PATCH 11/59] feat(live): add idempotent context finish and callback size limits Live sessions now carry a host-selected tool-call cap and string lease identities, and a new FinishLiveContext method lets the host idempotently revoke a known context and cancel its work. Tool callback requests and replies are also rejected when their serialized form exceeds one mebibyte, so oversized payloads fail dispatch instead of being relayed. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyvoice-bus/src/live/session.rs | 31 ++++++- .../src/service/live/callback.rs | 6 ++ .../src/service/live/prepare.rs | 82 +++++++++++++++++++ crates/tinyvoice-module/src/service/relay.rs | 2 +- 4 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 crates/tinyvoice-module/src/service/live/prepare.rs diff --git a/crates/tinyvoice-bus/src/live/session.rs b/crates/tinyvoice-bus/src/live/session.rs index e921bef..7d93544 100644 --- a/crates/tinyvoice-bus/src/live/session.rs +++ b/crates/tinyvoice-bus/src/live/session.rs @@ -9,12 +9,14 @@ use super::{ServerFrame, TranscriptRole}; pub const TOOL_CALLBACK_INTERFACE: &str = "ai.tinyhumans.tinyvoice.LiveTools"; /// The only method invoked on a live tool callback endpoint. pub const TOOL_CALLBACK_METHOD: &str = "ExecuteLiveTool"; +/// Idempotently revoke a known context and cancel its host-owned work. +pub const FINISH_CALLBACK_METHOD: &str = "FinishLiveContext"; /// An opaque live lease, reserved before provider connection begins. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct LiveHandle { /// Unpredictable module-generated lease identity. - pub id: u128, + pub id: String, } /// Connection state, independent of host permissions or UI. @@ -265,6 +267,8 @@ pub struct LiveStartRequest { pub provider: String, /// Host transcript identity presented to the client. pub thread_id: String, + /// Host-selected maximum provider tool calls for this session. + pub max_tool_calls: u32, /// Host-selected outer tool-call timeout in milliseconds. pub tool_timeout_ms: u64, /// Host-selected connection timeout in milliseconds. @@ -312,8 +316,29 @@ pub struct LiveSendRequest { } /// Stop, release or inspect a previously reserved session. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LiveHandleRequest { /// Opaque session lease. pub handle: LiveHandle, } + +/// Idempotent cleanup of a previously registered host execution context. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FinishLiveContextRequest { + /// Known authority token; an unknown token must never create a context. + pub context_token: String, +} + +impl std::fmt::Debug for FinishLiveContextRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FinishLiveContextRequest") + .finish_non_exhaustive() + } +} + +/// Cleanup acknowledgement; already finished known contexts remain successful. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct FinishLiveContextReply { + /// Whether the known context is closed and its owned work has stopped. + pub finished: bool, +} diff --git a/crates/tinyvoice-module/src/service/live/callback.rs b/crates/tinyvoice-module/src/service/live/callback.rs index b4ca2f7..edb6954 100644 --- a/crates/tinyvoice-module/src/service/live/callback.rs +++ b/crates/tinyvoice-module/src/service/live/callback.rs @@ -85,7 +85,13 @@ impl ToolDispatch<(), ()> for CallbackTool { arguments, prefer_markdown: options.prefer_markdown, }; + if !super::super::relay::fits(&request, 1024 * 1024) { + anyhow::bail!("live tool callback request exceeds its serialized limit"); + } let reply: ExecuteLiveToolReply = self.proxy.call(TOOL_CALLBACK_METHOD, (request,)).await?; + if !super::super::relay::fits(&reply, 1024 * 1024) { + anyhow::bail!("live tool callback result exceeds its serialized limit"); + } // Preserve the complete canonical result and its reported-error flag. // A malformed result fails dispatch instead of synthesizing success. Ok(serde_json::from_value(reply.result)?) diff --git a/crates/tinyvoice-module/src/service/live/prepare.rs b/crates/tinyvoice-module/src/service/live/prepare.rs new file mode 100644 index 0000000..47664d5 --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/prepare.rs @@ -0,0 +1,82 @@ +//! Native provider and harness construction from explicit bus configuration. + +use std::collections::HashSet; +use std::sync::Arc; + +use tinyagents_harness::{context::{RunConfig, RunContext}, runtime::AgentHarness}; +use tinyagents_live::{LiveAgent, LiveAgentOptions, tinyliveagents::{ + AudioFormat, LiveConfig, LiveProvider, ToolDeclaration, TurnDetection, VadConfig, + elevenlabs::ElevenLabsConvai, gemini::{GeminiLive, GeminiRelay}, sarvam::SarvamCascade, +}}; +use tinyvoice_bus::live::session::{LiveConfiguration, LiveConnection, LiveStartRequest}; + +use super::callback::CallbackTool; + +/// Fully constructed portable provider/tool-loop inputs. +pub(super) struct Prepared { + pub(super) agent: LiveAgent<(), ()>, + pub(super) provider: Box, + pub(super) config: LiveConfig, + pub(super) context: RunContext<()>, + pub(super) options: LiveAgentOptions, +} + +/// Bind only the exact declarations supplied by the host. +pub(super) fn prepare(request: &LiveStartRequest, connection: &tinybus::Connection) -> tinybus::Result { + if request.tool_timeout_ms == 0 || request.connect_timeout_ms == 0 + || request.max_tool_calls == 0 || request.max_tool_calls > 10_000 { + return Err(tinybus::Error::failed("live timeout and tool-call bounds must be positive and finite")); + } + let mut names = HashSet::new(); + let mut harness = AgentHarness::<(), ()>::new(); + for declaration in &request.configuration.tools { + if declaration.name.is_empty() || !names.insert(declaration.name.as_str()) { + return Err(tinybus::Error::failed("live tool names must be nonempty and unique")); + } + harness.register_tool_dispatch(Arc::new(CallbackTool::new( + declaration.clone(), request.tool_port.clone(), connection, + )?)); + } + let provider: Box = match &request.connection { + LiveConnection::Gemini { api_key } => Box::new(GeminiLive::new(api_key)), + LiveConnection::GeminiRelay { ws_url, session_id, model } => { + Box::new(GeminiRelay::connect_url(ws_url).with_session(session_id, model)) + }, + LiveConnection::ElevenLabs { signed_url } => Box::new(ElevenLabsConvai::connect_url(signed_url)), + LiveConnection::Sarvam { api_key } => Box::new(SarvamCascade::new(api_key)), + }; + let max_calls = usize::try_from(request.max_tool_calls) + .map_err(|_| tinybus::Error::failed("live tool-call bound is unsupported on this target"))?; + Ok(Prepared { + agent: LiveAgent::new(Arc::new(harness), Arc::new(())), + provider, + config: configuration(&request.configuration), + context: RunContext::new(RunConfig::new(format!("live-{}", request.handle.id)).with_max_tool_calls(max_calls), ()), + options: LiveAgentOptions { + tool_timeout: std::time::Duration::from_millis(request.tool_timeout_ms), + include_deferred_tools: false, + }, + }) +} + +fn configuration(config: &LiveConfiguration) -> LiveConfig { + LiveConfig { + model: config.model.clone(), + system_instruction: config.system_instruction.clone(), + voice: config.voice.clone(), + language: config.language.clone(), + first_message: config.first_message.clone(), + input_format: AudioFormat::pcm16(config.input_sample_rate), + tools: config.tools.iter().map(|tool| ToolDeclaration::new( + &tool.name, &tool.description, tool.parameters.clone(), + )).collect(), + input_transcription: config.input_transcription, + output_transcription: config.output_transcription, + vad: VadConfig { + turn_detection: if config.manual_turn_detection { TurnDetection::Manual } else { TurnDetection::Server }, + silence_ms: config.silence_ms, + threshold: config.speech_threshold, + }, + provider_options: config.provider_options.clone(), + } +} diff --git a/crates/tinyvoice-module/src/service/relay.rs b/crates/tinyvoice-module/src/service/relay.rs index cd456b7..b61e686 100644 --- a/crates/tinyvoice-module/src/service/relay.rs +++ b/crates/tinyvoice-module/src/service/relay.rs @@ -26,7 +26,7 @@ impl Write for SizeLimit { } } -fn fits(value: &impl Serialize, limit: usize) -> bool { +pub(super) fn fits(value: &impl Serialize, limit: usize) -> bool { serde_json::to_writer(SizeLimit { remaining: limit }, value).is_ok() } From 70be48eea42d46fb31914d20cfe8296a2a4b9341 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:30:06 +0300 Subject: [PATCH 12/59] test(service): cover live reservation before provider startup Add a test asserting that a live reservation can be created before the provider starts and then released, verifying the reserved and closed states along with the handle id length. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyvoice-module/src/service/mod_tests.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/tinyvoice-module/src/service/mod_tests.rs b/crates/tinyvoice-module/src/service/mod_tests.rs index 2d99d03..fef344d 100644 --- a/crates/tinyvoice-module/src/service/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/mod_tests.rs @@ -1072,3 +1072,15 @@ async fn live_first_frame_parsing_preserves_default_rate_and_host_metadata() { assert_eq!(reply["thread_id"], "thread"); assert_eq!(reply["client_id"], "client"); } + +#[tokio::test] +async fn live_reservations_exist_before_provider_startup_and_can_be_released() { + let proxy = connect().await.unwrap(); + let reserved: serde_json::Value = proxy.call("LiveReserve", ()).await.unwrap(); + assert_eq!(reserved["state"], "reserved"); + assert_eq!(reserved["handle"]["id"].as_str().unwrap().len(), 32); + let released: serde_json::Value = proxy.call("LiveRelease", serde_json::json!({ + "handle": reserved["handle"], + })).await.unwrap(); + assert_eq!(released["state"], "closed"); +} From 630a3b967dfc7ca2d124fdd0d39037689b6390ba Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:31:57 +0300 Subject: [PATCH 13/59] feat(live): add session table and actor modules Wire up the new actor, prepare, and table modules in the live service and re-export the session table for use by the runtime. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinyvoice-module/src/service/live/mod.rs | 5 + .../src/service/live/table.rs | 293 ++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 crates/tinyvoice-module/src/service/live/table.rs diff --git a/crates/tinyvoice-module/src/service/live/mod.rs b/crates/tinyvoice-module/src/service/live/mod.rs index 015681a..9ffd074 100644 --- a/crates/tinyvoice-module/src/service/live/mod.rs +++ b/crates/tinyvoice-module/src/service/live/mod.rs @@ -1,4 +1,9 @@ //! Compiled live provider runtime and bounded session lifecycle. +mod actor; mod callback; +mod prepare; mod projection; +mod table; + +pub(super) use table::Sessions; diff --git a/crates/tinyvoice-module/src/service/live/table.rs b/crates/tinyvoice-module/src/service/live/table.rs new file mode 100644 index 0000000..e739754 --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/table.rs @@ -0,0 +1,293 @@ +//! Bounded leases, cancellation and replayable live event batches. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}}; +use tokio::sync::{Notify, mpsc}; +use tokio::task::JoinHandle; +use tinyagents_live::tinyliveagents::LiveSender; +use tinyvoice_bus::live::session::{ + LiveBatch, LiveCommand, LiveHandle, LiveOutput, LiveReadRequest, LiveSendRequest, + LiveStartRequest, LiveState, LiveStatus, +}; + +const MAX_LEASES: usize = 16; +pub(super) const MAX_GROUPS: usize = 32; +pub(super) const MAX_BATCH_BYTES: usize = 1024 * 1024; +pub(super) const MAX_AUDIO_BYTES: usize = 64 * 1024; +pub(super) const MAX_TEXT_BYTES: usize = 64 * 1024; +const RESERVATION_TTL: std::time::Duration = std::time::Duration::from_secs(60); + +/// Mutable state never held across an await. +pub(super) struct Phase { + pub(super) state: LiveState, + pub(super) sender: Option, + pub(super) task: Option>, + request: Option, +} + +struct Reader { + rx: mpsc::Receiver>, + carry: Option>, + pending: Option, + last_acknowledged: u64, + next_batch: u64, +} + +/// One owned native provider task and its bounded event queue. +pub(super) struct Lease { + pub(super) phase: Mutex, + pub(super) outputs: mpsc::Sender>, + pub(super) cancelled: AtomicBool, + changed: Notify, + reader: tokio::sync::Mutex, + created: tokio::time::Instant, +} + +impl Lease { + fn new() -> Self { + let (tx, rx) = mpsc::channel(MAX_GROUPS); + Self { + phase: Mutex::new(Phase { + state: LiveState::Reserved, sender: None, task: None, request: None, + }), + outputs: tx, + cancelled: AtomicBool::new(false), + changed: Notify::new(), + reader: tokio::sync::Mutex::new(Reader { + rx, carry: None, pending: None, last_acknowledged: 0, next_batch: 1, + }), + created: tokio::time::Instant::now(), + } + } + + pub(super) fn cancel(&self) { + self.cancelled.store(true, Ordering::SeqCst); + self.changed.notify_one(); + } + + pub(super) async fn wait_cancelled(&self) { + if !self.cancelled.load(Ordering::SeqCst) { + self.changed.notified().await; + } + } + + fn status(&self, handle: LiveHandle) -> tinybus::Result { + let phase = self.phase.lock().map_err(|_| unavailable())?; + Ok(LiveStatus { handle, state: phase.state }) + } +} + +/// Admission table; shutdown permanently closes this module instance. +pub(super) struct Sessions { + closed: AtomicBool, + leases: Mutex>>, + connection: Mutex>, +} + +impl std::fmt::Debug for Sessions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Sessions").field("closed", &self.closed).finish_non_exhaustive() + } +} + +impl Default for Sessions { + fn default() -> Self { + Self { + closed: AtomicBool::new(false), + leases: Mutex::new(HashMap::new()), + connection: Mutex::new(None), + } + } +} + +impl Sessions { + pub(super) fn with_connection(connection: tinybus::Connection) -> Self { + Self { connection: Mutex::new(Some(connection)), ..Self::default() } + } + + pub(super) fn reserve(&self) -> tinybus::Result { + let mut leases = self.leases.lock().map_err(|_| unavailable())?; + if self.closed.load(Ordering::SeqCst) { + return Err(tinybus::Error::failed("live module admission is closed")); + } + leases.retain(|_, lease| { + lease.phase.lock().is_ok_and(|phase| { + phase.state != LiveState::Reserved || lease.created.elapsed() < RESERVATION_TTL + }) + }); + if leases.len() >= MAX_LEASES { + return Err(tinybus::Error::failed("live session capacity is exhausted")); + } + loop { + let mut random = [0_u8; 16]; + getrandom::fill(&mut random).map_err(|_| tinybus::Error::failed("live handle generation failed"))?; + let id: String = random.iter().map(|byte| format!("{byte:02x}")).collect(); + let handle = LiveHandle { id }; + if !leases.contains_key(&handle) { + leases.insert(handle.clone(), Arc::new(Lease::new())); + return Ok(LiveStatus { handle, state: LiveState::Reserved }); + } + } + } + + fn lease(&self, handle: &LiveHandle) -> tinybus::Result> { + self.leases.lock().map_err(|_| unavailable())?.get(handle).cloned() + .ok_or_else(|| tinybus::Error::failed("unknown live session handle")) + } + + pub(super) fn start(&self, request: LiveStartRequest) -> tinybus::Result { + if !super::super::relay::fits(&request, MAX_BATCH_BYTES) { + return Err(tinybus::Error::failed("live startup exceeds the serialized size limit")); + } + let lease = self.lease(&request.handle)?; + let connection = self.connection.lock().map_err(|_| unavailable())?.clone() + .ok_or_else(|| tinybus::Error::failed("live callback connection is unavailable"))?; + let mut phase = lease.phase.lock().map_err(|_| unavailable())?; + if let Some(previous) = &phase.request { + if previous == &request { + return Ok(LiveStatus { handle: request.handle, state: phase.state }); + } + return Err(tinybus::Error::failed("live session was already started with different inputs")); + } + if phase.state != LiveState::Reserved || lease.created.elapsed() >= RESERVATION_TTL { + return Err(tinybus::Error::failed("live reservation is closed or expired")); + } + let prepared = super::prepare::prepare(&request, &connection)?; + phase.state = LiveState::Starting; + phase.request = Some(request.clone()); + let task_lease = lease.clone(); + let handle = request.handle.clone(); + phase.task = Some(tokio::spawn(super::actor::run(task_lease, request, prepared, connection))); + Ok(LiveStatus { handle, state: LiveState::Starting }) + } + + pub(super) async fn send(&self, request: LiveSendRequest) -> tinybus::Result { + use base64::Engine as _; + let lease = self.lease(&request.handle)?; + let sender = { + let phase = lease.phase.lock().map_err(|_| unavailable())?; + phase.sender.clone().ok_or_else(|| tinybus::Error::failed("live session is not running"))? + }; + let command = match request.command { + LiveCommand::Audio { pcm16_base64 } => { + if pcm16_base64.len() > MAX_AUDIO_BYTES.div_ceil(3) * 4 { + return Err(tinybus::Error::failed("live audio exceeds its input limit")); + } + let pcm = base64::engine::general_purpose::STANDARD.decode(pcm16_base64) + .map_err(|_| tinybus::Error::failed("invalid live audio encoding"))?; + if pcm.len() > MAX_AUDIO_BYTES || pcm.len() % 2 != 0 { + return Err(tinybus::Error::failed("live PCM16 audio exceeds its limit or has a partial sample")); + } + tinyagents_live::tinyliveagents::ClientCommand::Audio(pcm.into()) + } + LiveCommand::Text { text } => { + if text.len() > MAX_TEXT_BYTES { + return Err(tinybus::Error::failed("live text exceeds its input limit")); + } + tinyagents_live::tinyliveagents::ClientCommand::Text(text) + } + LiveCommand::Interrupt => tinyagents_live::tinyliveagents::ClientCommand::Interrupt, + LiveCommand::Stop => tinyagents_live::tinyliveagents::ClientCommand::Close, + }; + tokio::time::timeout(std::time::Duration::from_secs(1), sender.send(command)) + .await.map_err(|_| tinybus::Error::failed("live command queue did not accept input"))? + .map_err(|error| tinybus::Error::failed(error.to_string()))?; + lease.status(request.handle) + } + + pub(super) async fn read(&self, request: LiveReadRequest) -> tinybus::Result { + let lease = self.lease(&request.handle)?; + let mut reader = lease.reader.lock().await; + if let Some(ack) = request.acknowledged_batch { + if reader.pending.as_ref().is_some_and(|batch| batch.batch == ack) { + reader.pending = None; + reader.last_acknowledged = ack; + } else if ack != reader.last_acknowledged { + return Err(tinybus::Error::failed("live batch acknowledgement is stale or unknown")); + } + } + if let Some(batch) = &reader.pending { + return Ok(batch.clone()); + } + let mut outputs = Vec::new(); + for _ in 0..MAX_GROUPS { + let group = reader.carry.take().or_else(|| reader.rx.try_recv().ok()); + let Some(group) = group else { break }; + let before = outputs.len(); + outputs.extend(group); + if !super::super::relay::fits(&outputs, MAX_BATCH_BYTES - 4096) { + reader.carry = Some(outputs.split_off(before)); + if outputs.is_empty() { + return Err(tinybus::Error::failed("live event group exceeds its output limit")); + } + break; + } + } + let state = lease.status(request.handle.clone())?.state; + if outputs.is_empty() { + return Ok(LiveBatch { handle: request.handle, batch: reader.last_acknowledged, state, outputs }); + } + let batch = LiveBatch { handle: request.handle, batch: reader.next_batch, state, outputs }; + reader.next_batch = reader.next_batch.checked_add(1) + .ok_or_else(|| tinybus::Error::failed("live batch sequence is exhausted"))?; + reader.pending = Some(batch.clone()); + Ok(batch) + } + + pub(super) async fn release(&self, handle: LiveHandle) -> tinybus::Result { + let lease = self.leases.lock().map_err(|_| unavailable())?.remove(&handle); + if let Some(lease) = lease { + stop_lease(&lease).await?; + } + Ok(LiveStatus { handle, state: LiveState::Closed }) + } + + pub(super) async fn shutdown(&self) -> tinybus::Result<()> { + self.closed.store(true, Ordering::SeqCst); + let leases: Vec<_> = self.leases.lock().map_err(|_| unavailable())?.drain().map(|(_, lease)| lease).collect(); + // Release the service's connection ownership while actors retain their + // own callback proxies until bounded cleanup finishes. + self.connection.lock().map_err(|_| unavailable())?.take(); + for lease in &leases { lease.cancel(); } + let mut failure = None; + for lease in leases { + if let Err(error) = stop_lease(&lease).await { failure = Some(error); } + } + failure.map_or(Ok(()), Err) + } +} + +async fn stop_lease(lease: &Lease) -> tinybus::Result<()> { + lease.cancel(); + let task = { + let mut phase = lease.phase.lock().map_err(|_| unavailable())?; + phase.sender = None; + phase.state = LiveState::Closed; + phase.task.take() + }; + if let Some(mut task) = task { + if tokio::time::timeout(std::time::Duration::from_secs(2), &mut task).await.is_err() { + task.abort(); + let _ = task.await; + return Err(tinybus::Error::failed("live worker cleanup exceeded its deadline")); + } + } + Ok(()) +} + +impl Drop for Sessions { + fn drop(&mut self) { + if let Ok(leases) = self.leases.get_mut() { + for lease in leases.values() { + lease.cancel(); + if let Ok(mut phase) = lease.phase.lock() { + if let Some(task) = phase.task.take() { task.abort(); } + } + } + } + } +} + +fn unavailable() -> tinybus::Error { + tinybus::Error::failed("live session state is unavailable") +} From a5cc51c8fad7081d344989c3346296cf2be3eace Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:33:52 +0300 Subject: [PATCH 14/59] fix(live): confirm host cleanup before releasing a session Session teardown now waits for the actor to acknowledge that host context cleanup finished, and reports an error if the worker exits unexpectedly or the acknowledgement never arrives. Startup validation also rejects connect and tool timeouts above their upper bounds so oversized values cannot stall a session indefinitely. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/service/live/actor.rs | 165 +++++++++++++++ .../src/service/live/prepare.rs | 82 ++++++-- .../src/service/live/table.rs | 189 ++++++++++++++---- .../tinyvoice-module/src/service/mod_tests.rs | 12 +- 4 files changed, 385 insertions(+), 63 deletions(-) create mode 100644 crates/tinyvoice-module/src/service/live/actor.rs diff --git a/crates/tinyvoice-module/src/service/live/actor.rs b/crates/tinyvoice-module/src/service/live/actor.rs new file mode 100644 index 0000000..5a40685 --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/actor.rs @@ -0,0 +1,165 @@ +//! Owned provider pump with bounded output and bounded host-context cleanup. + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use std::sync::Arc; +use std::time::Duration; +use tinyagents_live::{ + LiveAgentEvent, LiveAgentSession, + tinyliveagents::{CloseReason, Error, LiveEvent}, +}; +use tinyvoice_bus::live::{ + ServerFrame, + session::{ + FINISH_CALLBACK_METHOD, FinishLiveContextReply, FinishLiveContextRequest, LiveOutput, + LiveStartRequest, LiveState, TOOL_CALLBACK_INTERFACE, + }, +}; + +use super::{ + prepare::Prepared, + table::{Lease, MAX_AUDIO_BYTES, MAX_TEXT_BYTES}, +}; + +const FINISH_TIMEOUT: Duration = Duration::from_secs(1); + +/// Own the connection, native loop and cleanup for one accepted start request. +pub(super) async fn run( + lease: Arc, + request: LiveStartRequest, + prepared: Prepared, + connection: tinybus::Connection, +) { + let connected = tokio::select! { + biased; + () = lease.wait_cancelled() => None, + result = tokio::time::timeout( + Duration::from_millis(request.connect_timeout_ms), + prepared.agent.start( + prepared.provider.as_ref(), prepared.config, prepared.context, prepared.options, + ), + ) => Some(result.unwrap_or(Err(Error::Timeout))), + }; + match connected { + Some(Ok(mut session)) => { + if let Ok(mut phase) = lease.phase.lock() { + phase.sender = Some(session.sender()); + phase.state = LiveState::Running; + } + tokio::select! { + biased; + () = lease.wait_cancelled() => {} + () = forward(&lease, &mut session, &request) => {} + } + // Drop stops TinyAgentsLive's worker and provider event driver before + // asking the host to cancel its independently owned callback work. + drop(session); + } + Some(Err(error)) => { + let outputs = super::projection::map_event( + LiveAgentEvent::Live(LiveEvent::Closed(CloseReason::Error(error))), + &request.session_id, + &request.provider, + &request.thread_id, + ); + let _ = lease.outputs.send(outputs).await; + } + None => {} + } + let confirmed = finish_context(&connection, &request).await; + if let Ok(mut phase) = lease.phase.lock() { + phase.sender = None; + phase.state = LiveState::Closed; + phase.cleanup_confirmed = confirmed; + } +} + +/// Drain native events in order; output backpressure never blocks input methods. +async fn forward(lease: &Lease, session: &mut LiveAgentSession, request: &LiveStartRequest) { + while let Some(event) = session.recv().await { + let terminal = matches!(event, LiveAgentEvent::Live(LiveEvent::Closed(_))); + match event { + LiveAgentEvent::Live(LiveEvent::Audio(pcm)) => { + for chunk in pcm.chunks(MAX_AUDIO_BYTES) { + if lease + .outputs + .send(vec![LiveOutput::Audio { + pcm16_base64: BASE64.encode(chunk), + }]) + .await + .is_err() + { + return; + } + } + } + event => { + if oversized(&event) { + let outputs = vec![ + LiveOutput::Json { + frame: ServerFrame::Error { + code: "invalid_response".into(), + message: "live provider text exceeds the bounded output limit" + .into(), + fatal: true, + }, + }, + LiveOutput::Json { + frame: ServerFrame::Closed { + reason: "error".into(), + }, + }, + ]; + let _ = lease.outputs.send(outputs).await; + return; + } + let outputs = super::projection::map_event( + event, + &request.session_id, + &request.provider, + &request.thread_id, + ); + if !outputs.is_empty() && lease.outputs.send(outputs).await.is_err() { + return; + } + } + } + if terminal { + return; + } + } +} + +fn oversized(event: &LiveAgentEvent) -> bool { + match event { + LiveAgentEvent::Live( + LiveEvent::InputTranscript { text, .. } | LiveEvent::OutputTranscript { text, .. }, + ) => text.len() > MAX_TEXT_BYTES, + LiveAgentEvent::ToolStarted { call_id, name } + | LiveAgentEvent::ToolFinished { call_id, name, .. } => { + call_id.len() > MAX_TEXT_BYTES || name.len() > MAX_TEXT_BYTES + } + _ => false, + } +} + +/// Stop waiting after the deadline even if a host callback remains unresponsive. +/// +/// The host owns its work: it must also cancel it when its context lease drops. +/// This notification revokes a known context; it never creates one. +async fn finish_context(connection: &tinybus::Connection, request: &LiveStartRequest) -> bool { + let Ok(proxy) = connection.proxy( + request.tool_port.bus_name.as_str(), + request.tool_port.object_path.as_str(), + TOOL_CALLBACK_INTERFACE, + ) else { + return false; + }; + let call = proxy.call::<_, FinishLiveContextReply>( + FINISH_CALLBACK_METHOD, + (FinishLiveContextRequest { + context_token: request.tool_port.context_token.clone(), + },), + ); + matches!(tokio::time::timeout(FINISH_TIMEOUT, call).await, Ok(Ok(reply)) if reply.finished) +} diff --git a/crates/tinyvoice-module/src/service/live/prepare.rs b/crates/tinyvoice-module/src/service/live/prepare.rs index 47664d5..f114a96 100644 --- a/crates/tinyvoice-module/src/service/live/prepare.rs +++ b/crates/tinyvoice-module/src/service/live/prepare.rs @@ -3,11 +3,19 @@ use std::collections::HashSet; use std::sync::Arc; -use tinyagents_harness::{context::{RunConfig, RunContext}, runtime::AgentHarness}; -use tinyagents_live::{LiveAgent, LiveAgentOptions, tinyliveagents::{ - AudioFormat, LiveConfig, LiveProvider, ToolDeclaration, TurnDetection, VadConfig, - elevenlabs::ElevenLabsConvai, gemini::{GeminiLive, GeminiRelay}, sarvam::SarvamCascade, -}}; +use tinyagents_harness::{ + context::{RunConfig, RunContext}, + runtime::AgentHarness, +}; +use tinyagents_live::{ + LiveAgent, LiveAgentOptions, + tinyliveagents::{ + AudioFormat, LiveConfig, LiveProvider, ToolDeclaration, TurnDetection, VadConfig, + elevenlabs::ElevenLabsConvai, + gemini::{GeminiLive, GeminiRelay}, + sarvam::SarvamCascade, + }, +}; use tinyvoice_bus::live::session::{LiveConfiguration, LiveConnection, LiveStartRequest}; use super::callback::CallbackTool; @@ -22,36 +30,58 @@ pub(super) struct Prepared { } /// Bind only the exact declarations supplied by the host. -pub(super) fn prepare(request: &LiveStartRequest, connection: &tinybus::Connection) -> tinybus::Result { - if request.tool_timeout_ms == 0 || request.connect_timeout_ms == 0 - || request.max_tool_calls == 0 || request.max_tool_calls > 10_000 { - return Err(tinybus::Error::failed("live timeout and tool-call bounds must be positive and finite")); +pub(super) fn prepare( + request: &LiveStartRequest, + connection: &tinybus::Connection, +) -> tinybus::Result { + if request.tool_timeout_ms == 0 + || request.connect_timeout_ms == 0 + || request.max_tool_calls == 0 + || request.max_tool_calls > 10_000 + || request.connect_timeout_ms > 60_000 + || request.tool_timeout_ms > 1_800_000 + { + return Err(tinybus::Error::failed( + "live timeout and tool-call bounds must be positive and finite", + )); } let mut names = HashSet::new(); let mut harness = AgentHarness::<(), ()>::new(); for declaration in &request.configuration.tools { if declaration.name.is_empty() || !names.insert(declaration.name.as_str()) { - return Err(tinybus::Error::failed("live tool names must be nonempty and unique")); + return Err(tinybus::Error::failed( + "live tool names must be nonempty and unique", + )); } harness.register_tool_dispatch(Arc::new(CallbackTool::new( - declaration.clone(), request.tool_port.clone(), connection, + declaration.clone(), + request.tool_port.clone(), + connection, )?)); } let provider: Box = match &request.connection { LiveConnection::Gemini { api_key } => Box::new(GeminiLive::new(api_key)), - LiveConnection::GeminiRelay { ws_url, session_id, model } => { - Box::new(GeminiRelay::connect_url(ws_url).with_session(session_id, model)) - }, - LiveConnection::ElevenLabs { signed_url } => Box::new(ElevenLabsConvai::connect_url(signed_url)), + LiveConnection::GeminiRelay { + ws_url, + session_id, + model, + } => Box::new(GeminiRelay::connect_url(ws_url).with_session(session_id, model)), + LiveConnection::ElevenLabs { signed_url } => { + Box::new(ElevenLabsConvai::connect_url(signed_url)) + } LiveConnection::Sarvam { api_key } => Box::new(SarvamCascade::new(api_key)), }; - let max_calls = usize::try_from(request.max_tool_calls) - .map_err(|_| tinybus::Error::failed("live tool-call bound is unsupported on this target"))?; + let max_calls = usize::try_from(request.max_tool_calls).map_err(|_| { + tinybus::Error::failed("live tool-call bound is unsupported on this target") + })?; Ok(Prepared { agent: LiveAgent::new(Arc::new(harness), Arc::new(())), provider, config: configuration(&request.configuration), - context: RunContext::new(RunConfig::new(format!("live-{}", request.handle.id)).with_max_tool_calls(max_calls), ()), + context: RunContext::new( + RunConfig::new(format!("live-{}", request.handle.id)).with_max_tool_calls(max_calls), + (), + ), options: LiveAgentOptions { tool_timeout: std::time::Duration::from_millis(request.tool_timeout_ms), include_deferred_tools: false, @@ -67,13 +97,21 @@ fn configuration(config: &LiveConfiguration) -> LiveConfig { language: config.language.clone(), first_message: config.first_message.clone(), input_format: AudioFormat::pcm16(config.input_sample_rate), - tools: config.tools.iter().map(|tool| ToolDeclaration::new( - &tool.name, &tool.description, tool.parameters.clone(), - )).collect(), + tools: config + .tools + .iter() + .map(|tool| { + ToolDeclaration::new(&tool.name, &tool.description, tool.parameters.clone()) + }) + .collect(), input_transcription: config.input_transcription, output_transcription: config.output_transcription, vad: VadConfig { - turn_detection: if config.manual_turn_detection { TurnDetection::Manual } else { TurnDetection::Server }, + turn_detection: if config.manual_turn_detection { + TurnDetection::Manual + } else { + TurnDetection::Server + }, silence_ms: config.silence_ms, threshold: config.speech_threshold, }, diff --git a/crates/tinyvoice-module/src/service/live/table.rs b/crates/tinyvoice-module/src/service/live/table.rs index e739754..a1f4afd 100644 --- a/crates/tinyvoice-module/src/service/live/table.rs +++ b/crates/tinyvoice-module/src/service/live/table.rs @@ -1,14 +1,17 @@ //! Bounded leases, cancellation and replayable live event batches. use std::collections::HashMap; -use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}}; -use tokio::sync::{Notify, mpsc}; -use tokio::task::JoinHandle; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; use tinyagents_live::tinyliveagents::LiveSender; use tinyvoice_bus::live::session::{ LiveBatch, LiveCommand, LiveHandle, LiveOutput, LiveReadRequest, LiveSendRequest, LiveStartRequest, LiveState, LiveStatus, }; +use tokio::sync::{Notify, mpsc}; +use tokio::task::JoinHandle; const MAX_LEASES: usize = 16; pub(super) const MAX_GROUPS: usize = 32; @@ -23,6 +26,7 @@ pub(super) struct Phase { pub(super) sender: Option, pub(super) task: Option>, request: Option, + pub(super) cleanup_confirmed: bool, } struct Reader { @@ -48,13 +52,21 @@ impl Lease { let (tx, rx) = mpsc::channel(MAX_GROUPS); Self { phase: Mutex::new(Phase { - state: LiveState::Reserved, sender: None, task: None, request: None, + state: LiveState::Reserved, + sender: None, + task: None, + request: None, + cleanup_confirmed: true, }), outputs: tx, cancelled: AtomicBool::new(false), changed: Notify::new(), reader: tokio::sync::Mutex::new(Reader { - rx, carry: None, pending: None, last_acknowledged: 0, next_batch: 1, + rx, + carry: None, + pending: None, + last_acknowledged: 0, + next_batch: 1, }), created: tokio::time::Instant::now(), } @@ -73,7 +85,10 @@ impl Lease { fn status(&self, handle: LiveHandle) -> tinybus::Result { let phase = self.phase.lock().map_err(|_| unavailable())?; - Ok(LiveStatus { handle, state: phase.state }) + Ok(LiveStatus { + handle, + state: phase.state, + }) } } @@ -86,7 +101,9 @@ pub(super) struct Sessions { impl std::fmt::Debug for Sessions { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Sessions").field("closed", &self.closed).finish_non_exhaustive() + f.debug_struct("Sessions") + .field("closed", &self.closed) + .finish_non_exhaustive() } } @@ -102,7 +119,10 @@ impl Default for Sessions { impl Sessions { pub(super) fn with_connection(connection: tinybus::Connection) -> Self { - Self { connection: Mutex::new(Some(connection)), ..Self::default() } + Self { + connection: Mutex::new(Some(connection)), + ..Self::default() + } } pub(super) fn reserve(&self) -> tinybus::Result { @@ -120,45 +140,72 @@ impl Sessions { } loop { let mut random = [0_u8; 16]; - getrandom::fill(&mut random).map_err(|_| tinybus::Error::failed("live handle generation failed"))?; + getrandom::fill(&mut random) + .map_err(|_| tinybus::Error::failed("live handle generation failed"))?; let id: String = random.iter().map(|byte| format!("{byte:02x}")).collect(); let handle = LiveHandle { id }; if !leases.contains_key(&handle) { leases.insert(handle.clone(), Arc::new(Lease::new())); - return Ok(LiveStatus { handle, state: LiveState::Reserved }); + return Ok(LiveStatus { + handle, + state: LiveState::Reserved, + }); } } } fn lease(&self, handle: &LiveHandle) -> tinybus::Result> { - self.leases.lock().map_err(|_| unavailable())?.get(handle).cloned() + self.leases + .lock() + .map_err(|_| unavailable())? + .get(handle) + .cloned() .ok_or_else(|| tinybus::Error::failed("unknown live session handle")) } pub(super) fn start(&self, request: LiveStartRequest) -> tinybus::Result { if !super::super::relay::fits(&request, MAX_BATCH_BYTES) { - return Err(tinybus::Error::failed("live startup exceeds the serialized size limit")); + return Err(tinybus::Error::failed( + "live startup exceeds the serialized size limit", + )); } let lease = self.lease(&request.handle)?; - let connection = self.connection.lock().map_err(|_| unavailable())?.clone() + let connection = self + .connection + .lock() + .map_err(|_| unavailable())? + .clone() .ok_or_else(|| tinybus::Error::failed("live callback connection is unavailable"))?; let mut phase = lease.phase.lock().map_err(|_| unavailable())?; if let Some(previous) = &phase.request { if previous == &request { - return Ok(LiveStatus { handle: request.handle, state: phase.state }); + return Ok(LiveStatus { + handle: request.handle, + state: phase.state, + }); } - return Err(tinybus::Error::failed("live session was already started with different inputs")); + return Err(tinybus::Error::failed( + "live session was already started with different inputs", + )); } if phase.state != LiveState::Reserved || lease.created.elapsed() >= RESERVATION_TTL { - return Err(tinybus::Error::failed("live reservation is closed or expired")); + return Err(tinybus::Error::failed( + "live reservation is closed or expired", + )); } let prepared = super::prepare::prepare(&request, &connection)?; phase.state = LiveState::Starting; + phase.cleanup_confirmed = false; phase.request = Some(request.clone()); let task_lease = lease.clone(); let handle = request.handle.clone(); - phase.task = Some(tokio::spawn(super::actor::run(task_lease, request, prepared, connection))); - Ok(LiveStatus { handle, state: LiveState::Starting }) + phase.task = Some(tokio::spawn(super::actor::run( + task_lease, request, prepared, connection, + ))); + Ok(LiveStatus { + handle, + state: LiveState::Starting, + }) } pub(super) async fn send(&self, request: LiveSendRequest) -> tinybus::Result { @@ -166,17 +213,23 @@ impl Sessions { let lease = self.lease(&request.handle)?; let sender = { let phase = lease.phase.lock().map_err(|_| unavailable())?; - phase.sender.clone().ok_or_else(|| tinybus::Error::failed("live session is not running"))? + phase + .sender + .clone() + .ok_or_else(|| tinybus::Error::failed("live session is not running"))? }; let command = match request.command { LiveCommand::Audio { pcm16_base64 } => { if pcm16_base64.len() > MAX_AUDIO_BYTES.div_ceil(3) * 4 { return Err(tinybus::Error::failed("live audio exceeds its input limit")); } - let pcm = base64::engine::general_purpose::STANDARD.decode(pcm16_base64) + let pcm = base64::engine::general_purpose::STANDARD + .decode(pcm16_base64) .map_err(|_| tinybus::Error::failed("invalid live audio encoding"))?; if pcm.len() > MAX_AUDIO_BYTES || pcm.len() % 2 != 0 { - return Err(tinybus::Error::failed("live PCM16 audio exceeds its limit or has a partial sample")); + return Err(tinybus::Error::failed( + "live PCM16 audio exceeds its limit or has a partial sample", + )); } tinyagents_live::tinyliveagents::ClientCommand::Audio(pcm.into()) } @@ -190,7 +243,8 @@ impl Sessions { LiveCommand::Stop => tinyagents_live::tinyliveagents::ClientCommand::Close, }; tokio::time::timeout(std::time::Duration::from_secs(1), sender.send(command)) - .await.map_err(|_| tinybus::Error::failed("live command queue did not accept input"))? + .await + .map_err(|_| tinybus::Error::failed("live command queue did not accept input"))? .map_err(|error| tinybus::Error::failed(error.to_string()))?; lease.status(request.handle) } @@ -199,11 +253,17 @@ impl Sessions { let lease = self.lease(&request.handle)?; let mut reader = lease.reader.lock().await; if let Some(ack) = request.acknowledged_batch { - if reader.pending.as_ref().is_some_and(|batch| batch.batch == ack) { + if reader + .pending + .as_ref() + .is_some_and(|batch| batch.batch == ack) + { reader.pending = None; reader.last_acknowledged = ack; } else if ack != reader.last_acknowledged { - return Err(tinybus::Error::failed("live batch acknowledgement is stale or unknown")); + return Err(tinybus::Error::failed( + "live batch acknowledgement is stale or unknown", + )); } } if let Some(batch) = &reader.pending { @@ -218,40 +278,71 @@ impl Sessions { if !super::super::relay::fits(&outputs, MAX_BATCH_BYTES - 4096) { reader.carry = Some(outputs.split_off(before)); if outputs.is_empty() { - return Err(tinybus::Error::failed("live event group exceeds its output limit")); + return Err(tinybus::Error::failed( + "live event group exceeds its output limit", + )); } break; } } let state = lease.status(request.handle.clone())?.state; if outputs.is_empty() { - return Ok(LiveBatch { handle: request.handle, batch: reader.last_acknowledged, state, outputs }); + return Ok(LiveBatch { + handle: request.handle, + batch: reader.last_acknowledged, + state, + outputs, + }); } - let batch = LiveBatch { handle: request.handle, batch: reader.next_batch, state, outputs }; - reader.next_batch = reader.next_batch.checked_add(1) + let batch = LiveBatch { + handle: request.handle, + batch: reader.next_batch, + state, + outputs, + }; + reader.next_batch = reader + .next_batch + .checked_add(1) .ok_or_else(|| tinybus::Error::failed("live batch sequence is exhausted"))?; reader.pending = Some(batch.clone()); Ok(batch) } pub(super) async fn release(&self, handle: LiveHandle) -> tinybus::Result { - let lease = self.leases.lock().map_err(|_| unavailable())?.remove(&handle); + let lease = self + .leases + .lock() + .map_err(|_| unavailable())? + .remove(&handle); if let Some(lease) = lease { stop_lease(&lease).await?; } - Ok(LiveStatus { handle, state: LiveState::Closed }) + Ok(LiveStatus { + handle, + state: LiveState::Closed, + }) } pub(super) async fn shutdown(&self) -> tinybus::Result<()> { self.closed.store(true, Ordering::SeqCst); - let leases: Vec<_> = self.leases.lock().map_err(|_| unavailable())?.drain().map(|(_, lease)| lease).collect(); + let leases: Vec<_> = self + .leases + .lock() + .map_err(|_| unavailable())? + .drain() + .map(|(_, lease)| lease) + .collect(); // Release the service's connection ownership while actors retain their // own callback proxies until bounded cleanup finishes. self.connection.lock().map_err(|_| unavailable())?.take(); - for lease in &leases { lease.cancel(); } + for lease in &leases { + lease.cancel(); + } let mut failure = None; for lease in leases { - if let Err(error) = stop_lease(&lease).await { failure = Some(error); } + if let Err(error) = stop_lease(&lease).await { + failure = Some(error); + } } failure.map_or(Ok(()), Err) } @@ -266,10 +357,30 @@ async fn stop_lease(lease: &Lease) -> tinybus::Result<()> { phase.task.take() }; if let Some(mut task) = task { - if tokio::time::timeout(std::time::Duration::from_secs(2), &mut task).await.is_err() { - task.abort(); - let _ = task.await; - return Err(tinybus::Error::failed("live worker cleanup exceeded its deadline")); + match tokio::time::timeout(std::time::Duration::from_secs(2), &mut task).await { + Err(_) => { + task.abort(); + let _ = task.await; + return Err(tinybus::Error::failed( + "live worker cleanup exceeded its deadline", + )); + } + Ok(Err(_)) => { + return Err(tinybus::Error::failed( + "live worker terminated unexpectedly", + )); + } + Ok(Ok(())) => {} + } + if !lease + .phase + .lock() + .map_err(|_| unavailable())? + .cleanup_confirmed + { + return Err(tinybus::Error::failed( + "live provider stopped but host context cleanup was not acknowledged", + )); } } Ok(()) @@ -281,7 +392,9 @@ impl Drop for Sessions { for lease in leases.values() { lease.cancel(); if let Ok(mut phase) = lease.phase.lock() { - if let Some(task) = phase.task.take() { task.abort(); } + if let Some(task) = phase.task.take() { + task.abort(); + } } } } diff --git a/crates/tinyvoice-module/src/service/mod_tests.rs b/crates/tinyvoice-module/src/service/mod_tests.rs index fef344d..e3578c6 100644 --- a/crates/tinyvoice-module/src/service/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/mod_tests.rs @@ -1079,8 +1079,14 @@ async fn live_reservations_exist_before_provider_startup_and_can_be_released() { let reserved: serde_json::Value = proxy.call("LiveReserve", ()).await.unwrap(); assert_eq!(reserved["state"], "reserved"); assert_eq!(reserved["handle"]["id"].as_str().unwrap().len(), 32); - let released: serde_json::Value = proxy.call("LiveRelease", serde_json::json!({ - "handle": reserved["handle"], - })).await.unwrap(); + let released: serde_json::Value = proxy + .call( + "LiveRelease", + serde_json::json!({ + "handle": reserved["handle"], + }), + ) + .await + .unwrap(); assert_eq!(released["state"], "closed"); } From 6f4cca37f8d0c68099156026805bc0d5f9a333ad Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:34:46 +0300 Subject: [PATCH 15/59] feat(service): expose live session lifecycle over the bus Add LiveReserve, LiveStart, LiveSend, LiveRead, LiveRelease and LiveShutdown methods to the Voice interface, backed by a shared live session table wired to the bus connection at setup. This lets clients reserve, drive and tear down bounded live sessions through the module rather than only preparing input frames. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyvoice-bus/src/names/mod.rs | 18 +++++++ .../src/service/live/actor.rs | 2 +- .../src/service/live/table.rs | 5 +- crates/tinyvoice-module/src/service/mod.rs | 51 ++++++++++++++++++- 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/crates/tinyvoice-bus/src/names/mod.rs b/crates/tinyvoice-bus/src/names/mod.rs index 566b985..942c8ca 100644 --- a/crates/tinyvoice-bus/src/names/mod.rs +++ b/crates/tinyvoice-bus/src/names/mod.rs @@ -8,6 +8,18 @@ pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinyvoice/Voice"; /// One constant per member of [`BUS_NAME`]. pub mod methods { + /// Reserve a bounded live session before startup. + pub const LIVE_RESERVE: &str = "LiveReserve"; + /// Start native provider and tool-loop execution on a reserved handle. + pub const LIVE_START: &str = "LiveStart"; + /// Forward bounded audio, text, interrupt or stop input. + pub const LIVE_SEND: &str = "LiveSend"; + /// Read an ordered replayable live event batch. + pub const LIVE_READ: &str = "LiveRead"; + /// Release a live session and notify host context cleanup. + pub const LIVE_RELEASE: &str = "LiveRelease"; + /// Close admission and stop all owned live sessions. + pub const LIVE_SHUTDOWN: &str = "LiveShutdown"; /// Parse an initial or later portable live control frame. pub const PREPARE_LIVE_INPUT: &str = "PrepareLiveInput"; /// Extracts and classifies a provider-relayed conversational turn. @@ -104,6 +116,12 @@ pub const METHODS: &[&str] = &[ methods::HOTKEY_STOP, methods::IS_HALLUCINATED, methods::LIST_INPUT_DEVICES, + methods::LIVE_READ, + methods::LIVE_RELEASE, + methods::LIVE_RESERVE, + methods::LIVE_SEND, + methods::LIVE_SHUTDOWN, + methods::LIVE_START, methods::PREPARE_CAPTURE, methods::PREPARE_FRAMES, methods::PREPARE_LIVE_INPUT, diff --git a/crates/tinyvoice-module/src/service/live/actor.rs b/crates/tinyvoice-module/src/service/live/actor.rs index 5a40685..60e4cb7 100644 --- a/crates/tinyvoice-module/src/service/live/actor.rs +++ b/crates/tinyvoice-module/src/service/live/actor.rs @@ -155,7 +155,7 @@ async fn finish_context(connection: &tinybus::Connection, request: &LiveStartReq ) else { return false; }; - let call = proxy.call::<_, FinishLiveContextReply>( + let call = proxy.call::( FINISH_CALLBACK_METHOD, (FinishLiveContextRequest { context_token: request.tool_port.context_token.clone(), diff --git a/crates/tinyvoice-module/src/service/live/table.rs b/crates/tinyvoice-module/src/service/live/table.rs index a1f4afd..9c5d6cd 100644 --- a/crates/tinyvoice-module/src/service/live/table.rs +++ b/crates/tinyvoice-module/src/service/live/table.rs @@ -93,7 +93,7 @@ impl Lease { } /// Admission table; shutdown permanently closes this module instance. -pub(super) struct Sessions { +pub(in crate::service) struct Sessions { closed: AtomicBool, leases: Mutex>>, connection: Mutex>, @@ -121,7 +121,8 @@ impl Sessions { pub(super) fn with_connection(connection: tinybus::Connection) -> Self { Self { connection: Mutex::new(Some(connection)), - ..Self::default() + closed: AtomicBool::new(false), + leases: Mutex::new(HashMap::new()), } } diff --git a/crates/tinyvoice-module/src/service/mod.rs b/crates/tinyvoice-module/src/service/mod.rs index 0122048..81cc41f 100644 --- a/crates/tinyvoice-module/src/service/mod.rs +++ b/crates/tinyvoice-module/src/service/mod.rs @@ -59,6 +59,7 @@ pub struct VoiceService { sessions: std::sync::Mutex, capture: std::sync::Arc, hotkeys: std::sync::Arc, + live: std::sync::Arc, } /// The session table and the counter that names its entries. @@ -188,6 +189,42 @@ fn to_json(value: &T) -> TinyBusResult { )] #[tinybus::interface(name = "ai.tinyhumans.tinyvoice.Voice")] impl VoiceService { + /// Reserve a bounded live lease before any provider connection begins. + async fn live_reserve(&self) -> TinyBusResult { + self.live.reserve() + } + /// Begin provider startup idempotently on a known reservation. + async fn live_start( + &self, + request: tinyvoice_bus::live::session::LiveStartRequest, + ) -> TinyBusResult { + self.live.start(request) + } + /// Forward bounded input to the existing native provider sender. + async fn live_send( + &self, + request: tinyvoice_bus::live::session::LiveSendRequest, + ) -> TinyBusResult { + self.live.send(request).await + } + /// Read ordered effects, replaying the current batch until acknowledgement. + async fn live_read( + &self, + request: tinyvoice_bus::live::session::LiveReadRequest, + ) -> TinyBusResult { + self.live.read(request).await + } + /// Stop owned tasks and notify host context cleanup within a finite deadline. + async fn live_release( + &self, + request: tinyvoice_bus::live::session::LiveHandleRequest, + ) -> TinyBusResult { + self.live.release(request.handle).await + } + /// Permanently close admission and stop all owned live tasks. + async fn live_shutdown(&self) -> TinyBusResult<()> { + self.live.shutdown().await + } /// Parse portable live control frames without applying host policy. async fn prepare_live_input( &self, @@ -674,7 +711,13 @@ impl VoiceService { /// Claim the bus name and serve the interface. async fn setup(connection: Connection) -> TinyBusResult<()> { connection - .serve_at(names::OBJECT_PATH.try_into()?, VoiceService::default()) + .serve_at( + names::OBJECT_PATH.try_into()?, + VoiceService { + live: std::sync::Arc::new(live::Sessions::with_connection(connection.clone())), + ..VoiceService::default() + }, + ) .await?; connection.request_name(names::BUS_NAME).await?; Ok(()) @@ -698,6 +741,12 @@ tinybus_module::module_export_optional_static! { "PrepareFrames", "PrepareRelayTurn", "PrepareLiveInput", + "LiveReserve", + "LiveStart", + "LiveSend", + "LiveRead", + "LiveRelease", + "LiveShutdown", "FrameEnergies", "CaptureStart", "CapturePoll", From 13c47237743c60f8bb60775da08036b85f1edbeb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:41:19 +0300 Subject: [PATCH 16/59] fix(live): reject start on closed or cancelled sessions The start path now checks the session's closed flag and the lease's cancelled flag before admitting a reservation, so a session that was shut down or cancelled can no longer be started. Visibility of the session methods was also widened to the service module to match their actual callers. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/service/live/table.rs | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/crates/tinyvoice-module/src/service/live/table.rs b/crates/tinyvoice-module/src/service/live/table.rs index 9c5d6cd..7e222b0 100644 --- a/crates/tinyvoice-module/src/service/live/table.rs +++ b/crates/tinyvoice-module/src/service/live/table.rs @@ -118,7 +118,7 @@ impl Default for Sessions { } impl Sessions { - pub(super) fn with_connection(connection: tinybus::Connection) -> Self { + pub(in crate::service) fn with_connection(connection: tinybus::Connection) -> Self { Self { connection: Mutex::new(Some(connection)), closed: AtomicBool::new(false), @@ -126,7 +126,7 @@ impl Sessions { } } - pub(super) fn reserve(&self) -> tinybus::Result { + pub(in crate::service) fn reserve(&self) -> tinybus::Result { let mut leases = self.leases.lock().map_err(|_| unavailable())?; if self.closed.load(Ordering::SeqCst) { return Err(tinybus::Error::failed("live module admission is closed")); @@ -164,7 +164,10 @@ impl Sessions { .ok_or_else(|| tinybus::Error::failed("unknown live session handle")) } - pub(super) fn start(&self, request: LiveStartRequest) -> tinybus::Result { + pub(in crate::service) fn start( + &self, + request: LiveStartRequest, + ) -> tinybus::Result { if !super::super::relay::fits(&request, MAX_BATCH_BYTES) { return Err(tinybus::Error::failed( "live startup exceeds the serialized size limit", @@ -189,7 +192,11 @@ impl Sessions { "live session was already started with different inputs", )); } - if phase.state != LiveState::Reserved || lease.created.elapsed() >= RESERVATION_TTL { + if self.closed.load(Ordering::SeqCst) + || lease.cancelled.load(Ordering::SeqCst) + || phase.state != LiveState::Reserved + || lease.created.elapsed() >= RESERVATION_TTL + { return Err(tinybus::Error::failed( "live reservation is closed or expired", )); @@ -209,7 +216,10 @@ impl Sessions { }) } - pub(super) async fn send(&self, request: LiveSendRequest) -> tinybus::Result { + pub(in crate::service) async fn send( + &self, + request: LiveSendRequest, + ) -> tinybus::Result { use base64::Engine as _; let lease = self.lease(&request.handle)?; let sender = { @@ -250,7 +260,10 @@ impl Sessions { lease.status(request.handle) } - pub(super) async fn read(&self, request: LiveReadRequest) -> tinybus::Result { + pub(in crate::service) async fn read( + &self, + request: LiveReadRequest, + ) -> tinybus::Result { let lease = self.lease(&request.handle)?; let mut reader = lease.reader.lock().await; if let Some(ack) = request.acknowledged_batch { @@ -309,7 +322,10 @@ impl Sessions { Ok(batch) } - pub(super) async fn release(&self, handle: LiveHandle) -> tinybus::Result { + pub(in crate::service) async fn release( + &self, + handle: LiveHandle, + ) -> tinybus::Result { let lease = self .leases .lock() @@ -324,7 +340,7 @@ impl Sessions { }) } - pub(super) async fn shutdown(&self) -> tinybus::Result<()> { + pub(in crate::service) async fn shutdown(&self) -> tinybus::Result<()> { self.closed.store(true, Ordering::SeqCst); let leases: Vec<_> = self .leases From 71158e40a9e8b3656ccbe7411d95e26747512640 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:42:42 +0300 Subject: [PATCH 17/59] test(live): add session table tests with tokio test-util Adds a test module for the live session table and enables tokio's test-util feature in dev-dependencies so tests can control time. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyvoice-module/Cargo.toml | 2 +- .../src/service/live/table.rs | 4 + .../src/service/live/table_tests.rs | 152 ++++++++++++++++++ 3 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 crates/tinyvoice-module/src/service/live/table_tests.rs diff --git a/crates/tinyvoice-module/Cargo.toml b/crates/tinyvoice-module/Cargo.toml index 2a30279..0f5dcc5 100644 --- a/crates/tinyvoice-module/Cargo.toml +++ b/crates/tinyvoice-module/Cargo.toml @@ -81,7 +81,7 @@ broken_intra_doc_links = "warn" private_intra_doc_links = "warn" [dev-dependencies] -tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "test-util"] } [patch.crates-io] tinybus = { path = "../../vendor/tinybus/crates/tinybus" } diff --git a/crates/tinyvoice-module/src/service/live/table.rs b/crates/tinyvoice-module/src/service/live/table.rs index 7e222b0..4b63986 100644 --- a/crates/tinyvoice-module/src/service/live/table.rs +++ b/crates/tinyvoice-module/src/service/live/table.rs @@ -421,3 +421,7 @@ impl Drop for Sessions { fn unavailable() -> tinybus::Error { tinybus::Error::failed("live session state is unavailable") } + +#[cfg(test)] +#[path = "table_tests.rs"] +mod tests; diff --git a/crates/tinyvoice-module/src/service/live/table_tests.rs b/crates/tinyvoice-module/src/service/live/table_tests.rs new file mode 100644 index 0000000..3ab4cd3 --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/table_tests.rs @@ -0,0 +1,152 @@ +use super::*; + +fn read_request(handle: &LiveHandle, ack: Option) -> LiveReadRequest { + LiveReadRequest { + handle: handle.clone(), + acknowledged_batch: ack, + } +} + +#[tokio::test(start_paused = true)] +async fn abandoned_reservations_expire_without_evicting_running_sessions() -> tinybus::Result<()> { + let sessions = Sessions::default(); + let active = sessions.reserve()?.handle; + sessions + .lease(&active)? + .phase + .lock() + .map_err(|_| unavailable())? + .state = LiveState::Running; + for _ in 1..MAX_LEASES { + sessions.reserve()?; + } + assert!(sessions.reserve().is_err()); + tokio::time::advance(RESERVATION_TTL).await; + sessions.reserve()?; + assert_eq!( + sessions.lease(&active)?.status(active)?.state, + LiveState::Running + ); + assert_eq!(sessions.leases.lock().map_err(|_| unavailable())?.len(), 2); + sessions.shutdown().await +} + +#[tokio::test] +async fn unacknowledged_batches_replay_without_repeating_committed_persistence() +-> tinybus::Result<()> { + let sessions = Sessions::default(); + let handle = sessions.reserve()?.handle; + let lease = sessions.lease(&handle)?; + let group = vec![LiveOutput::Persist { + role: tinyvoice_bus::live::TranscriptRole::User, + text: "first".into(), + }]; + lease + .outputs + .send(group.clone()) + .await + .map_err(|_| unavailable())?; + let first = sessions.read(read_request(&handle, None)).await?; + lease + .outputs + .send(vec![LiveOutput::Persist { + role: tinyvoice_bus::live::TranscriptRole::Agent, + text: "second".into(), + }]) + .await + .map_err(|_| unavailable())?; + assert_eq!(first.outputs, group); + assert_eq!(sessions.read(read_request(&handle, None)).await?, first); + assert!( + sessions + .read(read_request(&handle, Some(first.batch + 1))) + .await + .is_err() + ); + let second = sessions + .read(read_request(&handle, Some(first.batch))) + .await?; + assert_eq!(second.batch, first.batch + 1); + assert_eq!( + sessions + .read(read_request(&handle, Some(first.batch))) + .await?, + second + ); + let empty = sessions + .read(read_request(&handle, Some(second.batch))) + .await?; + assert!(empty.outputs.is_empty()); + assert_eq!(empty.batch, second.batch); + sessions.shutdown().await +} + +struct Dropped(Arc); +impl Drop for Dropped { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } +} + +fn blocked_task(lease: &Lease, dropped: Arc) -> tinybus::Result<()> { + lease.phase.lock().map_err(|_| unavailable())?.task = Some(tokio::spawn(async move { + let _guard = Dropped(dropped); + std::future::pending::<()>().await; + })); + Ok(()) +} + +#[tokio::test(start_paused = true)] +async fn unresponsive_cleanup_is_aborted_and_release_retries_do_not_restart_it() +-> tinybus::Result<()> { + let sessions = Sessions::default(); + let handle = sessions.reserve()?.handle; + let lease = sessions.lease(&handle)?; + let dropped = Arc::new(AtomicBool::new(false)); + blocked_task(&lease, dropped.clone())?; + tokio::task::yield_now().await; + let before = tokio::time::Instant::now(); + assert!(sessions.release(handle.clone()).await.is_err()); + assert_eq!(before.elapsed(), std::time::Duration::from_secs(2)); + assert!(dropped.load(Ordering::SeqCst)); + assert!(lease.cancelled.load(Ordering::SeqCst)); + assert_eq!( + sessions.release(handle.clone()).await?.state, + LiveState::Closed + ); + assert!(sessions.lease(&handle).is_err()); + Ok(()) +} + +#[tokio::test(start_paused = true)] +async fn shutdown_has_one_cleanup_deadline_for_all_unresponsive_sessions() -> tinybus::Result<()> { + let sessions = Sessions::default(); + let mut guards = Vec::new(); + for _ in 0..3 { + let handle = sessions.reserve()?.handle; + let dropped = Arc::new(AtomicBool::new(false)); + blocked_task(&sessions.lease(&handle)?, dropped.clone())?; + guards.push(dropped); + } + tokio::task::yield_now().await; + let before = tokio::time::Instant::now(); + assert!(sessions.shutdown().await.is_err()); + assert_eq!(before.elapsed(), std::time::Duration::from_secs(2)); + assert!(guards.iter().all(|guard| guard.load(Ordering::SeqCst))); + assert!(sessions.reserve().is_err()); + sessions.shutdown().await +} + +#[tokio::test] +async fn dropping_module_aborts_owned_workers_without_waiting_for_host_cleanup() +-> tinybus::Result<()> { + let sessions = Sessions::default(); + let handle = sessions.reserve()?.handle; + let dropped = Arc::new(AtomicBool::new(false)); + blocked_task(&sessions.lease(&handle)?, dropped.clone())?; + tokio::task::yield_now().await; + drop(sessions); + tokio::task::yield_now().await; + assert!(dropped.load(Ordering::SeqCst)); + Ok(()) +} From b6968483e865c7c9d9bba168f9d72f723f068016 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:43:26 +0300 Subject: [PATCH 18/59] test(live): bind session lease before spawning blocked task The two shutdown tests now hold the session lease in a local binding before passing it to the blocked task, so the lease outlives the call instead of being dropped as a temporary. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyvoice-module/src/service/live/table_tests.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/tinyvoice-module/src/service/live/table_tests.rs b/crates/tinyvoice-module/src/service/live/table_tests.rs index 3ab4cd3..caaa828 100644 --- a/crates/tinyvoice-module/src/service/live/table_tests.rs +++ b/crates/tinyvoice-module/src/service/live/table_tests.rs @@ -125,7 +125,8 @@ async fn shutdown_has_one_cleanup_deadline_for_all_unresponsive_sessions() -> ti for _ in 0..3 { let handle = sessions.reserve()?.handle; let dropped = Arc::new(AtomicBool::new(false)); - blocked_task(&sessions.lease(&handle)?, dropped.clone())?; + let lease = sessions.lease(&handle)?; + blocked_task(&lease, dropped.clone())?; guards.push(dropped); } tokio::task::yield_now().await; @@ -143,7 +144,8 @@ async fn dropping_module_aborts_owned_workers_without_waiting_for_host_cleanup() let sessions = Sessions::default(); let handle = sessions.reserve()?.handle; let dropped = Arc::new(AtomicBool::new(false)); - blocked_task(&sessions.lease(&handle)?, dropped.clone())?; + let lease = sessions.lease(&handle)?; + blocked_task(&lease, dropped.clone())?; tokio::task::yield_now().await; drop(sessions); tokio::task::yield_now().await; From 3c67f572dd38e75d8aa4a622504232200ac00625 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:43:58 +0300 Subject: [PATCH 19/59] test(live): cover release cancellation keeping worker attached Add a test asserting that aborting an in-flight release does not detach the owned provider worker, so the worker is still dropped when the lease goes away. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/service/live/table_tests.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/tinyvoice-module/src/service/live/table_tests.rs b/crates/tinyvoice-module/src/service/live/table_tests.rs index caaa828..884762d 100644 --- a/crates/tinyvoice-module/src/service/live/table_tests.rs +++ b/crates/tinyvoice-module/src/service/live/table_tests.rs @@ -152,3 +152,28 @@ async fn dropping_module_aborts_owned_workers_without_waiting_for_host_cleanup() assert!(dropped.load(Ordering::SeqCst)); Ok(()) } + +#[tokio::test(start_paused = true)] +async fn cancelling_release_does_not_detach_the_owned_provider_worker() -> tinybus::Result<()> { + let sessions = Arc::new(Sessions::default()); + let handle = sessions.reserve()?.handle; + let lease = sessions.lease(&handle)?; + let dropped = Arc::new(AtomicBool::new(false)); + blocked_task(&lease, dropped.clone())?; + tokio::task::yield_now().await; + let releasing = tokio::spawn(async move { sessions.release(handle).await }); + tokio::task::yield_now().await; + assert!( + lease + .phase + .lock() + .map_err(|_| unavailable())? + .task + .is_none() + ); + releasing.abort(); + let _ = releasing.await; + tokio::task::yield_now().await; + assert!(dropped.load(Ordering::SeqCst)); + Ok(()) +} From 44a95373111a7f855e9f91284de1eb4e609a4fe2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:44:26 +0300 Subject: [PATCH 20/59] fix(live): run session cleanup concurrently and abort workers on cancel Shutdown now stops all admitted sessions in parallel via a JoinSet so the deadline no longer scales with session count, and cleanup futures are aborted if the caller cancels. Provider worker handles are wrapped so dropping a cleanup future aborts the task instead of detaching it. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/service/live/table.rs | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/crates/tinyvoice-module/src/service/live/table.rs b/crates/tinyvoice-module/src/service/live/table.rs index 4b63986..fe190f9 100644 --- a/crates/tinyvoice-module/src/service/live/table.rs +++ b/crates/tinyvoice-module/src/service/live/table.rs @@ -355,10 +355,23 @@ impl Sessions { for lease in &leases { lease.cancel(); } - let mut failure = None; + // Start every bounded cleanup together: the shutdown deadline must not + // multiply by the number of admitted sessions. JoinSet also aborts its + // cleanup futures if the caller cancels shutdown. + let mut cleanups = tokio::task::JoinSet::new(); for lease in leases { - if let Err(error) = stop_lease(&lease).await { - failure = Some(error); + cleanups.spawn(async move { stop_lease(&lease).await }); + } + let mut failure = None; + while let Some(result) = cleanups.join_next().await { + match result { + Ok(Ok(())) => {} + Ok(Err(error)) => failure = Some(error), + Err(_) => { + failure = Some(tinybus::Error::failed( + "live cleanup task terminated unexpectedly", + )) + } } } failure.map_or(Ok(()), Err) @@ -373,11 +386,14 @@ async fn stop_lease(lease: &Lease) -> tinybus::Result<()> { phase.state = LiveState::Closed; phase.task.take() }; - if let Some(mut task) = task { - match tokio::time::timeout(std::time::Duration::from_secs(2), &mut task).await { + if let Some(task) = task { + // Cancellation of Release/Shutdown must not drop a bare JoinHandle, + // which would detach the provider worker instead of terminating it. + let mut owned = OwnedTask(task); + match tokio::time::timeout(std::time::Duration::from_secs(2), &mut owned.0).await { Err(_) => { - task.abort(); - let _ = task.await; + owned.0.abort(); + let _ = (&mut owned.0).await; return Err(tinybus::Error::failed( "live worker cleanup exceeded its deadline", )); @@ -403,6 +419,15 @@ async fn stop_lease(lease: &Lease) -> tinybus::Result<()> { Ok(()) } +/// Abort even when the caller drops a cleanup future before its deadline. +struct OwnedTask(JoinHandle<()>); + +impl Drop for OwnedTask { + fn drop(&mut self) { + self.0.abort(); + } +} + impl Drop for Sessions { fn drop(&mut self) { if let Ok(leases) = self.leases.get_mut() { From ebefcf77503d64f095b1fc177b023b1694ce6df7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:47:09 +0300 Subject: [PATCH 21/59] test(live): add test module for callback tool Wire the callback tool's test file into the module tree so its tests are compiled and run. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/service/live/callback.rs | 4 + .../src/service/live/callback_tests.rs | 171 ++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 crates/tinyvoice-module/src/service/live/callback_tests.rs diff --git a/crates/tinyvoice-module/src/service/live/callback.rs b/crates/tinyvoice-module/src/service/live/callback.rs index edb6954..2141fb2 100644 --- a/crates/tinyvoice-module/src/service/live/callback.rs +++ b/crates/tinyvoice-module/src/service/live/callback.rs @@ -97,3 +97,7 @@ impl ToolDispatch<(), ()> for CallbackTool { Ok(serde_json::from_value(reply.result)?) } } + +#[cfg(test)] +#[path = "callback_tests.rs"] +mod tests; diff --git a/crates/tinyvoice-module/src/service/live/callback_tests.rs b/crates/tinyvoice-module/src/service/live/callback_tests.rs new file mode 100644 index 0000000..c2a71b5 --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/callback_tests.rs @@ -0,0 +1,171 @@ +//! Native bus fixtures for exact tool provenance and current host authority. + +use super::*; +use serde_json::json; +use std::sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, +}; +use tinyagents_harness::context::RunConfig; +use tinybus::broker::Broker; +use tinybus::transport::memory::MemoryBus; + +#[derive(Clone, Default)] +struct Authority { + revoked: Arc, + malformed: Arc, + seen: Arc>>, +} + +#[tinybus::interface(name = "ai.tinyhumans.tinyvoice.LiveTools")] +impl Authority { + async fn execute_live_tool( + &self, + request: ExecuteLiveToolRequest, + ) -> tinybus::Result { + if request.context_token != "known-context" || request.name != "approved" { + return Err(tinybus::Error::failed("unknown host authority")); + } + self.seen + .lock() + .map_err(|_| tinybus::Error::failed("fixture lock"))? + .push(request); + if self.malformed.load(Ordering::SeqCst) { + return Ok(ExecuteLiveToolReply { + result: json!({"invented_success": true}), + }); + } + let mut result = if self.revoked.load(Ordering::SeqCst) { + ToolResult::error("revoked by current host policy") + } else { + ToolResult::success("approved result") + }; + result.metadata = Some(json!({"host_only": "retained"})); + result.markdown_formatted = Some("rendered".into()); + Ok(ExecuteLiveToolReply { + result: serde_json::to_value(result) + .map_err(|error| tinybus::Error::failed(error.to_string()))?, + }) + } +} + +async fn fixture() -> tinybus::Result<(CallbackTool, Authority, tinybus::Connection)> { + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let authority = Authority::default(); + let host = tinybus::Connection::connect(bus.connect().await?).await?; + host.object_server() + .serve_at("/test/LiveTools".try_into()?, authority.clone()) + .await?; + host.request_name("ai.test.LiveTools").await?; + let module = tinybus::Connection::connect(bus.connect().await?).await?; + let tool = CallbackTool::new( + LiveToolDeclaration { + name: "approved".into(), + description: "approved declaration".into(), + parameters: json!({"type": "object"}), + prefer_markdown: true, + }, + LiveToolPort { + bus_name: "ai.test.LiveTools".into(), + object_path: "/test/LiveTools".into(), + context_token: "known-context".into(), + }, + &module, + )?; + Ok((tool, authority, host)) +} + +fn context() -> RunContext<()> { + RunContext::new(RunConfig::new("fixture"), ()) +} + +#[tokio::test] +async fn callback_preserves_native_call_identity_arguments_and_complete_host_result() +-> anyhow::Result<()> { + let (tool, authority, _host) = fixture().await?; + let result = tool + .execute( + &(), + CallId::new("provider-call-7"), + json!({"argument": "value"}), + ToolCallOptions { + prefer_markdown: true, + }, + &context(), + ) + .await?; + assert!(!result.is_error); + assert_eq!(result.markdown_formatted.as_deref(), Some("rendered")); + assert_eq!(result.metadata, Some(json!({"host_only": "retained"}))); + let seen = authority + .seen + .lock() + .map_err(|_| anyhow::anyhow!("fixture lock"))?; + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].call_id, "provider-call-7"); + assert_eq!(seen[0].arguments, json!({"argument": "value"})); + assert!(seen[0].prefer_markdown); + Ok(()) +} + +#[tokio::test] +async fn saved_declaration_never_overrides_revoked_live_host_policy() -> anyhow::Result<()> { + let (tool, authority, _host) = fixture().await?; + authority.revoked.store(true, Ordering::SeqCst); + let result = tool + .execute( + &(), + CallId::new("revoked-call"), + json!({}), + ToolCallOptions::default(), + &context(), + ) + .await?; + assert!(result.is_error); + assert_eq!(result.text(), "revoked by current host policy"); + assert!(tool.tool().execute(json!({})).await.is_err()); + Ok(()) +} + +#[tokio::test] +async fn malformed_host_result_is_not_fabricated_as_success() -> anyhow::Result<()> { + let (tool, authority, _host) = fixture().await?; + authority.malformed.store(true, Ordering::SeqCst); + assert!( + tool.execute( + &(), + CallId::new("bad-reply"), + json!({}), + ToolCallOptions::default(), + &context() + ) + .await + .is_err() + ); + Ok(()) +} + +#[tokio::test] +async fn oversized_tool_arguments_fail_before_calling_host_executor() -> anyhow::Result<()> { + let (tool, authority, _host) = fixture().await?; + assert!( + tool.execute( + &(), + CallId::new("oversized"), + json!({"payload": "x".repeat(1024 * 1024)}), + ToolCallOptions::default(), + &context() + ) + .await + .is_err() + ); + assert!( + authority + .seen + .lock() + .map_err(|_| anyhow::anyhow!("fixture lock"))? + .is_empty() + ); + Ok(()) +} From e06c9b7d7fcd56f97dc205cea38ed6ef99ff890f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:48:35 +0300 Subject: [PATCH 22/59] test(live): add actor tests and simplify callback fixture Adds a test module for the live actor and switches the callback test fixture to the connection's serve_at helper, which is the current API. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/service/live/actor.rs | 4 ++++ .../src/service/live/actor_tests.rs | 21 +++++++++++++++++++ .../src/service/live/callback_tests.rs | 3 +-- 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 crates/tinyvoice-module/src/service/live/actor_tests.rs diff --git a/crates/tinyvoice-module/src/service/live/actor.rs b/crates/tinyvoice-module/src/service/live/actor.rs index 60e4cb7..f066f23 100644 --- a/crates/tinyvoice-module/src/service/live/actor.rs +++ b/crates/tinyvoice-module/src/service/live/actor.rs @@ -163,3 +163,7 @@ async fn finish_context(connection: &tinybus::Connection, request: &LiveStartReq ); matches!(tokio::time::timeout(FINISH_TIMEOUT, call).await, Ok(Ok(reply)) if reply.finished) } + +#[cfg(test)] +#[path = "actor_tests.rs"] +mod tests; diff --git a/crates/tinyvoice-module/src/service/live/actor_tests.rs b/crates/tinyvoice-module/src/service/live/actor_tests.rs new file mode 100644 index 0000000..6b57369 --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/actor_tests.rs @@ -0,0 +1,21 @@ +//! Bounded provider text and host cleanup regressions. + +use super::*; + +#[test] +fn oversized_provider_errors_are_rejected_before_event_projection() { + let error = Error::Provider("x".repeat(MAX_TEXT_BYTES + 1)); + assert!(oversized(&LiveAgentEvent::Live(LiveEvent::Error { + error: error.clone(), + fatal: false + }))); + assert!(oversized(&LiveAgentEvent::Live(LiveEvent::Closed( + CloseReason::Error(error) + )))); + assert!(oversized(&LiveAgentEvent::Live(LiveEvent::Closed( + CloseReason::Remote { + code: Some(1000), + reason: "x".repeat(MAX_TEXT_BYTES + 1) + } + )))); +} diff --git a/crates/tinyvoice-module/src/service/live/callback_tests.rs b/crates/tinyvoice-module/src/service/live/callback_tests.rs index c2a71b5..9a0ee5c 100644 --- a/crates/tinyvoice-module/src/service/live/callback_tests.rs +++ b/crates/tinyvoice-module/src/service/live/callback_tests.rs @@ -54,8 +54,7 @@ async fn fixture() -> tinybus::Result<(CallbackTool, Authority, tinybus::Connect Broker::new().spawn(bus.clone()); let authority = Authority::default(); let host = tinybus::Connection::connect(bus.connect().await?).await?; - host.object_server() - .serve_at("/test/LiveTools".try_into()?, authority.clone()) + host.serve_at("/test/LiveTools".try_into()?, authority.clone()) .await?; host.request_name("ai.test.LiveTools").await?; let module = tinybus::Connection::connect(bus.connect().await?).await?; From bf1c6db3002e332987a6fa4eb8f9269668308c23 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:49:57 +0300 Subject: [PATCH 23/59] fix(live): bound oversized live events and harden context cleanup Live event projection now rejects oversized session ids, provider errors and remote close reasons before they reach the host, and context cleanup takes the tool port directly so repeated or late finish calls only revoke a known host context once. Tests cover the unresponsive-host timeout and idempotent revocation paths, and the module docs now list the 41 methods under contract 1.7. Auto-committed-on: dragonfly Co-authored-by: Medulla --- MODULE.md | 9 +- .../src/service/live/actor.rs | 33 +++++-- .../src/service/live/actor_tests.rs | 88 +++++++++++++++++++ .../src/service/live/projection_tests.rs | 2 + .../src/service/live/table_tests.rs | 2 + crates/tinyvoice/src/live/mod_tests.rs | 2 + docs/specs/module-capture.md | 4 +- 7 files changed, 132 insertions(+), 8 deletions(-) diff --git a/MODULE.md b/MODULE.md index af80d63..ae1ed12 100644 --- a/MODULE.md +++ b/MODULE.md @@ -4,7 +4,7 @@ This package contains the native `tinyvoice` module for TinyBus module ABI v1. Install only the archive matching the host operating system and architecture. The module claims `ai.tinyhumans.tinyvoice.Voice`, serves the object at -`/ai/tinyhumans/tinyvoice/Voice`, and provides 34 methods under contract 1.6. +`/ai/tinyhumans/tinyvoice/Voice`, and provides 41 methods under contract 1.7. ## Methods @@ -21,6 +21,13 @@ The module claims `ai.tinyhumans.tinyvoice.Voice`, serves the object at | `VadReset` | `session` | — | | `VadClose` | `session` | — | | `PrepareRelayTurn` | `RelayTurnRequest { messages, readback_prefix }` | `PreparedRelayTurn`: prompt, ordered history pairs, optional readback payload, content-free, speak-back and answerable flags | +| `PrepareLiveInput` | `PrepareLiveInputRequest { awaiting_start, frame }` | parsed control or transport action; host owns policy validation | +| `LiveReserve` | none | opaque `LiveStatus` reserved before connection startup | +| `LiveStart` | `LiveStartRequest` | idempotent starting/running status for identical inputs | +| `LiveSend` | handle and text, PCM16 audio, interrupt or stop command | status after bounded input admission | +| `LiveRead` | handle and optional acknowledged batch | bounded replayable ordered frames and persistence effects | +| `LiveRelease` | handle | closed status after bounded provider/context cleanup | +| `LiveShutdown` | none | terminal admission shutdown and concurrent bounded cleanup | | `PrepareFrames` | `samples`, `source_rate`, `channels` | base64 `f32` mono @ 16 kHz | | `FrameEnergies` | `samples`, `frame_len` | per-frame RMS | | `EncodeWav` | `samples` (`f32` mono), `sample_rate` | base64 WAV | diff --git a/crates/tinyvoice-module/src/service/live/actor.rs b/crates/tinyvoice-module/src/service/live/actor.rs index f066f23..d9ab1ac 100644 --- a/crates/tinyvoice-module/src/service/live/actor.rs +++ b/crates/tinyvoice-module/src/service/live/actor.rs @@ -66,7 +66,7 @@ pub(super) async fn run( } None => {} } - let confirmed = finish_context(&connection, &request).await; + let confirmed = finish_context(&connection, &request.tool_port).await; if let Ok(mut phase) = lease.phase.lock() { phase.sender = None; phase.state = LiveState::Closed; @@ -139,6 +139,26 @@ fn oversized(event: &LiveAgentEvent) -> bool { | LiveAgentEvent::ToolFinished { call_id, name, .. } => { call_id.len() > MAX_TEXT_BYTES || name.len() > MAX_TEXT_BYTES } + LiveAgentEvent::Live(LiveEvent::Ready(info)) => info + .session_id + .as_ref() + .is_some_and(|id| id.len() > MAX_TEXT_BYTES), + LiveAgentEvent::Live( + LiveEvent::Error { error, .. } | LiveEvent::Closed(CloseReason::Error(error)), + ) => oversized_error(error), + LiveAgentEvent::Live(LiveEvent::Closed(CloseReason::Remote { reason, .. })) => { + reason.len() > MAX_TEXT_BYTES + } + _ => false, + } +} + +fn oversized_error(error: &Error) -> bool { + match error { + Error::InvalidConfig(text) + | Error::Connect(text) + | Error::Provider(text) + | Error::Protocol(text) => text.len() > MAX_TEXT_BYTES, _ => false, } } @@ -147,10 +167,13 @@ fn oversized(event: &LiveAgentEvent) -> bool { /// /// The host owns its work: it must also cancel it when its context lease drops. /// This notification revokes a known context; it never creates one. -async fn finish_context(connection: &tinybus::Connection, request: &LiveStartRequest) -> bool { +async fn finish_context( + connection: &tinybus::Connection, + port: &tinyvoice_bus::live::session::LiveToolPort, +) -> bool { let Ok(proxy) = connection.proxy( - request.tool_port.bus_name.as_str(), - request.tool_port.object_path.as_str(), + port.bus_name.as_str(), + port.object_path.as_str(), TOOL_CALLBACK_INTERFACE, ) else { return false; @@ -158,7 +181,7 @@ async fn finish_context(connection: &tinybus::Connection, request: &LiveStartReq let call = proxy.call::( FINISH_CALLBACK_METHOD, (FinishLiveContextRequest { - context_token: request.tool_port.context_token.clone(), + context_token: port.context_token.clone(), },), ); matches!(tokio::time::timeout(FINISH_TIMEOUT, call).await, Ok(Ok(reply)) if reply.finished) diff --git a/crates/tinyvoice-module/src/service/live/actor_tests.rs b/crates/tinyvoice-module/src/service/live/actor_tests.rs index 6b57369..8178d8e 100644 --- a/crates/tinyvoice-module/src/service/live/actor_tests.rs +++ b/crates/tinyvoice-module/src/service/live/actor_tests.rs @@ -19,3 +19,91 @@ fn oversized_provider_errors_are_rejected_before_event_projection() { } )))); } + +use std::sync::{ + Mutex, + atomic::{AtomicUsize, Ordering}, +}; +use tinybus::broker::Broker; +use tinybus::transport::memory::MemoryBus; +use tinyvoice_bus::live::session::LiveToolPort; + +#[derive(Clone, Default)] +struct HostCleanup { + finishes: Arc, + known_finished: Arc>, + unresponsive: bool, +} + +#[tinybus::interface(name = "ai.tinyhumans.tinyvoice.LiveTools")] +impl HostCleanup { + async fn finish_live_context( + &self, + request: FinishLiveContextRequest, + ) -> tinybus::Result { + if request.context_token != "known-context" { + return Ok(FinishLiveContextReply { finished: false }); + } + if self.unresponsive { + std::future::pending::<()>().await; + } + let mut finished = self + .known_finished + .lock() + .map_err(|_| tinybus::Error::failed("fixture lock"))?; + if !*finished { + *finished = true; + self.finishes.fetch_add(1, Ordering::SeqCst); + } + Ok(FinishLiveContextReply { finished: true }) + } +} + +async fn cleanup_fixture( + unresponsive: bool, +) -> tinybus::Result<( + tinybus::Connection, + tinybus::Connection, + LiveToolPort, + HostCleanup, +)> { + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let host = tinybus::Connection::connect(bus.connect().await?).await?; + let cleanup = HostCleanup { + unresponsive, + ..HostCleanup::default() + }; + host.serve_at("/test/LiveTools".try_into()?, cleanup.clone()) + .await?; + host.request_name("ai.test.LiveTools").await?; + let module = tinybus::Connection::connect(bus.connect().await?).await?; + let port = LiveToolPort { + bus_name: "ai.test.LiveTools".into(), + object_path: "/test/LiveTools".into(), + context_token: "known-context".into(), + }; + Ok((module, host, port, cleanup)) +} + +#[tokio::test(start_paused = true)] +async fn unresponsive_host_cleanup_expires_without_acknowledging_success() -> tinybus::Result<()> { + let (module, _host, port, cleanup) = cleanup_fixture(true).await?; + let before = tokio::time::Instant::now(); + assert!(!finish_context(&module, &port).await); + assert_eq!(before.elapsed(), FINISH_TIMEOUT); + assert_eq!(cleanup.finishes.load(Ordering::SeqCst), 0); + Ok(()) +} + +#[tokio::test] +async fn repeated_and_late_finish_only_revoke_known_host_context_once() -> tinybus::Result<()> { + let (module, _host, mut port, cleanup) = cleanup_fixture(false).await?; + assert!(finish_context(&module, &port).await); + assert!(finish_context(&module, &port).await); + assert_eq!(cleanup.finishes.load(Ordering::SeqCst), 1); + port.context_token = "unknown-late-token".into(); + assert!(!finish_context(&module, &port).await); + assert_eq!(cleanup.finishes.load(Ordering::SeqCst), 1); + Ok(()) +} diff --git a/crates/tinyvoice-module/src/service/live/projection_tests.rs b/crates/tinyvoice-module/src/service/live/projection_tests.rs index 7ce0905..59ca9e8 100644 --- a/crates/tinyvoice-module/src/service/live/projection_tests.rs +++ b/crates/tinyvoice-module/src/service/live/projection_tests.rs @@ -1,3 +1,5 @@ +//! Portable live-session regression fixtures. + use super::*; use tinyagents_live::tinyliveagents::{AudioFormat, Error as LiveError, SessionInfo, ToolCall}; diff --git a/crates/tinyvoice-module/src/service/live/table_tests.rs b/crates/tinyvoice-module/src/service/live/table_tests.rs index 884762d..fd75fd1 100644 --- a/crates/tinyvoice-module/src/service/live/table_tests.rs +++ b/crates/tinyvoice-module/src/service/live/table_tests.rs @@ -1,3 +1,5 @@ +//! Portable live-session regression fixtures. + use super::*; fn read_request(handle: &LiveHandle, ack: Option) -> LiveReadRequest { diff --git a/crates/tinyvoice/src/live/mod_tests.rs b/crates/tinyvoice/src/live/mod_tests.rs index b586552..ebb7dd2 100644 --- a/crates/tinyvoice/src/live/mod_tests.rs +++ b/crates/tinyvoice/src/live/mod_tests.rs @@ -1,3 +1,5 @@ +//! Portable live-session regression fixtures. + use super::*; #[test] diff --git a/docs/specs/module-capture.md b/docs/specs/module-capture.md index 4459fba..31905bd 100644 --- a/docs/specs/module-capture.md +++ b/docs/specs/module-capture.md @@ -76,13 +76,13 @@ names, error detail, handles, audio, paths and request arguments. Local fixtures exercise lifecycle, bounds, faults and in-memory bus dispatch without hardware or external services. The actual compiled artifact verifier -checks all 34 declared members and permission denial without opening a device. +checks all 41 declared members and permission denial without opening a device. The native device bridge follows the repository's existing physical-device coverage exception; manager and processing code remain subject to the 90% per-file threshold. The release workflow installs ALSA development headers for Linux module builds. -OpenHuman must pin a released artifact compatible with current contract 1.6 +OpenHuman must pin a released artifact compatible with current contract 1.7 (this capture slice was introduced in 1.3) and verify its digest before switching callers. Module-owned hotkeys are specified in `hotkey-module-lifecycle.md`. From 149c20fc654458fd56e67b4f43c40fe99fa31ee8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:51:48 +0300 Subject: [PATCH 24/59] feat(tinyvoice-module): add live runtime verification to module example The verify_module example now runs a live runtime check against the bus, using tokio-tungstenite and futures-util as dev-dependencies. The bus constants are imported from tinyvoice_bus instead of tinyvoice_module so the example exercises the same names the module serves. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyvoice-module/Cargo.lock | 2 + crates/tinyvoice-module/Cargo.toml | 2 + .../examples/verify_module.rs | 6 +- .../examples/verify_module/live.rs | 261 ++++++++++++++++++ 4 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 crates/tinyvoice-module/examples/verify_module/live.rs diff --git a/crates/tinyvoice-module/Cargo.lock b/crates/tinyvoice-module/Cargo.lock index 2193bf1..4c9a184 100644 --- a/crates/tinyvoice-module/Cargo.lock +++ b/crates/tinyvoice-module/Cargo.lock @@ -2296,6 +2296,7 @@ dependencies = [ "anyhow", "async-trait", "base64 0.22.1", + "futures-util", "getrandom 0.4.3", "rustix", "serde", @@ -2309,6 +2310,7 @@ dependencies = [ "tinyvoice-bus", "tinyvoice-hotkey-win", "tokio", + "tokio-tungstenite", "x11rb", ] diff --git a/crates/tinyvoice-module/Cargo.toml b/crates/tinyvoice-module/Cargo.toml index 0f5dcc5..2eb51d2 100644 --- a/crates/tinyvoice-module/Cargo.toml +++ b/crates/tinyvoice-module/Cargo.toml @@ -81,6 +81,8 @@ broken_intra_doc_links = "warn" private_intra_doc_links = "warn" [dev-dependencies] +futures-util = "0.3" +tokio-tungstenite = "0.29" tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "test-util"] } [patch.crates-io] diff --git a/crates/tinyvoice-module/examples/verify_module.rs b/crates/tinyvoice-module/examples/verify_module.rs index 9241c92..ed744b4 100644 --- a/crates/tinyvoice-module/examples/verify_module.rs +++ b/crates/tinyvoice-module/examples/verify_module.rs @@ -13,7 +13,10 @@ use tinybus::transport::memory::MemoryBus; // example once ended up dialling `/ai.tinyhumans.tinyvoice.Voice` — a bus name // where an object path belongs — while the module served the correct path and // every in-process test still passed. -use tinyvoice_module::{BUS_NAME, OBJECT_PATH}; +use tinyvoice_bus::{BUS_NAME, OBJECT_PATH}; + +#[path = "verify_module/live.rs"] +mod live; #[tokio::main] async fn main() -> Result<(), Box> { @@ -69,6 +72,7 @@ async fn main() -> Result<(), Box> { } verify_relay_turn(&proxy).await?; verify_live_input(&proxy).await?; + live::verify_runtime(&proxy, &client).await?; verify_recording_summary(&proxy).await?; // Default-denied permission must reject before any native device is opened. let denied: tinyvoice_bus::capture::CaptureResult = diff --git a/crates/tinyvoice-module/examples/verify_module/live.rs b/crates/tinyvoice-module/examples/verify_module/live.rs new file mode 100644 index 0000000..54c16c7 --- /dev/null +++ b/crates/tinyvoice-module/examples/verify_module/live.rs @@ -0,0 +1,261 @@ +//! Real loaded-module duplex, tool-authority and cleanup verification. + +use futures_util::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use std::io; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, +}; +use tinyvoice_bus::live::{ServerFrame, session::*}; + +#[derive(Clone, Default)] +struct Host { + calls: Arc>>, + finishes: Arc, +} + +#[tinybus::interface(name = "ai.tinyhumans.tinyvoice.LiveTools")] +impl Host { + async fn execute_live_tool( + &self, + request: ExecuteLiveToolRequest, + ) -> tinybus::Result { + if request.context_token != "known-live-context" || request.name != "approved" { + return Err(tinybus::Error::failed("no current executor")); + } + self.calls + .lock() + .map_err(|_| tinybus::Error::failed("fixture lock"))? + .push(request); + // A previously declared tool is now revoked by the current host policy. + Ok(ExecuteLiveToolReply { + result: json!({"content": [{"type": "text", "text": "denied by current policy"}], "is_error": true}), + }) + } + async fn finish_live_context( + &self, + request: FinishLiveContextRequest, + ) -> tinybus::Result { + if request.context_token != "known-live-context" { + return Ok(FinishLiveContextReply { finished: false }); + } + self.finishes.store(1, Ordering::SeqCst); + Ok(FinishLiveContextReply { finished: true }) + } +} + +type CheckResult = Result>; + +pub(super) async fn verify_runtime( + proxy: &tinybus::Proxy, + client: &tinybus::Connection, +) -> CheckResult<()> { + let host = Host::default(); + client + .serve_at("/test/LiveTools".try_into()?, host.clone()) + .await?; + client.request_name("ai.test.LiveTools").await?; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let address = listener.local_addr()?; + let provider = tokio::spawn(scripted_provider(listener)); + let reserved: LiveStatus = proxy.call("LiveReserve", ()).await?; + let request: LiveStartRequest = serde_json::from_value(json!({ + "handle": reserved.handle, + "connection": {"kind": "gemini_relay", "ws_url": format!("ws://{address}"), "session_id": "provider-session", "model": "fixture-model"}, + "configuration": {"model": null, "system_instruction": null, "voice": null, "language": null, "first_message": null, "input_sample_rate": 16000, + "tools": [{"name": "approved", "description": "host declaration", "parameters": {"type": "object"}, "prefer_markdown": false}], + "input_transcription": true, "output_transcription": true, "manual_turn_detection": false, "silence_ms": null, "speech_threshold": null, "provider_options": {}}, + "tool_port": {"bus_name": "ai.test.LiveTools", "object_path": "/test/LiveTools", "context_token": "known-live-context"}, + "session_id": "fallback-session", "provider": "fixture-provider", "thread_id": "host-thread", "max_tool_calls": 8, "tool_timeout_ms": 1000, "connect_timeout_ms": 1000 + }))?; + let started: LiveStatus = proxy.call("LiveStart", (request.clone(),)).await?; + if started.state != LiveState::Starting { + return Err(io::Error::other("start did not admit asynchronously").into()); + } + let retry: LiveStatus = proxy.call("LiveStart", (request,)).await?; + if retry.handle != started.handle { + return Err(io::Error::other("start retry changed handle").into()); + } + let outputs = tokio::time::timeout( + std::time::Duration::from_secs(5), + collect_outputs(proxy, &reserved.handle), + ) + .await??; + provider.await??; + check_outputs(&outputs, &host)?; + let released: LiveStatus = proxy + .call( + "LiveRelease", + (LiveHandleRequest { + handle: reserved.handle.clone(), + },), + ) + .await?; + if released.state != LiveState::Closed || host.finishes.load(Ordering::SeqCst) != 1 { + return Err(io::Error::other("release did not finish known context").into()); + } + let _: LiveStatus = proxy + .call( + "LiveRelease", + (LiveHandleRequest { + handle: reserved.handle, + },), + ) + .await?; + let _: () = proxy.call("LiveShutdown", ()).await?; + if proxy.call::("LiveReserve", ()).await.is_ok() { + return Err(io::Error::other("shutdown reopened admission").into()); + } + Ok(()) +} + +async fn collect_outputs( + proxy: &tinybus::Proxy, + handle: &LiveHandle, +) -> tinybus::Result> { + let mut outputs = Vec::new(); + let mut ack = None; + let mut sent = false; + loop { + let batch: LiveBatch = proxy + .call( + "LiveRead", + (LiveReadRequest { + handle: handle.clone(), + acknowledged_batch: ack, + },), + ) + .await?; + if !batch.outputs.is_empty() { + let replay: LiveBatch = proxy + .call( + "LiveRead", + (LiveReadRequest { + handle: handle.clone(), + acknowledged_batch: None, + },), + ) + .await?; + if replay != batch { + return Err(tinybus::Error::failed("unacknowledged batch changed")); + } + ack = Some(batch.batch); + } + if !sent && batch.state == LiveState::Running { + let _: LiveStatus = proxy + .call( + "LiveSend", + (LiveSendRequest { + handle: handle.clone(), + command: LiveCommand::Text { + text: "hello".into(), + }, + },), + ) + .await?; + sent = true; + } + let terminal = batch.outputs.iter().any(|output| { + matches!( + output, + LiveOutput::Json { + frame: ServerFrame::Closed { .. } + } + ) + }); + outputs.extend(batch.outputs); + if terminal { + return Ok(outputs); + } + tokio::task::yield_now().await; + } +} + +async fn scripted_provider(listener: tokio::net::TcpListener) -> Result<(), io::Error> { + let (stream, _) = listener.accept().await?; + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .map_err(io::Error::other)?; + let message = socket + .next() + .await + .ok_or_else(|| io::Error::other("provider disconnected"))? + .map_err(io::Error::other)?; + if !message + .to_text() + .map_err(io::Error::other)? + .contains("hello") + { + return Err(io::Error::other("text command was not forwarded")); + } + socket.send(tokio_tungstenite::tungstenite::Message::Text(json!({"serverContent": {"inputTranscription": {"text": "heard"}, "outputTranscription": {"text": "reply"}, "turnComplete": true}}).to_string().into())).await.map_err(io::Error::other)?; + socket.send(tokio_tungstenite::tungstenite::Message::Text(json!({"toolCall": {"functionCalls": [{"id": "native-7", "name": "approved", "args": {"input": "safe"}}, {"id": "unknown-8", "name": "undeclared", "args": {}}]}}).to_string().into())).await.map_err(io::Error::other)?; + let mut response_ids = Vec::new(); + while response_ids.len() < 2 { + let message = socket + .next() + .await + .ok_or_else(|| io::Error::other("provider disconnected before tool results"))? + .map_err(io::Error::other)?; + let value: Value = serde_json::from_str(message.to_text().map_err(io::Error::other)?) + .map_err(io::Error::other)?; + if let Some(responses) = value + .pointer("/toolResponse/functionResponses") + .and_then(Value::as_array) + { + response_ids.extend(responses.iter().filter_map(|response| { + response + .get("id") + .and_then(Value::as_str) + .map(str::to_string) + })); + } + } + response_ids.sort(); + if response_ids != ["native-7", "unknown-8"] { + return Err(io::Error::other("tool result identities changed")); + } + socket.close(None).await.map_err(io::Error::other) +} + +fn check_outputs(outputs: &[LiveOutput], host: &Host) -> CheckResult<()> { + for (index, output) in outputs.iter().enumerate() { + if let LiveOutput::Json { + frame: + ServerFrame::Transcript { + role, + text, + is_final: true, + }, + } = output + { + if outputs.get(index + 1) + != Some(&LiveOutput::Persist { + role: *role, + text: text.clone(), + }) + { + return Err( + io::Error::other("final transcript was not followed by persistence").into(), + ); + } + } + } + let calls = host + .calls + .lock() + .map_err(|_| io::Error::other("fixture lock"))?; + if calls.len() != 1 + || calls[0].call_id != "native-7" + || calls[0].arguments != json!({"input": "safe"}) + { + return Err( + io::Error::other("unknown call executed or native call provenance changed").into(), + ); + } + for id in ["native-7", "unknown-8"] { + if !outputs.iter().any(|output| matches!(output, LiveOutput::Json { frame: ServerFrame::ToolFinished { call_id, ok: false, cancelled: false, .. } } if call_id == id)) { return Err(io::Error::other("denied/unknown tool was fabricated as success").into()); } + } + Ok(()) +} From 592076b48268b17e22772f08dc12f7f09c500322 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:54:55 +0300 Subject: [PATCH 25/59] chore: files changed crates/tinyvoice-module/src/service/live/callback_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/service/live/callback_tests.rs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/tinyvoice-module/src/service/live/callback_tests.rs b/crates/tinyvoice-module/src/service/live/callback_tests.rs index 9a0ee5c..2078a35 100644 --- a/crates/tinyvoice-module/src/service/live/callback_tests.rs +++ b/crates/tinyvoice-module/src/service/live/callback_tests.rs @@ -15,10 +15,22 @@ struct Authority { revoked: Arc, malformed: Arc, seen: Arc>>, + hanging: Arc, + entered: Arc, + cancel_execution: Arc, + cancelled: Arc, } #[tinybus::interface(name = "ai.tinyhumans.tinyvoice.LiveTools")] impl Authority { + async fn cancel_live_tool(&self, request: Value) -> tinybus::Result { + if request.get("context_token").and_then(Value::as_str) != Some("known-context") { + return Ok(json!({"cancelled": false})); + } + self.cancel_execution.notify_one(); + self.cancelled.notify_one(); + Ok(json!({"cancelled": true})) + } async fn execute_live_tool( &self, request: ExecuteLiveToolRequest, @@ -30,6 +42,11 @@ impl Authority { .lock() .map_err(|_| tinybus::Error::failed("fixture lock"))? .push(request); + if self.hanging.load(Ordering::SeqCst) { + self.entered.notify_one(); + self.cancel_execution.notified().await; + return Err(tinybus::Error::failed("host invocation cancelled")); + } if self.malformed.load(Ordering::SeqCst) { return Ok(ExecuteLiveToolReply { result: json!({"invented_success": true}), @@ -168,3 +185,29 @@ async fn oversized_tool_arguments_fail_before_calling_host_executor() -> anyhow: ); Ok(()) } + +#[tokio::test(start_paused = true)] +async fn dropping_native_callback_notifies_host_to_cancel_its_known_invocation() +-> anyhow::Result<()> { + let (tool, authority, _host) = fixture().await?; + authority.hanging.store(true, Ordering::SeqCst); + let call = tokio::spawn(async move { + tool.execute( + &(), + CallId::new("cancelled-provider-call"), + json!({}), + ToolCallOptions::default(), + &context(), + ) + .await + }); + authority.entered.notified().await; + call.abort(); + let _ = call.await; + tokio::time::timeout( + std::time::Duration::from_secs(1), + authority.cancelled.notified(), + ) + .await?; + Ok(()) +} From 8995188b603b7d1d1ed032578ba1cb81e05cebd0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:57:41 +0300 Subject: [PATCH 26/59] feat(live): cancel host tool invocations when native dispatch is dropped Add a CancelLiveTool callback and a fresh invocation id on ExecuteLiveTool so the module can tell the host to revoke exactly one known invocation when its native dispatch future is dropped, rather than leaving host work detached. Cancellation is bounded and best effort, so hosts must still cancel their own invocation leases on endpoint or context teardown. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyvoice-bus/src/live/session.rs | 40 +++++++++++++++- .../src/service/live/actor.rs | 2 +- .../src/service/live/callback.rs | 40 +++++++++++++++- .../src/service/live/callback_tests.rs | 37 ++++++++++++++- .../src/service/live/projection.rs | 8 ++-- .../src/service/live/table.rs | 23 +++++---- docs/plans/live-module-runtime.md | 2 +- docs/specs/live-module-runtime.md | 47 +++++++++++++++++-- 8 files changed, 175 insertions(+), 24 deletions(-) diff --git a/crates/tinyvoice-bus/src/live/session.rs b/crates/tinyvoice-bus/src/live/session.rs index 7d93544..615bbd1 100644 --- a/crates/tinyvoice-bus/src/live/session.rs +++ b/crates/tinyvoice-bus/src/live/session.rs @@ -7,8 +7,10 @@ use super::{ServerFrame, TranscriptRole}; /// Fixed interface served by a host's policy-aware tool callback. pub const TOOL_CALLBACK_INTERFACE: &str = "ai.tinyhumans.tinyvoice.LiveTools"; -/// The only method invoked on a live tool callback endpoint. +/// Execute an admitted provider tool call through current host authority. pub const TOOL_CALLBACK_METHOD: &str = "ExecuteLiveTool"; +/// Revoke one known host invocation when its native dispatch future is dropped. +pub const CANCEL_CALLBACK_METHOD: &str = "CancelLiveTool"; /// Idempotently revoke a known context and cancel its host-owned work. pub const FINISH_CALLBACK_METHOD: &str = "FinishLiveContext"; @@ -127,6 +129,8 @@ impl std::fmt::Debug for LiveToolPort { pub struct ExecuteLiveToolRequest { /// Saved host authority, checked independently of the declaration. pub context_token: String, + /// Fresh invocation identity used only for precise host-work cancellation. + pub invocation_id: String, /// Admitted provider call identity. pub call_id: String, /// Declared tool name. @@ -141,6 +145,7 @@ impl std::fmt::Debug for ExecuteLiveToolRequest { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ExecuteLiveToolRequest") .field("context_token", &"[redacted]") + .field("invocation_id", &self.invocation_id) .field("call_id", &self.call_id) .field("name", &self.name) .field("arguments", &"[redacted]") @@ -342,3 +347,36 @@ pub struct FinishLiveContextReply { /// Whether the known context is closed and its owned work has stopped. pub finished: bool, } + +/// Cancel only this known invocation; late/repeated calls must not create work. +/// +/// The host matches both the context and the fresh invocation identity. The +/// provider call identity remains separately available for provenance. Unknown +/// contexts or invocations return false; already completed known invocations +/// may return true without repeating any side effect. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CancelLiveToolRequest { + /// Saved host authority; never recovered from provider arguments. + pub context_token: String, + /// Module-generated invocation identity from the execute request. + pub invocation_id: String, + /// Original admitted provider call identity. + pub call_id: String, +} + +impl std::fmt::Debug for CancelLiveToolRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CancelLiveToolRequest") + .field("context_token", &"[redacted]") + .field("invocation_id", &self.invocation_id) + .field("call_id", &self.call_id) + .finish() + } +} + +/// Acknowledgement that a known host invocation is no longer running. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct CancelLiveToolReply { + /// False for an unknown invocation; never invent a successful cancellation. + pub cancelled: bool, +} diff --git a/crates/tinyvoice-module/src/service/live/actor.rs b/crates/tinyvoice-module/src/service/live/actor.rs index d9ab1ac..d2f4071 100644 --- a/crates/tinyvoice-module/src/service/live/actor.rs +++ b/crates/tinyvoice-module/src/service/live/actor.rs @@ -149,7 +149,7 @@ fn oversized(event: &LiveAgentEvent) -> bool { LiveAgentEvent::Live(LiveEvent::Closed(CloseReason::Remote { reason, .. })) => { reason.len() > MAX_TEXT_BYTES } - _ => false, + LiveAgentEvent::Live(_) => false, } } diff --git a/crates/tinyvoice-module/src/service/live/callback.rs b/crates/tinyvoice-module/src/service/live/callback.rs index 2141fb2..158bb79 100644 --- a/crates/tinyvoice-module/src/service/live/callback.rs +++ b/crates/tinyvoice-module/src/service/live/callback.rs @@ -7,8 +7,9 @@ use serde_json::Value; use tinyagents_harness::{context::RunContext, ids::CallId, tool::ToolDispatch}; use tinytools::{Tool, ToolCallOptions, ToolResult}; use tinyvoice_bus::live::session::{ - ExecuteLiveToolReply, ExecuteLiveToolRequest, LiveToolDeclaration, LiveToolPort, - TOOL_CALLBACK_INTERFACE, TOOL_CALLBACK_METHOD, + CANCEL_CALLBACK_METHOD, CancelLiveToolReply, CancelLiveToolRequest, ExecuteLiveToolReply, + ExecuteLiveToolRequest, LiveToolDeclaration, LiveToolPort, TOOL_CALLBACK_INTERFACE, + TOOL_CALLBACK_METHOD, }; /// Schema-only tool descriptor; typed dispatch is the only executable path. @@ -79,6 +80,7 @@ impl ToolDispatch<(), ()> for CallbackTool { _parent: &RunContext<()>, ) -> anyhow::Result { let request = ExecuteLiveToolRequest { + invocation_id: super::table::random_id()?, context_token: self.port.context_token.clone(), call_id: call_id.to_string(), name: self.declaration.0.name.clone(), @@ -88,7 +90,16 @@ impl ToolDispatch<(), ()> for CallbackTool { if !super::super::relay::fits(&request, 1024 * 1024) { anyhow::bail!("live tool callback request exceeds its serialized limit"); } + let mut cancellation = InvocationGuard { + proxy: self.proxy.clone(), + request: Some(CancelLiveToolRequest { + context_token: request.context_token.clone(), + invocation_id: request.invocation_id.clone(), + call_id: request.call_id.clone(), + }), + }; let reply: ExecuteLiveToolReply = self.proxy.call(TOOL_CALLBACK_METHOD, (request,)).await?; + cancellation.request = None; if !super::super::relay::fits(&reply, 1024 * 1024) { anyhow::bail!("live tool callback result exceeds its serialized limit"); } @@ -101,3 +112,28 @@ impl ToolDispatch<(), ()> for CallbackTool { #[cfg(test)] #[path = "callback_tests.rs"] mod tests; + +/// A dropped native callback cannot detach its independently owned host work. +/// +/// Notification is bounded and best effort; hosts must additionally cancel +/// their invocation leases on endpoint/context teardown. Provider cancellation +/// that permits a running native tool to finish does not drop this guard early. +struct InvocationGuard { + proxy: tinybus::Proxy, + request: Option, +} + +impl Drop for InvocationGuard { + fn drop(&mut self) { + if let Some(request) = self.request.take() { + let proxy = self.proxy.clone(); + tokio::spawn(async move { + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + proxy.call::(CANCEL_CALLBACK_METHOD, (request,)), + ) + .await; + }); + } + } +} diff --git a/crates/tinyvoice-module/src/service/live/callback_tests.rs b/crates/tinyvoice-module/src/service/live/callback_tests.rs index 2078a35..95eb31d 100644 --- a/crates/tinyvoice-module/src/service/live/callback_tests.rs +++ b/crates/tinyvoice-module/src/service/live/callback_tests.rs @@ -19,6 +19,7 @@ struct Authority { entered: Arc, cancel_execution: Arc, cancelled: Arc, + cancellations: Arc>>, } #[tinybus::interface(name = "ai.tinyhumans.tinyvoice.LiveTools")] @@ -27,8 +28,28 @@ impl Authority { if request.get("context_token").and_then(Value::as_str) != Some("known-context") { return Ok(json!({"cancelled": false})); } - self.cancel_execution.notify_one(); - self.cancelled.notify_one(); + let known = self + .seen + .lock() + .map_err(|_| tinybus::Error::failed("fixture lock"))? + .iter() + .any(|seen| { + request.get("invocation_id").and_then(Value::as_str) + == Some(seen.invocation_id.as_str()) + && request.get("call_id").and_then(Value::as_str) == Some(seen.call_id.as_str()) + }); + if !known { + return Ok(json!({"cancelled": false})); + } + let mut cancellations = self + .cancellations + .lock() + .map_err(|_| tinybus::Error::failed("fixture lock"))?; + if !cancellations.contains(&request) { + cancellations.push(request); + self.cancel_execution.notify_one(); + self.cancelled.notify_one(); + } Ok(json!({"cancelled": true})) } async fn execute_live_tool( @@ -209,5 +230,17 @@ async fn dropping_native_callback_notifies_host_to_cancel_its_known_invocation() authority.cancelled.notified(), ) .await?; + let cancellations = authority + .cancellations + .lock() + .map_err(|_| anyhow::anyhow!("fixture lock"))?; + assert_eq!(cancellations.len(), 1); + assert_eq!(cancellations[0]["call_id"], "cancelled-provider-call"); + let seen = authority + .seen + .lock() + .map_err(|_| anyhow::anyhow!("fixture lock"))?; + assert_eq!(cancellations[0]["invocation_id"], seen[0].invocation_id); + assert_eq!(seen[0].invocation_id.len(), 32); Ok(()) } diff --git a/crates/tinyvoice-module/src/service/live/projection.rs b/crates/tinyvoice-module/src/service/live/projection.rs index 031eccb..a1c139e 100644 --- a/crates/tinyvoice-module/src/service/live/projection.rs +++ b/crates/tinyvoice-module/src/service/live/projection.rs @@ -51,7 +51,7 @@ pub(super) fn map_event( LiveEvent::Interrupted => vec![json(ServerFrame::Interrupted)], LiveEvent::TurnComplete { .. } => vec![json(ServerFrame::TurnComplete)], LiveEvent::Error { error, fatal } => { - let error = project_error(error); + let error = project_error(&error); vec![json(ServerFrame::Error { code: error.0.to_string(), message: error.1, @@ -67,7 +67,7 @@ pub(super) fn map_event( None => "remote".to_string(), }, CloseReason::Error(error) => { - let error = project_error(error); + let error = project_error(&error); out.push(json(ServerFrame::Error { code: error.0.to_string(), message: error.1, @@ -103,8 +103,8 @@ fn json(frame: ServerFrame) -> LiveOutput { LiveOutput::Json { frame } } -fn project_error(error: Error) -> (&'static str, String) { - let code = match &error { +fn project_error(error: &Error) -> (&'static str, String) { + let code = match error { Error::Unauthorized => "unauthorized", Error::InsufficientCredits => "insufficient_credits", Error::RateLimited => "rate_limited", diff --git a/crates/tinyvoice-module/src/service/live/table.rs b/crates/tinyvoice-module/src/service/live/table.rs index fe190f9..e8e3c9d 100644 --- a/crates/tinyvoice-module/src/service/live/table.rs +++ b/crates/tinyvoice-module/src/service/live/table.rs @@ -140,10 +140,7 @@ impl Sessions { return Err(tinybus::Error::failed("live session capacity is exhausted")); } loop { - let mut random = [0_u8; 16]; - getrandom::fill(&mut random) - .map_err(|_| tinybus::Error::failed("live handle generation failed"))?; - let id: String = random.iter().map(|byte| format!("{byte:02x}")).collect(); + let id = random_id()?; let handle = LiveHandle { id }; if !leases.contains_key(&handle) { leases.insert(handle.clone(), Arc::new(Lease::new())); @@ -370,7 +367,7 @@ impl Sessions { Err(_) => { failure = Some(tinybus::Error::failed( "live cleanup task terminated unexpectedly", - )) + )); } } } @@ -433,10 +430,10 @@ impl Drop for Sessions { if let Ok(leases) = self.leases.get_mut() { for lease in leases.values() { lease.cancel(); - if let Ok(mut phase) = lease.phase.lock() { - if let Some(task) = phase.task.take() { - task.abort(); - } + if let Ok(mut phase) = lease.phase.lock() + && let Some(task) = phase.task.take() + { + task.abort(); } } } @@ -450,3 +447,11 @@ fn unavailable() -> tinybus::Error { #[cfg(test)] #[path = "table_tests.rs"] mod tests; + +/// Generate unpredictable correlation identities without deriving host secrets. +pub(super) fn random_id() -> tinybus::Result { + let mut random = [0_u8; 16]; + getrandom::fill(&mut random) + .map_err(|_| tinybus::Error::failed("live identity generation failed"))?; + Ok(format!("{:032x}", u128::from_be_bytes(random))) +} diff --git a/docs/plans/live-module-runtime.md b/docs/plans/live-module-runtime.md index 8569711..a98d551 100644 --- a/docs/plans/live-module-runtime.md +++ b/docs/plans/live-module-runtime.md @@ -8,7 +8,7 @@ Specification: ../specs/live-module-runtime.md. 2. Consume TinyAgentsLive from its canonical recorded source as a module implementation dependency. Add a callback tool adapter with explicit host context and current-policy enforcement at the host endpoint. -3. Add bounded random session handles and duplex open/send/read/close/shutdown +3. Add bounded random session handles and duplex reserve/start/send/read/release/shutdown operations. Replay event batches until acknowledged and retain terminal events until explicit release. 4. Verify a real compiled native module against a local provider and a diff --git a/docs/specs/live-module-runtime.md b/docs/specs/live-module-runtime.md index b43ddad..ec08754 100644 --- a/docs/specs/live-module-runtime.md +++ b/docs/specs/live-module-runtime.md @@ -16,7 +16,7 @@ Ready uses the provider's session id when present and the host fallback otherwise. Terminal error closes emit error before closed. Unknown provider events produce no invented client event. -A live open request supplies an opaque tool callback endpoint and host context +A reserved live start request supplies an opaque tool callback endpoint and host context token. The module builds a TinyAgentsLive harness over callback tools. The host callback invokes its existing current policy-aware harness with its saved approval, origin and thread context. A tool declaration grants no permission. @@ -28,9 +28,9 @@ Session handles are opaque and random. A bounded table owns sessions and their tasks. Audio and serialized control requests have bounded sizes. Event reads return ordered bounded batches with replay until acknowledgment, so a lost reply does not erase transcript or terminal events. Stop asks the provider to -close and retains its terminal events; explicit close drops the session and -its tasks. Shutdown cancels and joins owned tasks. A disconnected client must -explicitly close its handle; capacity limits prevent leaked clients from +close and retains its terminal events; explicit release drops the session and +its tasks. Shutdown closes admission, cancels every native task and joins cleanups concurrently under one two-second deadline. Dropping a release/shutdown future aborts the worker rather than detaching its task. Module drop also aborts owned workers. A disconnected client must +explicitly release its handle; capacity limits prevent leaked clients from growing the table indefinitely. The realtime relay's local brain, progress selection, correlation admission, @@ -46,3 +46,42 @@ event projections, final transcript order, bounded input/event queues, retry batches, interruptions, cancellation, callback failures, remote closure, explicit close and shutdown. No test calls an external provider. Host adoption waits for accepted source and an available released module. + +## Admission and transport bounds + +Contract 1.7 adds PrepareLiveInput, LiveReserve, LiveStart, LiveSend, LiveRead, +LiveRelease and LiveShutdown. Reserve precedes provider connection; identical +start retries preserve the same lease and a changed start request fails. +Sixteen leases are admitted. Unstarted reservations expire after sixty seconds; +active sessions are never evicted to admit another client. Startup requests +and tool callback requests/replies each have a one-MiB serialized limit. +Text/audio input is at most sixty-four KiB (PCM16 audio contains whole samples). +A command waits at most one second for native input admission. Connection +timeouts are at most sixty seconds and tool timeouts at most thirty minutes; +both and the maximum tool-call count are explicit positive host inputs. + +Each lease buffers at most thirty-two event groups. Final transcript frame +and persistence effect form one indivisible ordered group. Reads retain the +exact numbered batch until acknowledged, reject unknown acknowledgments, and +carry a group to the next batch rather than split it. Replies stay below one +MiB with envelope headroom. Large native audio is split into bounded PCM16 +chunks. Unsupported events are ignored; oversized provider text terminates +with a controlled error before it can become an unreadable retained group. + +## Host invocation lifecycle + +ExecuteLiveTool carries the original admitted provider call ID, exact arguments, +rendering options and saved context token. A separately generated unpredictable +invocation ID lets CancelLiveTool target only that host invocation. Dropping a +native dispatch future sends a cancellation notification with a one-second +deadline. Provider cancellation preserves TinyAgentsLive's established rule: +an already-running tool may finish, but its result is suppressed. + +FinishLiveContext revokes the known context after native provider tasks stop. +It also has a one-second deadline; an unavailable/unresponsive host yields +an explicit negative cleanup result, never a fabricated acknowledgment. +Cancel/finish must be idempotent for already completed known work and must +not create or revive unknown contexts/invocations. TinyBus stops waiting +without cancelling remote execution, so the host must cancel its own invocation +leases on endpoint/context teardown as well. Remote notification is bounded +best effort; native provider termination does not depend on receiving it. From 9aba7599b14ab12f3dfa0fee22fc3d2e6306a0d2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:58:37 +0300 Subject: [PATCH 27/59] refactor(verify_module): switch live fixture to async mutex The live verification example now uses tokio's async mutex for its call and finish counters instead of std's blocking mutex, and the output checker became async to match. This keeps the fixture from blocking the runtime while it holds the lock. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../examples/verify_module/live.rs | 47 +++++++++---------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/crates/tinyvoice-module/examples/verify_module/live.rs b/crates/tinyvoice-module/examples/verify_module/live.rs index 54c16c7..7d7a00d 100644 --- a/crates/tinyvoice-module/examples/verify_module/live.rs +++ b/crates/tinyvoice-module/examples/verify_module/live.rs @@ -1,18 +1,25 @@ //! Real loaded-module duplex, tool-authority and cleanup verification. use futures_util::{SinkExt, StreamExt}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{Value, json}; use serde_json::{Value, json}; use std::io; -use std::sync::{ - Arc, Mutex, - atomic::{AtomicUsize, Ordering}, +use std::sync::Arc; +use tinyvoice_bus::live::{ + ServerFrame, + session::{ + ExecuteLiveToolReply, ExecuteLiveToolRequest, FinishLiveContextReply, + FinishLiveContextRequest, LiveBatch, LiveCommand, LiveHandle, LiveHandleRequest, + LiveOutput, LiveReadRequest, LiveSendRequest, LiveStartRequest, LiveState, LiveStatus, + }, }; -use tinyvoice_bus::live::{ServerFrame, session::*}; +use tokio::sync::Mutex; #[derive(Clone, Default)] struct Host { calls: Arc>>, - finishes: Arc, + finishes: Arc>, } #[tinybus::interface(name = "ai.tinyhumans.tinyvoice.LiveTools")] @@ -24,10 +31,7 @@ impl Host { if request.context_token != "known-live-context" || request.name != "approved" { return Err(tinybus::Error::failed("no current executor")); } - self.calls - .lock() - .map_err(|_| tinybus::Error::failed("fixture lock"))? - .push(request); + self.calls.lock().await.push(request); // A previously declared tool is now revoked by the current host policy. Ok(ExecuteLiveToolReply { result: json!({"content": [{"type": "text", "text": "denied by current policy"}], "is_error": true}), @@ -40,7 +44,7 @@ impl Host { if request.context_token != "known-live-context" { return Ok(FinishLiveContextReply { finished: false }); } - self.finishes.store(1, Ordering::SeqCst); + *self.finishes.lock().await = 1; Ok(FinishLiveContextReply { finished: true }) } } @@ -83,7 +87,7 @@ pub(super) async fn verify_runtime( ) .await??; provider.await??; - check_outputs(&outputs, &host)?; + check_outputs(&outputs, &host).await?; let released: LiveStatus = proxy .call( "LiveRelease", @@ -92,7 +96,7 @@ pub(super) async fn verify_runtime( },), ) .await?; - if released.state != LiveState::Closed || host.finishes.load(Ordering::SeqCst) != 1 { + if released.state != LiveState::Closed || *host.finishes.lock().await != 1 { return Err(io::Error::other("release did not finish known context").into()); } let _: LiveStatus = proxy @@ -219,7 +223,7 @@ async fn scripted_provider(listener: tokio::net::TcpListener) -> Result<(), io:: socket.close(None).await.map_err(io::Error::other) } -fn check_outputs(outputs: &[LiveOutput], host: &Host) -> CheckResult<()> { +async fn check_outputs(outputs: &[LiveOutput], host: &Host) -> CheckResult<()> { for (index, output) in outputs.iter().enumerate() { if let LiveOutput::Json { frame: @@ -229,23 +233,18 @@ fn check_outputs(outputs: &[LiveOutput], host: &Host) -> CheckResult<()> { is_final: true, }, } = output - { - if outputs.get(index + 1) + && outputs.get(index + 1) != Some(&LiveOutput::Persist { role: *role, text: text.clone(), }) - { - return Err( - io::Error::other("final transcript was not followed by persistence").into(), - ); - } + { + return Err( + io::Error::other("final transcript was not followed by persistence").into(), + ); } } - let calls = host - .calls - .lock() - .map_err(|_| io::Error::other("fixture lock"))?; + let calls = host.calls.lock().await; if calls.len() != 1 || calls[0].call_id != "native-7" || calls[0].arguments != json!({"input": "safe"}) From 72a66f01d299a776aee67a66819cf8eec065a00e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:00:15 +0300 Subject: [PATCH 28/59] fix(tinyvoice-module): remove duplicate imports in live example The live verification example imported futures_util and serde_json items twice, which would fail to compile. The duplicate use statements were dropped, and the live prepare module now wires up its test submodule behind cfg(test). Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../examples/verify_module/live.rs | 2 - .../src/service/live/fixtures/start.json | 8 ++ .../src/service/live/prepare.rs | 4 + .../src/service/live/prepare_tests.rs | 114 ++++++++++++++++++ 4 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 crates/tinyvoice-module/src/service/live/fixtures/start.json create mode 100644 crates/tinyvoice-module/src/service/live/prepare_tests.rs diff --git a/crates/tinyvoice-module/examples/verify_module/live.rs b/crates/tinyvoice-module/examples/verify_module/live.rs index 7d7a00d..a75b7f5 100644 --- a/crates/tinyvoice-module/examples/verify_module/live.rs +++ b/crates/tinyvoice-module/examples/verify_module/live.rs @@ -1,8 +1,6 @@ //! Real loaded-module duplex, tool-authority and cleanup verification. use futures_util::{SinkExt, StreamExt}; -use futures_util::{SinkExt, StreamExt}; -use serde_json::{Value, json}; use serde_json::{Value, json}; use std::io; use std::sync::Arc; diff --git a/crates/tinyvoice-module/src/service/live/fixtures/start.json b/crates/tinyvoice-module/src/service/live/fixtures/start.json new file mode 100644 index 0000000..27e0c01 --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/fixtures/start.json @@ -0,0 +1,8 @@ +{ + "handle": {"id": "fixture"}, + "connection": {"kind": "gemini_relay", "ws_url": "ws://127.0.0.1:9", "session_id": "provider-session", "model": "fixture"}, + "configuration": {"model": "fixture", "system_instruction": "host instruction", "voice": "host voice", "language": "en", "first_message": "hello", "input_sample_rate": 16000, + "tools": [], "input_transcription": true, "output_transcription": true, "manual_turn_detection": true, "silence_ms": 600, "speech_threshold": 0.5, "provider_options": {"host": true}}, + "tool_port": {"bus_name": "ai.test.LiveTools", "object_path": "/test/LiveTools", "context_token": "fixture-context"}, + "session_id": "host-session", "provider": "host-provider", "thread_id": "host-thread", "max_tool_calls": 8, "tool_timeout_ms": 1000, "connect_timeout_ms": 1000 +} diff --git a/crates/tinyvoice-module/src/service/live/prepare.rs b/crates/tinyvoice-module/src/service/live/prepare.rs index f114a96..c61f859 100644 --- a/crates/tinyvoice-module/src/service/live/prepare.rs +++ b/crates/tinyvoice-module/src/service/live/prepare.rs @@ -118,3 +118,7 @@ fn configuration(config: &LiveConfiguration) -> LiveConfig { provider_options: config.provider_options.clone(), } } + +#[cfg(test)] +#[path = "prepare_tests.rs"] +mod tests; diff --git a/crates/tinyvoice-module/src/service/live/prepare_tests.rs b/crates/tinyvoice-module/src/service/live/prepare_tests.rs new file mode 100644 index 0000000..6bb2ad5 --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/prepare_tests.rs @@ -0,0 +1,114 @@ +//! Explicit native configuration and bounded display-metadata fixtures. + +use super::*; +use serde_json::json; +use tinybus::broker::Broker; +use tinybus::transport::memory::MemoryBus; + +async fn fixture() -> anyhow::Result<(LiveStartRequest, tinybus::Connection)> { + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + Ok(( + serde_json::from_str(include_str!("fixtures/start.json"))?, + tinybus::Connection::connect(bus.connect().await?).await?, + )) +} + +#[tokio::test] +async fn oversized_display_identity_cannot_create_an_unreadable_ready_batch() -> anyhow::Result<()> +{ + let (mut request, connection) = fixture().await?; + request.thread_id = "\0".repeat(16 * 1024 + 1); + assert!(prepare(&request, &connection).is_err()); + Ok(()) +} + +#[tokio::test] +async fn native_configuration_preserves_all_explicit_host_fields() -> anyhow::Result<()> { + let (request, connection) = fixture().await?; + let prepared = prepare(&request, &connection)?; + assert_eq!(prepared.config.model, request.configuration.model); + assert_eq!( + prepared.config.system_instruction, + request.configuration.system_instruction + ); + assert_eq!(prepared.config.voice, request.configuration.voice); + assert_eq!(prepared.config.language, request.configuration.language); + assert_eq!( + prepared.config.first_message, + request.configuration.first_message + ); + assert_eq!(prepared.config.input_format, AudioFormat::pcm16(16000)); + assert!(prepared.config.input_transcription && prepared.config.output_transcription); + assert_eq!(prepared.config.vad.turn_detection, TurnDetection::Manual); + assert_eq!(prepared.config.vad.silence_ms, Some(600)); + assert_eq!(prepared.config.provider_options, json!({"host": true})); + assert_eq!( + prepared.options.tool_timeout, + std::time::Duration::from_millis(1000) + ); + assert!(!prepared.options.include_deferred_tools); + Ok(()) +} + +#[tokio::test] +async fn provider_construction_uses_only_host_supplied_connection_material() -> anyhow::Result<()> { + let (mut request, connection) = fixture().await?; + for provider in [ + LiveConnection::Gemini { + api_key: "fixture".into(), + }, + LiveConnection::ElevenLabs { + signed_url: "ws://127.0.0.1:9".into(), + }, + LiveConnection::Sarvam { + api_key: "fixture".into(), + }, + ] { + request.connection = provider; + request.configuration.manual_turn_detection = false; + assert!(prepare(&request, &connection).is_ok()); + } + Ok(()) +} + +#[tokio::test] +async fn empty_or_duplicate_declarations_never_create_a_dispatcher() -> anyhow::Result<()> { + let (mut request, connection) = fixture().await?; + let mut declaration = tinyvoice_bus::live::session::LiveToolDeclaration { + name: "".into(), + description: "fixture".into(), + parameters: json!({"type": "object"}), + prefer_markdown: false, + }; + request.configuration.tools = vec![declaration.clone()]; + assert!(prepare(&request, &connection).is_err()); + declaration.name = "approved".into(); + request.configuration.tools = vec![declaration.clone(), declaration]; + assert!(prepare(&request, &connection).is_err()); + request.configuration.tools.pop(); + let prepared = prepare(&request, &connection)?; + assert_eq!(prepared.config.tools.len(), 1); + assert_eq!(prepared.config.tools[0].name, "approved"); + Ok(()) +} + +#[tokio::test] +async fn host_timeout_and_tool_call_limits_are_finite_and_positive() -> anyhow::Result<()> { + let (request, connection) = fixture().await?; + for (tool_ms, connect_ms, calls) in [ + (0, 1, 1), + (1, 0, 1), + (1, 1, 0), + (1, 1, 10001), + (1, 60001, 1), + (1800001, 1, 1), + ] { + let mut invalid = request.clone(); + invalid.tool_timeout_ms = tool_ms; + invalid.connect_timeout_ms = connect_ms; + invalid.max_tool_calls = calls; + assert!(prepare(&invalid, &connection).is_err()); + } + Ok(()) +} From f4144356a7ec832ae9e2530c632df8b24f78223d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:02:17 +0300 Subject: [PATCH 29/59] fix(live): reject oversized display metadata and type cancel callbacks Live session preparation now fails when the session id, provider, or thread id exceeds the output-safe size limit, preventing oversized display metadata from reaching downstream consumers. The cancel callback fixtures were also switched from untyped JSON values to the typed request and reply structs so the tests exercise the real interface contract. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/service/live/callback_tests.rs | 38 +++++++++---------- .../src/service/live/prepare.rs | 8 ++++ .../src/service/live/table_tests.rs | 2 +- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/crates/tinyvoice-module/src/service/live/callback_tests.rs b/crates/tinyvoice-module/src/service/live/callback_tests.rs index 95eb31d..c90d4c7 100644 --- a/crates/tinyvoice-module/src/service/live/callback_tests.rs +++ b/crates/tinyvoice-module/src/service/live/callback_tests.rs @@ -19,14 +19,17 @@ struct Authority { entered: Arc, cancel_execution: Arc, cancelled: Arc, - cancellations: Arc>>, + cancellations: Arc>>, } #[tinybus::interface(name = "ai.tinyhumans.tinyvoice.LiveTools")] impl Authority { - async fn cancel_live_tool(&self, request: Value) -> tinybus::Result { - if request.get("context_token").and_then(Value::as_str) != Some("known-context") { - return Ok(json!({"cancelled": false})); + async fn cancel_live_tool( + &self, + request: CancelLiveToolRequest, + ) -> tinybus::Result { + if request.context_token != "known-context" { + return Ok(CancelLiveToolReply { cancelled: false }); } let known = self .seen @@ -34,23 +37,18 @@ impl Authority { .map_err(|_| tinybus::Error::failed("fixture lock"))? .iter() .any(|seen| { - request.get("invocation_id").and_then(Value::as_str) - == Some(seen.invocation_id.as_str()) - && request.get("call_id").and_then(Value::as_str) == Some(seen.call_id.as_str()) + request.invocation_id == seen.invocation_id && request.call_id == seen.call_id }); if !known { - return Ok(json!({"cancelled": false})); + return Ok(CancelLiveToolReply { cancelled: false }); } - let mut cancellations = self - .cancellations - .lock() - .map_err(|_| tinybus::Error::failed("fixture lock"))?; + let mut cancellations = self.cancellations.lock().await; if !cancellations.contains(&request) { cancellations.push(request); self.cancel_execution.notify_one(); self.cancelled.notify_one(); } - Ok(json!({"cancelled": true})) + Ok(CancelLiveToolReply { cancelled: true }) } async fn execute_live_tool( &self, @@ -197,12 +195,13 @@ async fn oversized_tool_arguments_fail_before_calling_host_executor() -> anyhow: .await .is_err() ); - assert!( + assert_eq!( authority .seen .lock() .map_err(|_| anyhow::anyhow!("fixture lock"))? - .is_empty() + .len(), + 0 ); Ok(()) } @@ -230,17 +229,14 @@ async fn dropping_native_callback_notifies_host_to_cancel_its_known_invocation() authority.cancelled.notified(), ) .await?; - let cancellations = authority - .cancellations - .lock() - .map_err(|_| anyhow::anyhow!("fixture lock"))?; + let cancellations = authority.cancellations.lock().await; assert_eq!(cancellations.len(), 1); - assert_eq!(cancellations[0]["call_id"], "cancelled-provider-call"); + assert_eq!(cancellations[0].call_id, "cancelled-provider-call"); let seen = authority .seen .lock() .map_err(|_| anyhow::anyhow!("fixture lock"))?; - assert_eq!(cancellations[0]["invocation_id"], seen[0].invocation_id); + assert_eq!(cancellations[0].invocation_id, seen[0].invocation_id); assert_eq!(seen[0].invocation_id.len(), 32); Ok(()) } diff --git a/crates/tinyvoice-module/src/service/live/prepare.rs b/crates/tinyvoice-module/src/service/live/prepare.rs index c61f859..9025a85 100644 --- a/crates/tinyvoice-module/src/service/live/prepare.rs +++ b/crates/tinyvoice-module/src/service/live/prepare.rs @@ -45,6 +45,14 @@ pub(super) fn prepare( "live timeout and tool-call bounds must be positive and finite", )); } + if [&request.session_id, &request.provider, &request.thread_id] + .iter() + .any(|text| text.len() > 16 * 1024) + { + return Err(tinybus::Error::failed( + "live display metadata exceeds its output-safe limit", + )); + } let mut names = HashSet::new(); let mut harness = AgentHarness::<(), ()>::new(); for declaration in &request.configuration.tools { diff --git a/crates/tinyvoice-module/src/service/live/table_tests.rs b/crates/tinyvoice-module/src/service/live/table_tests.rs index fd75fd1..106a06d 100644 --- a/crates/tinyvoice-module/src/service/live/table_tests.rs +++ b/crates/tinyvoice-module/src/service/live/table_tests.rs @@ -78,7 +78,7 @@ async fn unacknowledged_batches_replay_without_repeating_committed_persistence() let empty = sessions .read(read_request(&handle, Some(second.batch))) .await?; - assert!(empty.outputs.is_empty()); + assert_eq!(empty.outputs, vec![]); assert_eq!(empty.batch, second.batch); sessions.shutdown().await } From 5c8c01ce03b6fb119a8ea3a72b7eea5f8583e303 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:08:21 +0300 Subject: [PATCH 30/59] feat(bus): expose live transport module Add a transport module to the live bus that provides the mechanical client socket and persistence callbacks used by module-owned pumping. Test fixtures were also tidied to use clearer literals and equality assertions. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyvoice-bus/src/live/mod.rs | 2 + crates/tinyvoice-bus/src/live/transport.rs | 113 ++++++++++++++++++ .../src/service/live/prepare_tests.rs | 4 +- .../src/service/live/projection_tests.rs | 6 +- 4 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 crates/tinyvoice-bus/src/live/transport.rs diff --git a/crates/tinyvoice-bus/src/live/mod.rs b/crates/tinyvoice-bus/src/live/mod.rs index b34f8d8..ef2c97b 100644 --- a/crates/tinyvoice-bus/src/live/mod.rs +++ b/crates/tinyvoice-bus/src/live/mod.rs @@ -1,6 +1,8 @@ //! Portable live websocket control vocabulary. pub mod session; +/// Mechanical client socket and persistence callbacks for module-owned pumping. +pub mod transport; use serde::{Deserialize, Serialize}; diff --git a/crates/tinyvoice-bus/src/live/transport.rs b/crates/tinyvoice-bus/src/live/transport.rs new file mode 100644 index 0000000..16e3d20 --- /dev/null +++ b/crates/tinyvoice-bus/src/live/transport.rs @@ -0,0 +1,113 @@ +//! Mechanical client socket and persistence callback contract for the module pump. + +use serde::{Deserialize, Serialize}; +use super::{TransportFrame, session::{LiveHandle, LiveOutput}}; + +/// Host interface providing mechanical socket/persistence callbacks. +pub const INTERFACE: &str = "ai.tinyhumans.tinyvoice.LiveTransport"; +/// Read one replayable client frame without applying provider or session policy. +pub const READ_METHOD: &str = "ReadLiveInput"; +/// Apply one ordered effect group idempotently. +pub const DELIVER_METHOD: &str = "DeliverLiveEffects"; +/// Idempotently close a known client socket/context. +pub const CLOSE_METHOD: &str = "CloseLiveTransport"; + +/// Opaque host transport endpoint; the module owns the generic coordinator. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LiveTransportPort { + /// Host bus name. + pub bus_name: String, + /// Host object path. + pub object_path: String, + /// Saved known host transport context, never provider-selected. + pub context_token: String, +} + +impl std::fmt::Debug for LiveTransportPort { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LiveTransportPort").field("bus_name", &self.bus_name) + .field("object_path", &self.object_path).field("context_token", &"[redacted]").finish() + } +} + +/// Attach one transport coordinator to a reservation before provider startup. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LiveBindTransportRequest { + /// Reserved module lease. + pub handle: LiveHandle, + /// Mechanical host socket/persistence port. + pub port: LiveTransportPort, +} + +/// Advance past the last completely handled frame and obtain the next one. +/// +/// The host retains an unacknowledged frame until the exact sequence is +/// acknowledged. It waits briefly for input, returning no frame when idle. +/// It must not race multiple websocket readers or erase a lost reply. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReadLiveInputRequest { + /// Known host transport context. + pub context_token: String, + /// Last fully processed frame, zero before any input. + pub acknowledged_frame: u64, +} + +/// A retained client frame or an idle poll. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReadLiveInputReply { + /// Next contiguous frame number; idle replies retain the last acknowledged number. + pub sequence: u64, + /// None when no client input is available yet. + pub frame: Option, +} + +/// Apply these effects in order before acknowledging this delivery. +/// +/// The host owns socket writes and transcript persistence. It remembers this +/// delivery ID within the known transport context so retrying a lost reply +/// never duplicates committed persistence or creates another executor. +#[derive(Clone, PartialEq, Serialize, Deserialize)] +pub struct DeliverLiveEffectsRequest { + /// Known host transport context. + pub context_token: String, + /// Module lease identifying the delivery namespace. + pub handle: LiveHandle, + /// Stable input/output delivery identity within this lease. + pub delivery_id: String, + /// Ordered client frames, audio and host persistence effects. + pub outputs: Vec, +} + +/// Acknowledgment after all requested effects have been applied. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeliverLiveEffectsReply { + /// False if the known context could not apply the delivery. + pub delivered: bool, +} + +/// Close a known host transport without creating or reviving a context. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CloseLiveTransportRequest { + /// Known host transport context. + pub context_token: String, +} + +/// Result of bounded mechanical transport teardown. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct CloseLiveTransportReply { + /// True for a known already closed context, false for an unknown context. + pub closed: bool, +} + +macro_rules! redacted_debug { + ($name:ident) => { + impl std::fmt::Debug for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct(stringify!($name)).finish_non_exhaustive() + } + } + }; +} +redacted_debug!(ReadLiveInputRequest); +redacted_debug!(DeliverLiveEffectsRequest); +redacted_debug!(CloseLiveTransportRequest); diff --git a/crates/tinyvoice-module/src/service/live/prepare_tests.rs b/crates/tinyvoice-module/src/service/live/prepare_tests.rs index 6bb2ad5..1e549b1 100644 --- a/crates/tinyvoice-module/src/service/live/prepare_tests.rs +++ b/crates/tinyvoice-module/src/service/live/prepare_tests.rs @@ -76,7 +76,7 @@ async fn provider_construction_uses_only_host_supplied_connection_material() -> async fn empty_or_duplicate_declarations_never_create_a_dispatcher() -> anyhow::Result<()> { let (mut request, connection) = fixture().await?; let mut declaration = tinyvoice_bus::live::session::LiveToolDeclaration { - name: "".into(), + name: String::new(), description: "fixture".into(), parameters: json!({"type": "object"}), prefer_markdown: false, @@ -102,7 +102,7 @@ async fn host_timeout_and_tool_call_limits_are_finite_and_positive() -> anyhow:: (1, 1, 0), (1, 1, 10001), (1, 60001, 1), - (1800001, 1, 1), + (1_800_001, 1, 1), ] { let mut invalid = request.clone(); invalid.tool_timeout_ms = tool_ms; diff --git a/crates/tinyvoice-module/src/service/live/projection_tests.rs b/crates/tinyvoice-module/src/service/live/projection_tests.rs index 59ca9e8..0ed9c78 100644 --- a/crates/tinyvoice-module/src/service/live/projection_tests.rs +++ b/crates/tinyvoice-module/src/service/live/projection_tests.rs @@ -122,13 +122,13 @@ fn maps_audio_turns_errors_and_closes() { matches!(&out[0], LiveOutput::Json { frame: ServerFrame::Error { code, fatal: true, .. } } if code == "unauthorized") ); // Tool calls surface through ToolStarted/ToolFinished instead. - assert!( + assert_eq!( map(LiveEvent::ToolCall(ToolCall { call_id: "c".into(), name: "n".into(), args: serde_json::json!({}) - })) - .is_empty() + })), + vec![] ); } From 955c70af39fd17b3ca19d3693bbd8e01f9975a40 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:10:35 +0300 Subject: [PATCH 31/59] feat(live): bind module-owned transport to live reservations Add a transport binding path so a live reservation can hand input and output to a module-owned transport pump instead of the manual send/read calls, which now reject requests while a transport is bound. Cancellation wakes all waiters and expired reservations are cancelled, and lease teardown waits for both the provider and transport tasks before confirming cleanup. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/service/live/table.rs | 319 +++++++++++------- .../src/service/live/table_tests.rs | 20 ++ 2 files changed, 211 insertions(+), 128 deletions(-) diff --git a/crates/tinyvoice-module/src/service/live/table.rs b/crates/tinyvoice-module/src/service/live/table.rs index e8e3c9d..0be544d 100644 --- a/crates/tinyvoice-module/src/service/live/table.rs +++ b/crates/tinyvoice-module/src/service/live/table.rs @@ -25,6 +25,9 @@ pub(super) struct Phase { pub(super) state: LiveState, pub(super) sender: Option, pub(super) task: Option>, + transport_task: Option>, + transport: Option, + pub(super) transport_cleanup_confirmed: bool, request: Option, pub(super) cleanup_confirmed: bool, } @@ -48,13 +51,16 @@ pub(super) struct Lease { } impl Lease { - fn new() -> Self { + pub(super) fn new() -> Self { let (tx, rx) = mpsc::channel(MAX_GROUPS); Self { phase: Mutex::new(Phase { state: LiveState::Reserved, sender: None, task: None, + transport_task: None, + transport: None, + transport_cleanup_confirmed: true, request: None, cleanup_confirmed: true, }), @@ -74,16 +80,19 @@ impl Lease { pub(super) fn cancel(&self) { self.cancelled.store(true, Ordering::SeqCst); - self.changed.notify_one(); + self.changed.notify_waiters(); } pub(super) async fn wait_cancelled(&self) { - if !self.cancelled.load(Ordering::SeqCst) { - self.changed.notified().await; - } + let notified = self.changed.notified(); + tokio::pin!(notified); + // Register before testing the sticky flag so cancellation cannot land + // in the gap between the flag check and the await. + notified.as_mut().enable(); + if !self.cancelled.load(Ordering::SeqCst) { notified.await; } } - fn status(&self, handle: LiveHandle) -> tinybus::Result { + pub(super) fn status(&self, handle: LiveHandle) -> tinybus::Result { let phase = self.phase.lock().map_err(|_| unavailable())?; Ok(LiveStatus { handle, @@ -133,7 +142,9 @@ impl Sessions { } leases.retain(|_, lease| { lease.phase.lock().is_ok_and(|phase| { - phase.state != LiveState::Reserved || lease.created.elapsed() < RESERVATION_TTL + let keep = phase.state != LiveState::Reserved || lease.created.elapsed() < RESERVATION_TTL; + if !keep { lease.cancel(); } + keep }) }); if leases.len() >= MAX_LEASES { @@ -152,7 +163,7 @@ impl Sessions { } } - fn lease(&self, handle: &LiveHandle) -> tinybus::Result> { + pub(super) fn lease(&self, handle: &LiveHandle) -> tinybus::Result> { self.leases .lock() .map_err(|_| unavailable())? @@ -213,110 +224,42 @@ impl Sessions { }) } - pub(in crate::service) async fn send( - &self, - request: LiveSendRequest, - ) -> tinybus::Result { - use base64::Engine as _; + pub(in crate::service) async fn send(&self, request: LiveSendRequest) -> tinybus::Result { let lease = self.lease(&request.handle)?; - let sender = { - let phase = lease.phase.lock().map_err(|_| unavailable())?; - phase - .sender - .clone() - .ok_or_else(|| tinybus::Error::failed("live session is not running"))? - }; - let command = match request.command { - LiveCommand::Audio { pcm16_base64 } => { - if pcm16_base64.len() > MAX_AUDIO_BYTES.div_ceil(3) * 4 { - return Err(tinybus::Error::failed("live audio exceeds its input limit")); - } - let pcm = base64::engine::general_purpose::STANDARD - .decode(pcm16_base64) - .map_err(|_| tinybus::Error::failed("invalid live audio encoding"))?; - if pcm.len() > MAX_AUDIO_BYTES || pcm.len() % 2 != 0 { - return Err(tinybus::Error::failed( - "live PCM16 audio exceeds its limit or has a partial sample", - )); - } - tinyagents_live::tinyliveagents::ClientCommand::Audio(pcm.into()) - } - LiveCommand::Text { text } => { - if text.len() > MAX_TEXT_BYTES { - return Err(tinybus::Error::failed("live text exceeds its input limit")); - } - tinyagents_live::tinyliveagents::ClientCommand::Text(text) - } - LiveCommand::Interrupt => tinyagents_live::tinyliveagents::ClientCommand::Interrupt, - LiveCommand::Stop => tinyagents_live::tinyliveagents::ClientCommand::Close, - }; - tokio::time::timeout(std::time::Duration::from_secs(1), sender.send(command)) - .await - .map_err(|_| tinybus::Error::failed("live command queue did not accept input"))? - .map_err(|error| tinybus::Error::failed(error.to_string()))?; - lease.status(request.handle) + ensure_manual_transport(&lease)?; + send_lease(&lease, request).await } - pub(in crate::service) async fn read( - &self, - request: LiveReadRequest, - ) -> tinybus::Result { + pub(in crate::service) async fn read(&self, request: LiveReadRequest) -> tinybus::Result { let lease = self.lease(&request.handle)?; - let mut reader = lease.reader.lock().await; - if let Some(ack) = request.acknowledged_batch { - if reader - .pending - .as_ref() - .is_some_and(|batch| batch.batch == ack) - { - reader.pending = None; - reader.last_acknowledged = ack; - } else if ack != reader.last_acknowledged { - return Err(tinybus::Error::failed( - "live batch acknowledgement is stale or unknown", - )); - } - } - if let Some(batch) = &reader.pending { - return Ok(batch.clone()); + ensure_manual_transport(&lease)?; + read_lease(&lease, request).await + } + + pub(in crate::service) fn bind_transport(&self, request: tinyvoice_bus::live::transport::LiveBindTransportRequest) -> tinybus::Result { + if !super::super::relay::fits(&request.port, 8 * 1024) { + return Err(tinybus::Error::failed("live transport endpoint exceeds its size limit")); } - let mut outputs = Vec::new(); - for _ in 0..MAX_GROUPS { - let group = reader.carry.take().or_else(|| reader.rx.try_recv().ok()); - let Some(group) = group else { break }; - let before = outputs.len(); - outputs.extend(group); - if !super::super::relay::fits(&outputs, MAX_BATCH_BYTES - 4096) { - reader.carry = Some(outputs.split_off(before)); - if outputs.is_empty() { - return Err(tinybus::Error::failed( - "live event group exceeds its output limit", - )); - } - break; - } + let lease = self.lease(&request.handle)?; + let connection = self.connection.lock().map_err(|_| unavailable())?.clone() + .ok_or_else(|| tinybus::Error::failed("live transport callback connection is unavailable"))?; + // Validate the endpoint grammar before claiming transport ownership. + connection.proxy(request.port.bus_name.as_str(), request.port.object_path.as_str(), tinyvoice_bus::live::transport::INTERFACE)?; + let mut phase = lease.phase.lock().map_err(|_| unavailable())?; + if let Some(port) = &phase.transport { + return if port == &request.port { + Ok(LiveStatus { handle: request.handle, state: phase.state }) + } else { Err(tinybus::Error::failed("live transport was already bound to a different endpoint")) }; } - let state = lease.status(request.handle.clone())?.state; - if outputs.is_empty() { - return Ok(LiveBatch { - handle: request.handle, - batch: reader.last_acknowledged, - state, - outputs, - }); + if phase.state != LiveState::Reserved || lease.cancelled.load(Ordering::SeqCst) || self.closed.load(Ordering::SeqCst) { + return Err(tinybus::Error::failed("live transport must bind to an open reservation")); } - let batch = LiveBatch { - handle: request.handle, - batch: reader.next_batch, - state, - outputs, - }; - reader.next_batch = reader - .next_batch - .checked_add(1) - .ok_or_else(|| tinybus::Error::failed("live batch sequence is exhausted"))?; - reader.pending = Some(batch.clone()); - Ok(batch) + phase.transport = Some(request.port.clone()); + phase.transport_cleanup_confirmed = false; + phase.transport_task = Some(tokio::spawn(super::transport::run( + lease.clone(), request.handle.clone(), request.port, connection, + ))); + Ok(LiveStatus { handle: request.handle, state: phase.state }) } pub(in crate::service) async fn release( @@ -376,42 +319,47 @@ impl Sessions { } async fn stop_lease(lease: &Lease) -> tinybus::Result<()> { + lease.cancel(); + let (provider, transport) = { + let mut phase = lease.phase.lock().map_err(|_| unavailable())?; + phase.sender = None; + phase.state = LiveState::Closed; + (phase.task.take().map(OwnedTask), phase.transport_task.take().map(OwnedTask)) + }; + // Own both abort guards before awaiting either cleanup future. + let (provider, transport) = tokio::join!(wait_owned(provider), wait_owned(transport)); + provider?; + transport?; + let phase = lease.phase.lock().map_err(|_| unavailable())?; + if !phase.cleanup_confirmed || !phase.transport_cleanup_confirmed { + return Err(tinybus::Error::failed("live native tasks stopped but host context cleanup was not acknowledged")); + } + Ok(()) +} + +/// The transport can finish its provider without waiting on its own join handle. +pub(super) async fn stop_provider(lease: &Lease) -> tinybus::Result<()> { lease.cancel(); let task = { let mut phase = lease.phase.lock().map_err(|_| unavailable())?; phase.sender = None; phase.state = LiveState::Closed; - phase.task.take() + phase.task.take().map(OwnedTask) }; - if let Some(task) = task { - // Cancellation of Release/Shutdown must not drop a bare JoinHandle, - // which would detach the provider worker instead of terminating it. - let mut owned = OwnedTask(task); + wait_owned(task).await +} + +async fn wait_owned(task: Option) -> tinybus::Result<()> { + if let Some(mut owned) = task { match tokio::time::timeout(std::time::Duration::from_secs(2), &mut owned.0).await { Err(_) => { owned.0.abort(); let _ = (&mut owned.0).await; - return Err(tinybus::Error::failed( - "live worker cleanup exceeded its deadline", - )); - } - Ok(Err(_)) => { - return Err(tinybus::Error::failed( - "live worker terminated unexpectedly", - )); + return Err(tinybus::Error::failed("live worker cleanup exceeded its deadline")); } + Ok(Err(_)) => return Err(tinybus::Error::failed("live worker terminated unexpectedly")), Ok(Ok(())) => {} } - if !lease - .phase - .lock() - .map_err(|_| unavailable())? - .cleanup_confirmed - { - return Err(tinybus::Error::failed( - "live provider stopped but host context cleanup was not acknowledged", - )); - } } Ok(()) } @@ -435,6 +383,10 @@ impl Drop for Sessions { { task.abort(); } + if let Ok(mut phase) = lease.phase.lock() + && let Some(task) = phase.transport_task.take() { + task.abort(); + } } } } @@ -455,3 +407,114 @@ pub(super) fn random_id() -> tinybus::Result { .map_err(|_| tinybus::Error::failed("live identity generation failed"))?; Ok(format!("{:032x}", u128::from_be_bytes(random))) } + +fn ensure_manual_transport(lease: &Lease) -> tinybus::Result<()> { + if lease.phase.lock().map_err(|_| unavailable())?.transport.is_some() { + Err(tinybus::Error::failed("bound live transport owns input and output")) + } else { Ok(()) } +} + +/// Native input admission shared by the bus adapter and owned transport pump. +pub(super) async fn send_lease(lease: &Lease, request: LiveSendRequest) -> tinybus::Result { + use base64::Engine as _; + let sender = { + let phase = lease.phase.lock().map_err(|_| unavailable())?; + phase + .sender + .clone() + .ok_or_else(|| tinybus::Error::failed("live session is not running"))? + }; + let command = match request.command { + LiveCommand::Audio { pcm16_base64 } => { + if pcm16_base64.len() > MAX_AUDIO_BYTES.div_ceil(3) * 4 { + return Err(tinybus::Error::failed("live audio exceeds its input limit")); + } + let pcm = base64::engine::general_purpose::STANDARD + .decode(pcm16_base64) + .map_err(|_| tinybus::Error::failed("invalid live audio encoding"))?; + if pcm.len() > MAX_AUDIO_BYTES || pcm.len() % 2 != 0 { + return Err(tinybus::Error::failed( + "live PCM16 audio exceeds its limit or has a partial sample", + )); + } + tinyagents_live::tinyliveagents::ClientCommand::Audio(pcm.into()) + } + LiveCommand::Text { text } => { + if text.len() > MAX_TEXT_BYTES { + return Err(tinybus::Error::failed("live text exceeds its input limit")); + } + tinyagents_live::tinyliveagents::ClientCommand::Text(text) + } + LiveCommand::Interrupt => tinyagents_live::tinyliveagents::ClientCommand::Interrupt, + LiveCommand::Stop => tinyagents_live::tinyliveagents::ClientCommand::Close, + }; + tokio::time::timeout(std::time::Duration::from_secs(1), sender.send(command)) + .await + .map_err(|_| tinybus::Error::failed("live command queue did not accept input"))? + .map_err(|error| tinybus::Error::failed(error.to_string()))?; + lease.status(request.handle) +} + +/// Ordered replayable output shared by manual and module-owned delivery. +pub(super) async fn read_lease(lease: &Lease, request: LiveReadRequest) -> tinybus::Result { + let mut reader = lease.reader.lock().await; + if let Some(ack) = request.acknowledged_batch { + if reader + .pending + .as_ref() + .is_some_and(|batch| batch.batch == ack) + { + reader.pending = None; + reader.last_acknowledged = ack; + } else if ack != reader.last_acknowledged { + return Err(tinybus::Error::failed( + "live batch acknowledgement is stale or unknown", + )); + } + } + if let Some(batch) = &reader.pending { + return Ok(batch.clone()); + } + let mut outputs = Vec::new(); + for _ in 0..MAX_GROUPS { + let group = reader.carry.take().or_else(|| reader.rx.try_recv().ok()); + let Some(group) = group else { break }; + let before = outputs.len(); + outputs.extend(group); + if !super::super::relay::fits(&outputs, MAX_BATCH_BYTES - 4096) { + reader.carry = Some(outputs.split_off(before)); + if outputs.is_empty() { + return Err(tinybus::Error::failed( + "live event group exceeds its output limit", + )); + } + break; + } + } + let state = lease.status(request.handle.clone())?.state; + if outputs.is_empty() { + return Ok(LiveBatch { + handle: request.handle, + batch: reader.last_acknowledged, + state, + outputs, + }); + } + let batch = LiveBatch { + handle: request.handle, + batch: reader.next_batch, + state, + outputs, + }; + reader.next_batch = reader + .next_batch + .checked_add(1) + .ok_or_else(|| tinybus::Error::failed("live batch sequence is exhausted"))?; + reader.pending = Some(batch.clone()); + Ok(batch) +} + +/// Bound reservations cannot retain an idle client forever without startup. +pub(super) fn reservation_expired(lease: &Lease) -> bool { + lease.created.elapsed() >= RESERVATION_TTL +} diff --git a/crates/tinyvoice-module/src/service/live/table_tests.rs b/crates/tinyvoice-module/src/service/live/table_tests.rs index 106a06d..9bc07d1 100644 --- a/crates/tinyvoice-module/src/service/live/table_tests.rs +++ b/crates/tinyvoice-module/src/service/live/table_tests.rs @@ -179,3 +179,23 @@ async fn cancelling_release_does_not_detach_the_owned_provider_worker() -> tinyb assert!(dropped.load(Ordering::SeqCst)); Ok(()) } + +#[tokio::test(start_paused = true)] +async fn cancelling_session_wakes_provider_and_transport_coordinators() -> tinybus::Result<()> { + let lease = Arc::new(Lease::new()); + let mut waiters = tokio::task::JoinSet::new(); + for _ in 0..2 { + let lease = lease.clone(); + waiters.spawn(async move { + lease.wait_cancelled().await; + }); + } + tokio::task::yield_now().await; + lease.cancel(); + tokio::time::timeout(std::time::Duration::from_millis(10), async { + while waiters.join_next().await.is_some() {} + }) + .await + .map_err(|_| unavailable())?; + Ok(()) +} From 7e6aac8dc519960c2e1985666315b06f9aa44732 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:12:57 +0300 Subject: [PATCH 32/59] refactor(live): reindent transport and table helpers Reformat the live transport and session table code to satisfy rustfmt, splitting long expressions and match arms across lines. The new transport module is registered in the live service module. No behaviour changes. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyvoice-bus/src/live/transport.rs | 12 +- .../tinyvoice-module/src/service/live/mod.rs | 1 + .../src/service/live/table.rs | 290 +++++++++++------- .../src/service/live/transport.rs | 213 +++++++++++++ 4 files changed, 404 insertions(+), 112 deletions(-) create mode 100644 crates/tinyvoice-module/src/service/live/transport.rs diff --git a/crates/tinyvoice-bus/src/live/transport.rs b/crates/tinyvoice-bus/src/live/transport.rs index 16e3d20..99820a3 100644 --- a/crates/tinyvoice-bus/src/live/transport.rs +++ b/crates/tinyvoice-bus/src/live/transport.rs @@ -1,7 +1,10 @@ //! Mechanical client socket and persistence callback contract for the module pump. +use super::{ + TransportFrame, + session::{LiveHandle, LiveOutput}, +}; use serde::{Deserialize, Serialize}; -use super::{TransportFrame, session::{LiveHandle, LiveOutput}}; /// Host interface providing mechanical socket/persistence callbacks. pub const INTERFACE: &str = "ai.tinyhumans.tinyvoice.LiveTransport"; @@ -25,8 +28,11 @@ pub struct LiveTransportPort { impl std::fmt::Debug for LiveTransportPort { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("LiveTransportPort").field("bus_name", &self.bus_name) - .field("object_path", &self.object_path).field("context_token", &"[redacted]").finish() + f.debug_struct("LiveTransportPort") + .field("bus_name", &self.bus_name) + .field("object_path", &self.object_path) + .field("context_token", &"[redacted]") + .finish() } } diff --git a/crates/tinyvoice-module/src/service/live/mod.rs b/crates/tinyvoice-module/src/service/live/mod.rs index 9ffd074..e279645 100644 --- a/crates/tinyvoice-module/src/service/live/mod.rs +++ b/crates/tinyvoice-module/src/service/live/mod.rs @@ -5,5 +5,6 @@ mod callback; mod prepare; mod projection; mod table; +mod transport; pub(super) use table::Sessions; diff --git a/crates/tinyvoice-module/src/service/live/table.rs b/crates/tinyvoice-module/src/service/live/table.rs index 0be544d..0960175 100644 --- a/crates/tinyvoice-module/src/service/live/table.rs +++ b/crates/tinyvoice-module/src/service/live/table.rs @@ -89,7 +89,9 @@ impl Lease { // Register before testing the sticky flag so cancellation cannot land // in the gap between the flag check and the await. notified.as_mut().enable(); - if !self.cancelled.load(Ordering::SeqCst) { notified.await; } + if !self.cancelled.load(Ordering::SeqCst) { + notified.await; + } } pub(super) fn status(&self, handle: LiveHandle) -> tinybus::Result { @@ -142,8 +144,11 @@ impl Sessions { } leases.retain(|_, lease| { lease.phase.lock().is_ok_and(|phase| { - let keep = phase.state != LiveState::Reserved || lease.created.elapsed() < RESERVATION_TTL; - if !keep { lease.cancel(); } + let keep = + phase.state != LiveState::Reserved || lease.created.elapsed() < RESERVATION_TTL; + if !keep { + lease.cancel(); + } keep }) }); @@ -224,42 +229,81 @@ impl Sessions { }) } - pub(in crate::service) async fn send(&self, request: LiveSendRequest) -> tinybus::Result { + pub(in crate::service) async fn send( + &self, + request: LiveSendRequest, + ) -> tinybus::Result { let lease = self.lease(&request.handle)?; ensure_manual_transport(&lease)?; send_lease(&lease, request).await } - pub(in crate::service) async fn read(&self, request: LiveReadRequest) -> tinybus::Result { + pub(in crate::service) async fn read( + &self, + request: LiveReadRequest, + ) -> tinybus::Result { let lease = self.lease(&request.handle)?; ensure_manual_transport(&lease)?; read_lease(&lease, request).await } - pub(in crate::service) fn bind_transport(&self, request: tinyvoice_bus::live::transport::LiveBindTransportRequest) -> tinybus::Result { + pub(in crate::service) fn bind_transport( + &self, + request: tinyvoice_bus::live::transport::LiveBindTransportRequest, + ) -> tinybus::Result { if !super::super::relay::fits(&request.port, 8 * 1024) { - return Err(tinybus::Error::failed("live transport endpoint exceeds its size limit")); + return Err(tinybus::Error::failed( + "live transport endpoint exceeds its size limit", + )); } let lease = self.lease(&request.handle)?; - let connection = self.connection.lock().map_err(|_| unavailable())?.clone() - .ok_or_else(|| tinybus::Error::failed("live transport callback connection is unavailable"))?; + let connection = self + .connection + .lock() + .map_err(|_| unavailable())? + .clone() + .ok_or_else(|| { + tinybus::Error::failed("live transport callback connection is unavailable") + })?; // Validate the endpoint grammar before claiming transport ownership. - connection.proxy(request.port.bus_name.as_str(), request.port.object_path.as_str(), tinyvoice_bus::live::transport::INTERFACE)?; + connection.proxy( + request.port.bus_name.as_str(), + request.port.object_path.as_str(), + tinyvoice_bus::live::transport::INTERFACE, + )?; let mut phase = lease.phase.lock().map_err(|_| unavailable())?; if let Some(port) = &phase.transport { return if port == &request.port { - Ok(LiveStatus { handle: request.handle, state: phase.state }) - } else { Err(tinybus::Error::failed("live transport was already bound to a different endpoint")) }; + Ok(LiveStatus { + handle: request.handle, + state: phase.state, + }) + } else { + Err(tinybus::Error::failed( + "live transport was already bound to a different endpoint", + )) + }; } - if phase.state != LiveState::Reserved || lease.cancelled.load(Ordering::SeqCst) || self.closed.load(Ordering::SeqCst) { - return Err(tinybus::Error::failed("live transport must bind to an open reservation")); + if phase.state != LiveState::Reserved + || lease.cancelled.load(Ordering::SeqCst) + || self.closed.load(Ordering::SeqCst) + { + return Err(tinybus::Error::failed( + "live transport must bind to an open reservation", + )); } phase.transport = Some(request.port.clone()); phase.transport_cleanup_confirmed = false; phase.transport_task = Some(tokio::spawn(super::transport::run( - lease.clone(), request.handle.clone(), request.port, connection, + lease.clone(), + request.handle.clone(), + request.port, + connection, ))); - Ok(LiveStatus { handle: request.handle, state: phase.state }) + Ok(LiveStatus { + handle: request.handle, + state: phase.state, + }) } pub(in crate::service) async fn release( @@ -324,7 +368,10 @@ async fn stop_lease(lease: &Lease) -> tinybus::Result<()> { let mut phase = lease.phase.lock().map_err(|_| unavailable())?; phase.sender = None; phase.state = LiveState::Closed; - (phase.task.take().map(OwnedTask), phase.transport_task.take().map(OwnedTask)) + ( + phase.task.take().map(OwnedTask), + phase.transport_task.take().map(OwnedTask), + ) }; // Own both abort guards before awaiting either cleanup future. let (provider, transport) = tokio::join!(wait_owned(provider), wait_owned(transport)); @@ -332,7 +379,9 @@ async fn stop_lease(lease: &Lease) -> tinybus::Result<()> { transport?; let phase = lease.phase.lock().map_err(|_| unavailable())?; if !phase.cleanup_confirmed || !phase.transport_cleanup_confirmed { - return Err(tinybus::Error::failed("live native tasks stopped but host context cleanup was not acknowledged")); + return Err(tinybus::Error::failed( + "live native tasks stopped but host context cleanup was not acknowledged", + )); } Ok(()) } @@ -355,9 +404,15 @@ async fn wait_owned(task: Option) -> tinybus::Result<()> { Err(_) => { owned.0.abort(); let _ = (&mut owned.0).await; - return Err(tinybus::Error::failed("live worker cleanup exceeded its deadline")); + return Err(tinybus::Error::failed( + "live worker cleanup exceeded its deadline", + )); + } + Ok(Err(_)) => { + return Err(tinybus::Error::failed( + "live worker terminated unexpectedly", + )); } - Ok(Err(_)) => return Err(tinybus::Error::failed("live worker terminated unexpectedly")), Ok(Ok(())) => {} } } @@ -384,7 +439,8 @@ impl Drop for Sessions { task.abort(); } if let Ok(mut phase) = lease.phase.lock() - && let Some(task) = phase.transport_task.take() { + && let Some(task) = phase.transport_task.take() + { task.abort(); } } @@ -409,109 +465,125 @@ pub(super) fn random_id() -> tinybus::Result { } fn ensure_manual_transport(lease: &Lease) -> tinybus::Result<()> { - if lease.phase.lock().map_err(|_| unavailable())?.transport.is_some() { - Err(tinybus::Error::failed("bound live transport owns input and output")) - } else { Ok(()) } + if lease + .phase + .lock() + .map_err(|_| unavailable())? + .transport + .is_some() + { + Err(tinybus::Error::failed( + "bound live transport owns input and output", + )) + } else { + Ok(()) + } } /// Native input admission shared by the bus adapter and owned transport pump. -pub(super) async fn send_lease(lease: &Lease, request: LiveSendRequest) -> tinybus::Result { - use base64::Engine as _; - let sender = { - let phase = lease.phase.lock().map_err(|_| unavailable())?; - phase - .sender - .clone() - .ok_or_else(|| tinybus::Error::failed("live session is not running"))? - }; - let command = match request.command { - LiveCommand::Audio { pcm16_base64 } => { - if pcm16_base64.len() > MAX_AUDIO_BYTES.div_ceil(3) * 4 { - return Err(tinybus::Error::failed("live audio exceeds its input limit")); - } - let pcm = base64::engine::general_purpose::STANDARD - .decode(pcm16_base64) - .map_err(|_| tinybus::Error::failed("invalid live audio encoding"))?; - if pcm.len() > MAX_AUDIO_BYTES || pcm.len() % 2 != 0 { - return Err(tinybus::Error::failed( - "live PCM16 audio exceeds its limit or has a partial sample", - )); - } - tinyagents_live::tinyliveagents::ClientCommand::Audio(pcm.into()) +pub(super) async fn send_lease( + lease: &Lease, + request: LiveSendRequest, +) -> tinybus::Result { + use base64::Engine as _; + let sender = { + let phase = lease.phase.lock().map_err(|_| unavailable())?; + phase + .sender + .clone() + .ok_or_else(|| tinybus::Error::failed("live session is not running"))? + }; + let command = match request.command { + LiveCommand::Audio { pcm16_base64 } => { + if pcm16_base64.len() > MAX_AUDIO_BYTES.div_ceil(3) * 4 { + return Err(tinybus::Error::failed("live audio exceeds its input limit")); } - LiveCommand::Text { text } => { - if text.len() > MAX_TEXT_BYTES { - return Err(tinybus::Error::failed("live text exceeds its input limit")); - } - tinyagents_live::tinyliveagents::ClientCommand::Text(text) + let pcm = base64::engine::general_purpose::STANDARD + .decode(pcm16_base64) + .map_err(|_| tinybus::Error::failed("invalid live audio encoding"))?; + if pcm.len() > MAX_AUDIO_BYTES || pcm.len() % 2 != 0 { + return Err(tinybus::Error::failed( + "live PCM16 audio exceeds its limit or has a partial sample", + )); + } + tinyagents_live::tinyliveagents::ClientCommand::Audio(pcm.into()) + } + LiveCommand::Text { text } => { + if text.len() > MAX_TEXT_BYTES { + return Err(tinybus::Error::failed("live text exceeds its input limit")); } - LiveCommand::Interrupt => tinyagents_live::tinyliveagents::ClientCommand::Interrupt, - LiveCommand::Stop => tinyagents_live::tinyliveagents::ClientCommand::Close, - }; - tokio::time::timeout(std::time::Duration::from_secs(1), sender.send(command)) - .await - .map_err(|_| tinybus::Error::failed("live command queue did not accept input"))? - .map_err(|error| tinybus::Error::failed(error.to_string()))?; - lease.status(request.handle) + tinyagents_live::tinyliveagents::ClientCommand::Text(text) + } + LiveCommand::Interrupt => tinyagents_live::tinyliveagents::ClientCommand::Interrupt, + LiveCommand::Stop => tinyagents_live::tinyliveagents::ClientCommand::Close, + }; + tokio::time::timeout(std::time::Duration::from_secs(1), sender.send(command)) + .await + .map_err(|_| tinybus::Error::failed("live command queue did not accept input"))? + .map_err(|error| tinybus::Error::failed(error.to_string()))?; + lease.status(request.handle) } /// Ordered replayable output shared by manual and module-owned delivery. -pub(super) async fn read_lease(lease: &Lease, request: LiveReadRequest) -> tinybus::Result { - let mut reader = lease.reader.lock().await; - if let Some(ack) = request.acknowledged_batch { - if reader - .pending - .as_ref() - .is_some_and(|batch| batch.batch == ack) - { - reader.pending = None; - reader.last_acknowledged = ack; - } else if ack != reader.last_acknowledged { +pub(super) async fn read_lease( + lease: &Lease, + request: LiveReadRequest, +) -> tinybus::Result { + let mut reader = lease.reader.lock().await; + if let Some(ack) = request.acknowledged_batch { + if reader + .pending + .as_ref() + .is_some_and(|batch| batch.batch == ack) + { + reader.pending = None; + reader.last_acknowledged = ack; + } else if ack != reader.last_acknowledged { + return Err(tinybus::Error::failed( + "live batch acknowledgement is stale or unknown", + )); + } + } + if let Some(batch) = &reader.pending { + return Ok(batch.clone()); + } + let mut outputs = Vec::new(); + for _ in 0..MAX_GROUPS { + let group = reader.carry.take().or_else(|| reader.rx.try_recv().ok()); + let Some(group) = group else { break }; + let before = outputs.len(); + outputs.extend(group); + if !super::super::relay::fits(&outputs, MAX_BATCH_BYTES - 4096) { + reader.carry = Some(outputs.split_off(before)); + if outputs.is_empty() { return Err(tinybus::Error::failed( - "live batch acknowledgement is stale or unknown", + "live event group exceeds its output limit", )); } + break; } - if let Some(batch) = &reader.pending { - return Ok(batch.clone()); - } - let mut outputs = Vec::new(); - for _ in 0..MAX_GROUPS { - let group = reader.carry.take().or_else(|| reader.rx.try_recv().ok()); - let Some(group) = group else { break }; - let before = outputs.len(); - outputs.extend(group); - if !super::super::relay::fits(&outputs, MAX_BATCH_BYTES - 4096) { - reader.carry = Some(outputs.split_off(before)); - if outputs.is_empty() { - return Err(tinybus::Error::failed( - "live event group exceeds its output limit", - )); - } - break; - } - } - let state = lease.status(request.handle.clone())?.state; - if outputs.is_empty() { - return Ok(LiveBatch { - handle: request.handle, - batch: reader.last_acknowledged, - state, - outputs, - }); - } - let batch = LiveBatch { + } + let state = lease.status(request.handle.clone())?.state; + if outputs.is_empty() { + return Ok(LiveBatch { handle: request.handle, - batch: reader.next_batch, + batch: reader.last_acknowledged, state, outputs, - }; - reader.next_batch = reader - .next_batch - .checked_add(1) - .ok_or_else(|| tinybus::Error::failed("live batch sequence is exhausted"))?; - reader.pending = Some(batch.clone()); - Ok(batch) + }); + } + let batch = LiveBatch { + handle: request.handle, + batch: reader.next_batch, + state, + outputs, + }; + reader.next_batch = reader + .next_batch + .checked_add(1) + .ok_or_else(|| tinybus::Error::failed("live batch sequence is exhausted"))?; + reader.pending = Some(batch.clone()); + Ok(batch) } /// Bound reservations cannot retain an idle client forever without startup. diff --git a/crates/tinyvoice-module/src/service/live/transport.rs b/crates/tinyvoice-module/src/service/live/transport.rs new file mode 100644 index 0000000..8b4c469 --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/transport.rs @@ -0,0 +1,213 @@ +//! Module-owned client/provider select loop over mechanical host callbacks. + +use super::table::{ + Lease, MAX_AUDIO_BYTES, MAX_BATCH_BYTES, MAX_TEXT_BYTES, read_lease, reservation_expired, + send_lease, stop_provider, +}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use std::{future::Future, pin::Pin, sync::Arc, time::Duration}; +use tinyvoice_bus::live::{ + ClientFrame, LiveTransportAction, PrepareLiveInputRequest, PreparedLiveInput, TranscriptRole, + session::{LiveCommand, LiveHandle, LiveOutput, LiveReadRequest, LiveSendRequest, LiveState}, + transport::{ + CLOSE_METHOD, CloseLiveTransportReply, CloseLiveTransportRequest, DELIVER_METHOD, + DeliverLiveEffectsReply, DeliverLiveEffectsRequest, INTERFACE, LiveTransportPort, + READ_METHOD, ReadLiveInputReply, ReadLiveInputRequest, + }, +}; + +const CALLBACK_TIMEOUT: Duration = Duration::from_secs(1); +type Incoming = Pin> + Send>>; + +/// Own the client coordinator until EOF, write failure, cancellation or native close. +pub(super) async fn run( + lease: Arc, + handle: LiveHandle, + port: LiveTransportPort, + connection: tinybus::Connection, +) { + let Ok(proxy) = connection.proxy(port.bus_name.as_str(), port.object_path.as_str(), INTERFACE) + else { + lease.cancel(); + return; + }; + // Select around the whole loop so cancellation interrupts callback waits, + // persistence acknowledgments and bounded native input admission as well. + tokio::select! { + biased; + () = lease.wait_cancelled() => {} + _ = coordinate(&lease, &handle, &port, &proxy) => {} + } + let _ = stop_provider(&lease).await; + let closed = tokio::time::timeout( + CALLBACK_TIMEOUT, + proxy.call::( + CLOSE_METHOD, + (CloseLiveTransportRequest { + context_token: port.context_token, + },), + ), + ) + .await; + if let Ok(mut phase) = lease.phase.lock() { + phase.transport_cleanup_confirmed = matches!(closed, Ok(Ok(reply)) if reply.closed); + } +} + +async fn coordinate( + lease: &Lease, + handle: &LiveHandle, + port: &LiveTransportPort, + proxy: &tinybus::Proxy, +) -> tinybus::Result<()> { + let mut input_ack = 0_u64; + let mut output_ack = None; + let mut input: Incoming = Box::pin(std::future::pending()); + let mut input_pending = false; + let mut clock = tokio::time::interval(Duration::from_millis(10)); + clock.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tokio::select! { + // Drain ready provider outputs even if an idle host input callback + // replies immediately, preventing starvation by empty polls. + biased; + _ = clock.tick() => { + let batch = read_lease(lease, LiveReadRequest { handle: handle.clone(), acknowledged_batch: output_ack }).await?; + if !batch.outputs.is_empty() { + deliver(proxy, port, handle, format!("output-{}", batch.batch), batch.outputs).await?; + output_ack = Some(batch.batch); + } else if batch.state == LiveState::Closed { + return Ok(()); + } + if batch.state == LiveState::Reserved && reservation_expired(lease) { + return Ok(()); + } + if !input_pending && batch.state == LiveState::Running { + input = next_input(proxy.clone(), port.clone(), input_ack); + input_pending = true; + } + } + reply = &mut input => { + input_pending = false; + input = Box::pin(std::future::pending()); + let reply = reply?; + if !super::super::relay::fits(&reply, MAX_BATCH_BYTES) { + return Err(tinybus::Error::failed("live client frame exceeds its serialized limit")); + } + let Some(frame) = reply.frame else { + if reply.sequence != input_ack { return Err(tinybus::Error::failed("idle live frame advanced its sequence")); } + continue; + }; + let expected = input_ack.checked_add(1).ok_or_else(|| tinybus::Error::failed("live client frame sequence is exhausted"))?; + if reply.sequence != expected { return Err(tinybus::Error::failed("live client frame sequence is not contiguous")); } + let prepared = tinyvoice::live::prepare_input(PrepareLiveInputRequest { awaiting_start: false, frame }) + .map_err(|error| tinybus::Error::failed(error.to_string()))?; + if !apply_input(lease, handle, port, proxy, reply.sequence, prepared).await? { + return Ok(()); + } + input_ack = reply.sequence; + } + } + } +} + +fn next_input(proxy: tinybus::Proxy, port: LiveTransportPort, ack: u64) -> Incoming { + Box::pin(async move { + tokio::time::timeout( + CALLBACK_TIMEOUT, + proxy.call( + READ_METHOD, + (ReadLiveInputRequest { + context_token: port.context_token, + acknowledged_frame: ack, + },), + ), + ) + .await + .map_err(|_| tinybus::Error::failed("live client input callback exceeded its deadline"))? + }) +} + +/// Preserve typed-text persistence before dispatch and native stop semantics. +async fn apply_input( + lease: &Lease, + handle: &LiveHandle, + port: &LiveTransportPort, + proxy: &tinybus::Proxy, + sequence: u64, + input: PreparedLiveInput, +) -> tinybus::Result { + let command = match input { + PreparedLiveInput::Control(ClientFrame::Text { text }) => { + if text.len() > MAX_TEXT_BYTES { + return Err(tinybus::Error::failed("live text exceeds its input limit")); + } + deliver( + proxy, + port, + handle, + format!("input-{sequence}"), + vec![LiveOutput::Persist { + role: TranscriptRole::User, + text: text.clone(), + }], + ) + .await?; + LiveCommand::Text { text } + } + PreparedLiveInput::Control(ClientFrame::Interrupt) => LiveCommand::Interrupt, + PreparedLiveInput::Control(ClientFrame::Stop) => LiveCommand::Stop, + PreparedLiveInput::Transport(LiveTransportAction::Audio { pcm16 }) => { + if pcm16.len() > MAX_AUDIO_BYTES { + return Err(tinybus::Error::failed("live audio exceeds its input limit")); + } + LiveCommand::Audio { + pcm16_base64: BASE64.encode(pcm16), + } + } + PreparedLiveInput::Transport(LiveTransportAction::Closed) => return Ok(false), + PreparedLiveInput::Control(ClientFrame::Start { .. }) + | PreparedLiveInput::Transport(LiveTransportAction::Ignore) => return Ok(true), + }; + send_lease( + lease, + LiveSendRequest { + handle: handle.clone(), + command, + }, + ) + .await?; + Ok(true) +} + +/// Retry a lost acknowledgement with the same host idempotency identity. +async fn deliver( + proxy: &tinybus::Proxy, + port: &LiveTransportPort, + handle: &LiveHandle, + delivery_id: String, + outputs: Vec, +) -> tinybus::Result<()> { + let request = DeliverLiveEffectsRequest { + context_token: port.context_token.clone(), + handle: handle.clone(), + delivery_id, + outputs, + }; + if !super::super::relay::fits(&request, 2 * MAX_BATCH_BYTES) { + return Err(tinybus::Error::failed( + "live transport delivery exceeds its serialized limit", + )); + } + for _ in 0..2 { + if matches!(tokio::time::timeout(CALLBACK_TIMEOUT, proxy.call::( + DELIVER_METHOD, (request.clone(),), + )).await, Ok(Ok(reply)) if reply.delivered) + { + return Ok(()); + } + } + Err(tinybus::Error::failed( + "live transport did not acknowledge delivery", + )) +} From 4e480b6193701efe5d95cb0f5ddd04f0a9536980 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:25:29 +0300 Subject: [PATCH 33/59] chore(vendor): bump tinybus submodule Update the vendored tinybus submodule to a newer revision. Auto-committed-on: dragonfly Co-authored-by: Medulla --- vendor/tinybus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinybus b/vendor/tinybus index 356bb24..10579d4 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit 356bb24f30d7863d81569c7e1f07a7ae60feda48 +Subproject commit 10579d486f276c78c9c4a2b5eaa8bfee0e302292 From 45a826334ad05ddd1647d5a642921d5ac693a549 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:26:28 +0300 Subject: [PATCH 34/59] chore(deps): bump tinybus to 0.1.3 Update the pinned tinybus and tinybus-module dependencies to 0.1.3, which pulls in libc for the module host. Add a regression test asserting that dropping the module owner releases its bus name so live callbacks no longer retain it. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyvoice-module/Cargo.lock | 7 ++++--- crates/tinyvoice-module/Cargo.toml | 4 ++-- .../tinyvoice-module/src/service/mod_tests.rs | 21 +++++++++++++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/crates/tinyvoice-module/Cargo.lock b/crates/tinyvoice-module/Cargo.lock index 4c9a184..a19ce67 100644 --- a/crates/tinyvoice-module/Cargo.lock +++ b/crates/tinyvoice-module/Cargo.lock @@ -2099,10 +2099,11 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.1.2" +version = "0.1.3" dependencies = [ "async-trait", "flate2", + "libc", "serde", "serde_json", "sha2 0.10.9", @@ -2119,7 +2120,7 @@ dependencies = [ [[package]] name = "tinybus-macros" -version = "0.1.2" +version = "0.1.3" dependencies = [ "proc-macro2", "quote", @@ -2128,7 +2129,7 @@ dependencies = [ [[package]] name = "tinybus-module" -version = "0.1.2" +version = "0.1.3" dependencies = [ "async-trait", "serde", diff --git a/crates/tinyvoice-module/Cargo.toml b/crates/tinyvoice-module/Cargo.toml index 2eb51d2..db1b5f6 100644 --- a/crates/tinyvoice-module/Cargo.toml +++ b/crates/tinyvoice-module/Cargo.toml @@ -36,9 +36,9 @@ tinyvoice-bus = { version = "0.1.2", path = "../tinyvoice-bus" } # crate exists so the vendored TinyBus dependency never reaches it. tinyvoice = { version = "0.1.16", path = "../tinyvoice", features = ["capture", "hotkey"] } # TinyBus provides the typed service interface and the dynamic module host ABI. -tinybus = { version = "=0.1.2", default-features = false, features = ["macros", "modules"] } +tinybus = { version = "=0.1.3", default-features = false, features = ["macros", "modules"] } # The module-side SDK owns its runtime and exports the stable C entrypoints. -tinybus-module = "=0.1.2" +tinybus-module = "=0.1.3" # The module's methods are `async fn` on a served object, so it needs a runtime # even though every operation underneath is synchronous. tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/crates/tinyvoice-module/src/service/mod_tests.rs b/crates/tinyvoice-module/src/service/mod_tests.rs index e3578c6..85ff2c1 100644 --- a/crates/tinyvoice-module/src/service/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/mod_tests.rs @@ -1090,3 +1090,24 @@ async fn live_reservations_exist_before_provider_startup_and_can_be_released() { .unwrap(); assert_eq!(released["state"], "closed"); } + + +#[tokio::test] +async fn live_callback_access_does_not_retain_the_module_bus_name_after_drop() { + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let service = Connection::connect(bus.connect().await.unwrap()).await.unwrap(); + setup(service.clone()).await.unwrap(); + let client = Connection::connect(bus.connect().await.unwrap()).await.unwrap(); + let proxy = client.proxy(BUS_NAME, OBJECT_PATH, BUS_NAME).unwrap(); + let reserved: serde_json::Value = proxy.call("LiveReserve", ()).await.unwrap(); + assert_eq!(reserved["state"], "reserved"); + drop(service); + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + let reply: tinybus::Result = proxy.call("LiveReserve", ()).await; + if reply.is_err() { break; } + tokio::task::yield_now().await; + } + }).await.expect("dropping the module owner must release its name"); +} From e8d613ee490f7ad58e7012048b05a596b9ce1d19 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:27:22 +0300 Subject: [PATCH 35/59] test(service): cover idempotent live transport binding Add a test asserting that binding a live transport twice returns the same reservation and that a competing manual read or a mismatched context token is rejected. The existing name-release test now polls the bus name list directly instead of relying on a call failing, which makes the assertion independent of the transport fixture. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinyvoice-module/src/service/mod_tests.rs | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/tinyvoice-module/src/service/mod_tests.rs b/crates/tinyvoice-module/src/service/mod_tests.rs index 85ff2c1..f54af25 100644 --- a/crates/tinyvoice-module/src/service/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/mod_tests.rs @@ -1105,9 +1105,35 @@ async fn live_callback_access_does_not_retain_the_module_bus_name_after_drop() { drop(service); tokio::time::timeout(std::time::Duration::from_secs(1), async { loop { - let reply: tinybus::Result = proxy.call("LiveReserve", ()).await; - if reply.is_err() { break; } + if !client.list_names().await.unwrap().iter().any(|name| name.as_str() == BUS_NAME) { break; } tokio::task::yield_now().await; } }).await.expect("dropping the module owner must release its name"); } + + +#[tokio::test] +async fn bound_live_transport_is_idempotent_and_excludes_competing_manual_readers() { + let proxy = connect().await.unwrap(); + let reserved: serde_json::Value = proxy.call("LiveReserve", ()).await.unwrap(); + let request = serde_json::json!({ + "handle": reserved["handle"], + "port": {"bus_name":"ai.test.Transport", "object_path":"/test/Transport", "context_token":"known"} + }); + let first: serde_json::Value = proxy.call("LiveBindTransport", &request).await.unwrap(); + assert_eq!(first["state"], "reserved"); + let repeated: serde_json::Value = proxy.call("LiveBindTransport", &request).await.unwrap(); + assert_eq!(first, repeated); + let rejected: tinybus::Result = proxy.call("LiveRead", serde_json::json!({ + "handle":reserved["handle"], "acknowledged_batch":null + })).await; + assert!(rejected.is_err()); + let changed = serde_json::json!({"handle":reserved["handle"],"port":{ + "bus_name":"ai.test.Transport","object_path":"/test/Transport","context_token":"different" + }}); + let rejected: tinybus::Result = proxy.call("LiveBindTransport", changed).await; + assert!(rejected.is_err()); + // This fixture deliberately has no callback endpoint. Teardown fails + // boundedly rather than pretending the host acknowledged socket cleanup. + let _: tinybus::Result = proxy.call("LiveRelease", serde_json::json!({"handle":reserved["handle"]})).await; +} From 7c61cab0c7d5ebff5e544ad2c78e2227c3f7e3b6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:28:10 +0300 Subject: [PATCH 36/59] chore: files changed MODULE.md,crates/tinyvoice-bus/src/names/mod.rs,crates/tinyvoice-module/src/ser Auto-committed-on: dragonfly Co-authored-by: Medulla --- MODULE.md | 3 +- crates/tinyvoice-bus/src/names/mod.rs | 3 ++ .../src/service/live/table.rs | 10 +++-- crates/tinyvoice-module/src/service/mod.rs | 8 ++++ .../tinyvoice-module/src/service/mod_tests.rs | 45 ++++++++++++++----- docs/specs/live-module-runtime.md | 2 + docs/specs/module-capture.md | 2 +- 7 files changed, 56 insertions(+), 17 deletions(-) diff --git a/MODULE.md b/MODULE.md index ae1ed12..a60714d 100644 --- a/MODULE.md +++ b/MODULE.md @@ -4,7 +4,7 @@ This package contains the native `tinyvoice` module for TinyBus module ABI v1. Install only the archive matching the host operating system and architecture. The module claims `ai.tinyhumans.tinyvoice.Voice`, serves the object at -`/ai/tinyhumans/tinyvoice/Voice`, and provides 41 methods under contract 1.7. +`/ai/tinyhumans/tinyvoice/Voice`, and provides 42 methods under contract 1.7. ## Methods @@ -22,6 +22,7 @@ The module claims `ai.tinyhumans.tinyvoice.Voice`, serves the object at | `VadClose` | `session` | — | | `PrepareRelayTurn` | `RelayTurnRequest { messages, readback_prefix }` | `PreparedRelayTurn`: prompt, ordered history pairs, optional readback payload, content-free, speak-back and answerable flags | | `PrepareLiveInput` | `PrepareLiveInputRequest { awaiting_start, frame }` | parsed control or transport action; host owns policy validation | +| `LiveBindTransport` | reserved handle and mechanical host transport port | idempotent `LiveStatus`; module owns the client/provider coordinator | | `LiveReserve` | none | opaque `LiveStatus` reserved before connection startup | | `LiveStart` | `LiveStartRequest` | idempotent starting/running status for identical inputs | | `LiveSend` | handle and text, PCM16 audio, interrupt or stop command | status after bounded input admission | diff --git a/crates/tinyvoice-bus/src/names/mod.rs b/crates/tinyvoice-bus/src/names/mod.rs index 942c8ca..fa9de7e 100644 --- a/crates/tinyvoice-bus/src/names/mod.rs +++ b/crates/tinyvoice-bus/src/names/mod.rs @@ -9,6 +9,8 @@ pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinyvoice/Voice"; /// One constant per member of [`BUS_NAME`]. pub mod methods { /// Reserve a bounded live session before startup. + pub const LIVE_BIND_TRANSPORT: &str = "LiveBindTransport"; + /// Reserve a live provider session. pub const LIVE_RESERVE: &str = "LiveReserve"; /// Start native provider and tool-loop execution on a reserved handle. pub const LIVE_START: &str = "LiveStart"; @@ -116,6 +118,7 @@ pub const METHODS: &[&str] = &[ methods::HOTKEY_STOP, methods::IS_HALLUCINATED, methods::LIST_INPUT_DEVICES, + methods::LIVE_BIND_TRANSPORT, methods::LIVE_READ, methods::LIVE_RELEASE, methods::LIVE_RESERVE, diff --git a/crates/tinyvoice-module/src/service/live/table.rs b/crates/tinyvoice-module/src/service/live/table.rs index 0960175..14cc950 100644 --- a/crates/tinyvoice-module/src/service/live/table.rs +++ b/crates/tinyvoice-module/src/service/live/table.rs @@ -107,7 +107,7 @@ impl Lease { pub(in crate::service) struct Sessions { closed: AtomicBool, leases: Mutex>>, - connection: Mutex>, + connection: Mutex>, } impl std::fmt::Debug for Sessions { @@ -131,7 +131,7 @@ impl Default for Sessions { impl Sessions { pub(in crate::service) fn with_connection(connection: tinybus::Connection) -> Self { Self { - connection: Mutex::new(Some(connection)), + connection: Mutex::new(Some(connection.downgrade())), closed: AtomicBool::new(false), leases: Mutex::new(HashMap::new()), } @@ -191,7 +191,8 @@ impl Sessions { .connection .lock() .map_err(|_| unavailable())? - .clone() + .as_ref() + .and_then(tinybus::WeakConnection::upgrade) .ok_or_else(|| tinybus::Error::failed("live callback connection is unavailable"))?; let mut phase = lease.phase.lock().map_err(|_| unavailable())?; if let Some(previous) = &phase.request { @@ -261,7 +262,8 @@ impl Sessions { .connection .lock() .map_err(|_| unavailable())? - .clone() + .as_ref() + .and_then(tinybus::WeakConnection::upgrade) .ok_or_else(|| { tinybus::Error::failed("live transport callback connection is unavailable") })?; diff --git a/crates/tinyvoice-module/src/service/mod.rs b/crates/tinyvoice-module/src/service/mod.rs index 81cc41f..e0ce94c 100644 --- a/crates/tinyvoice-module/src/service/mod.rs +++ b/crates/tinyvoice-module/src/service/mod.rs @@ -193,6 +193,13 @@ impl VoiceService { async fn live_reserve(&self) -> TinyBusResult { self.live.reserve() } + /// Attach the module-owned socket coordinator before provider startup. + async fn live_bind_transport( + &self, + request: tinyvoice_bus::live::transport::LiveBindTransportRequest, + ) -> TinyBusResult { + self.live.bind_transport(request) + } /// Begin provider startup idempotently on a known reservation. async fn live_start( &self, @@ -742,6 +749,7 @@ tinybus_module::module_export_optional_static! { "PrepareRelayTurn", "PrepareLiveInput", "LiveReserve", + "LiveBindTransport", "LiveStart", "LiveSend", "LiveRead", diff --git a/crates/tinyvoice-module/src/service/mod_tests.rs b/crates/tinyvoice-module/src/service/mod_tests.rs index f54af25..fbebf4d 100644 --- a/crates/tinyvoice-module/src/service/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/mod_tests.rs @@ -1091,27 +1091,39 @@ async fn live_reservations_exist_before_provider_startup_and_can_be_released() { assert_eq!(released["state"], "closed"); } - #[tokio::test] async fn live_callback_access_does_not_retain_the_module_bus_name_after_drop() { let bus = MemoryBus::new(); Broker::new().spawn(bus.clone()); - let service = Connection::connect(bus.connect().await.unwrap()).await.unwrap(); + let service = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); setup(service.clone()).await.unwrap(); - let client = Connection::connect(bus.connect().await.unwrap()).await.unwrap(); + let client = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); let proxy = client.proxy(BUS_NAME, OBJECT_PATH, BUS_NAME).unwrap(); let reserved: serde_json::Value = proxy.call("LiveReserve", ()).await.unwrap(); assert_eq!(reserved["state"], "reserved"); drop(service); tokio::time::timeout(std::time::Duration::from_secs(1), async { loop { - if !client.list_names().await.unwrap().iter().any(|name| name.as_str() == BUS_NAME) { break; } + if !client + .list_names() + .await + .unwrap() + .iter() + .any(|name| name.as_str() == BUS_NAME) + { + break; + } tokio::task::yield_now().await; } - }).await.expect("dropping the module owner must release its name"); + }) + .await + .expect("dropping the module owner must release its name"); } - #[tokio::test] async fn bound_live_transport_is_idempotent_and_excludes_competing_manual_readers() { let proxy = connect().await.unwrap(); @@ -1124,16 +1136,27 @@ async fn bound_live_transport_is_idempotent_and_excludes_competing_manual_reader assert_eq!(first["state"], "reserved"); let repeated: serde_json::Value = proxy.call("LiveBindTransport", &request).await.unwrap(); assert_eq!(first, repeated); - let rejected: tinybus::Result = proxy.call("LiveRead", serde_json::json!({ - "handle":reserved["handle"], "acknowledged_batch":null - })).await; + let rejected: tinybus::Result = proxy + .call( + "LiveRead", + serde_json::json!({ + "handle":reserved["handle"], "acknowledged_batch":null + }), + ) + .await; assert!(rejected.is_err()); let changed = serde_json::json!({"handle":reserved["handle"],"port":{ "bus_name":"ai.test.Transport","object_path":"/test/Transport","context_token":"different" }}); - let rejected: tinybus::Result = proxy.call("LiveBindTransport", changed).await; + let rejected: tinybus::Result = + proxy.call("LiveBindTransport", changed).await; assert!(rejected.is_err()); // This fixture deliberately has no callback endpoint. Teardown fails // boundedly rather than pretending the host acknowledged socket cleanup. - let _: tinybus::Result = proxy.call("LiveRelease", serde_json::json!({"handle":reserved["handle"]})).await; + let _: tinybus::Result = proxy + .call( + "LiveRelease", + serde_json::json!({"handle":reserved["handle"]}), + ) + .await; } diff --git a/docs/specs/live-module-runtime.md b/docs/specs/live-module-runtime.md index ec08754..c87a5f0 100644 --- a/docs/specs/live-module-runtime.md +++ b/docs/specs/live-module-runtime.md @@ -85,3 +85,5 @@ not create or revive unknown contexts/invocations. TinyBus stops waiting without cancelling remote execution, so the host must cancel its own invocation leases on endpoint/context teardown as well. Remote notification is bounded best effort; native provider termination does not depend on receiving it. + +The runtime requires the non-owning connection capability proposed in TinyBus [PR #50](https://github.com/tinyhumansai/tinybus/pull/50). Served module objects store `WeakConnection`; active work upgrades it temporarily. OpenHuman adoption must wait for the prerequisite and a released, admitted TinyVoice artifact. diff --git a/docs/specs/module-capture.md b/docs/specs/module-capture.md index 31905bd..6a5a54c 100644 --- a/docs/specs/module-capture.md +++ b/docs/specs/module-capture.md @@ -76,7 +76,7 @@ names, error detail, handles, audio, paths and request arguments. Local fixtures exercise lifecycle, bounds, faults and in-memory bus dispatch without hardware or external services. The actual compiled artifact verifier -checks all 41 declared members and permission denial without opening a device. +checks all 42 declared members and permission denial without opening a device. The native device bridge follows the repository's existing physical-device coverage exception; manager and processing code remain subject to the 90% per-file threshold. From fd8f026ff89be8085281aede3457b96a70533213 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:29:03 +0300 Subject: [PATCH 37/59] test(live): add test module for live transport Wire the new transport test file into the live transport module behind cfg(test) so its tests are compiled and run with the crate. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/service/live/transport.rs | 4 + .../src/service/live/transport_tests.rs | 224 ++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 crates/tinyvoice-module/src/service/live/transport_tests.rs diff --git a/crates/tinyvoice-module/src/service/live/transport.rs b/crates/tinyvoice-module/src/service/live/transport.rs index 8b4c469..142bb06 100644 --- a/crates/tinyvoice-module/src/service/live/transport.rs +++ b/crates/tinyvoice-module/src/service/live/transport.rs @@ -211,3 +211,7 @@ async fn deliver( "live transport did not acknowledge delivery", )) } + +#[cfg(test)] +#[path = "transport_tests.rs"] +mod tests; diff --git a/crates/tinyvoice-module/src/service/live/transport_tests.rs b/crates/tinyvoice-module/src/service/live/transport_tests.rs new file mode 100644 index 0000000..d9b3c31 --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/transport_tests.rs @@ -0,0 +1,224 @@ +//! Actual bus callbacks pin delivery retry, EOF and bounded socket cleanup. + +use super::super::table::Sessions; +use super::*; +use std::collections::HashSet; +use std::sync::{ + Mutex, + atomic::{AtomicUsize, Ordering}, +}; +use tinybus::{broker::Broker, transport::memory::MemoryBus}; +use tinyvoice_bus::live::{TransportFrame, transport::LiveBindTransportRequest}; + +#[derive(Clone, Default)] +struct Socket { + committed: Arc>>, + effects: Arc>>, + attempts: Arc, + closes: Arc, + closed: Arc, + first_reply_lost: bool, + unresponsive_close: bool, + invalid_sequence: bool, +} + +#[tinybus::interface(name = "ai.tinyhumans.tinyvoice.LiveTransport")] +impl Socket { + async fn read_live_input( + &self, + request: ReadLiveInputRequest, + ) -> tinybus::Result { + Ok(ReadLiveInputReply { + sequence: request.acknowledged_frame + if self.invalid_sequence { 2 } else { 1 }, + frame: Some(TransportFrame::Closed), + }) + } + async fn deliver_live_effects( + &self, + request: DeliverLiveEffectsRequest, + ) -> tinybus::Result { + let attempt = self.attempts.fetch_add(1, Ordering::SeqCst); + if self + .committed + .lock() + .map_err(|_| tinybus::Error::failed("fixture lock"))? + .insert(request.delivery_id) + { + self.effects + .lock() + .map_err(|_| tinybus::Error::failed("fixture lock"))? + .extend(request.outputs); + } + if self.first_reply_lost && attempt == 0 { + return Err(tinybus::Error::failed( + "fixture lost acknowledgement after commit", + )); + } + Ok(DeliverLiveEffectsReply { delivered: true }) + } + async fn close_live_transport( + &self, + _request: CloseLiveTransportRequest, + ) -> tinybus::Result { + if self.unresponsive_close { + std::future::pending::<()>().await; + } + self.closes.fetch_add(1, Ordering::SeqCst); + self.closed.notify_one(); + Ok(CloseLiveTransportReply { closed: true }) + } +} + +async fn fixture( + socket: Socket, +) -> tinybus::Result<( + Sessions, + tinybus::Connection, + tinybus::Connection, + LiveHandle, + Arc, +)> { + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let host = tinybus::Connection::connect(bus.connect().await?).await?; + host.serve_at("/test/Transport".try_into()?, socket).await?; + host.request_name("ai.test.Transport").await?; + let module = tinybus::Connection::connect(bus.connect().await?).await?; + let sessions = Sessions::with_connection(module.clone()); + let handle = sessions.reserve()?.handle; + let lease = sessions.lease(&handle)?; + sessions.bind_transport(LiveBindTransportRequest { + handle: handle.clone(), + port: LiveTransportPort { + bus_name: "ai.test.Transport".into(), + object_path: "/test/Transport".into(), + context_token: "known".into(), + }, + })?; + Ok((sessions, module, host, handle, lease)) +} + +#[tokio::test] +async fn lost_delivery_reply_reuses_identity_and_preserves_order_without_duplicate_persistence() +-> tinybus::Result<()> { + let socket = Socket { + first_reply_lost: true, + ..Socket::default() + }; + let (sessions, _module, _host, handle, lease) = fixture(socket.clone()).await?; + let effects = vec![ + LiveOutput::JsonServerFrame { + frame: ServerFrame::Transcript { + role: TranscriptRole::Agent, + text: "complete".into(), + final_transcript: true, + }, + }, + LiveOutput::Persist { + role: TranscriptRole::Agent, + text: "complete".into(), + }, + ]; + lease + .outputs + .send(effects.clone()) + .await + .map_err(|_| tinybus::Error::failed("fixture queue"))?; + lease + .phase + .lock() + .map_err(|_| tinybus::Error::failed("fixture lock"))? + .state = LiveState::Closed; + tokio::time::timeout(Duration::from_secs(1), socket.closed.notified()) + .await + .map_err(|_| tinybus::Error::failed("fixture deadline"))?; + assert_eq!(socket.attempts.load(Ordering::SeqCst), 2); + assert_eq!( + *socket + .effects + .lock() + .map_err(|_| tinybus::Error::failed("fixture lock"))?, + effects + ); + assert_eq!( + socket + .committed + .lock() + .map_err(|_| tinybus::Error::failed("fixture lock"))? + .len(), + 1 + ); + sessions.release(handle).await?; + Ok(()) +} + +#[tokio::test] +async fn client_eof_stops_module_coordinator_and_closes_the_known_socket_once() +-> tinybus::Result<()> { + let socket = Socket::default(); + let (sessions, _module, _host, handle, lease) = fixture(socket.clone()).await?; + lease + .phase + .lock() + .map_err(|_| tinybus::Error::failed("fixture lock"))? + .state = LiveState::Running; + tokio::time::timeout(Duration::from_secs(1), socket.closed.notified()) + .await + .map_err(|_| tinybus::Error::failed("fixture deadline"))?; + assert!(lease.cancelled.load(Ordering::SeqCst)); + assert_eq!(socket.closes.load(Ordering::SeqCst), 1); + sessions.release(handle.clone()).await?; + sessions.release(handle).await?; + assert_eq!(socket.closes.load(Ordering::SeqCst), 1); + Ok(()) +} + +#[tokio::test] +async fn noncontiguous_socket_input_fails_closed_without_applying_effects() -> tinybus::Result<()> { + let socket = Socket { + invalid_sequence: true, + ..Socket::default() + }; + let (sessions, _module, _host, handle, lease) = fixture(socket.clone()).await?; + lease + .phase + .lock() + .map_err(|_| tinybus::Error::failed("fixture lock"))? + .state = LiveState::Running; + tokio::time::timeout(Duration::from_secs(1), socket.closed.notified()) + .await + .map_err(|_| tinybus::Error::failed("fixture deadline"))?; + assert_eq!(socket.attempts.load(Ordering::SeqCst), 0); + sessions.release(handle).await?; + Ok(()) +} + +#[tokio::test(start_paused = true)] +async fn unresponsive_socket_cleanup_is_bounded_and_never_acknowledged_as_success() +-> tinybus::Result<()> { + let socket = Socket { + unresponsive_close: true, + ..Socket::default() + }; + let (sessions, _module, _host, handle, lease) = fixture(socket.clone()).await?; + lease + .phase + .lock() + .map_err(|_| tinybus::Error::failed("fixture lock"))? + .state = LiveState::Running; + // Release cancels the active callback as well as the coordinator. The host + // refuses cleanup forever; the module must return within its fixed bound. + let before = tokio::time::Instant::now(); + assert!(sessions.release(handle.clone()).await.is_err()); + assert!(before.elapsed() <= Duration::from_secs(2)); + assert_eq!(socket.closes.load(Ordering::SeqCst), 0); + assert!( + !lease + .phase + .lock() + .map_err(|_| tinybus::Error::failed("fixture lock"))? + .transport_cleanup_confirmed + ); + sessions.release(handle).await?; + Ok(()) +} From 9afa7aab15bfa6e333d969dbb578a1d803ce1b46 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:29:29 +0300 Subject: [PATCH 38/59] test(live): update transport tests for renamed frame types Adjust the live transport tests to use the renamed ServerFrame import and the updated LiveOutput::Json variant with its is_final field, keeping the tests aligned with the current API. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyvoice-module/src/service/live/transport_tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/tinyvoice-module/src/service/live/transport_tests.rs b/crates/tinyvoice-module/src/service/live/transport_tests.rs index d9b3c31..7f9b20c 100644 --- a/crates/tinyvoice-module/src/service/live/transport_tests.rs +++ b/crates/tinyvoice-module/src/service/live/transport_tests.rs @@ -8,7 +8,7 @@ use std::sync::{ atomic::{AtomicUsize, Ordering}, }; use tinybus::{broker::Broker, transport::memory::MemoryBus}; -use tinyvoice_bus::live::{TransportFrame, transport::LiveBindTransportRequest}; +use tinyvoice_bus::live::{ServerFrame,TransportFrame, transport::LiveBindTransportRequest}; #[derive(Clone, Default)] struct Socket { @@ -107,11 +107,11 @@ async fn lost_delivery_reply_reuses_identity_and_preserves_order_without_duplica }; let (sessions, _module, _host, handle, lease) = fixture(socket.clone()).await?; let effects = vec![ - LiveOutput::JsonServerFrame { + LiveOutput::Json { frame: ServerFrame::Transcript { role: TranscriptRole::Agent, text: "complete".into(), - final_transcript: true, + is_final: true, }, }, LiveOutput::Persist { From 213b266a14b9e29ab226fc8ca3b071d21ce88df0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:30:11 +0300 Subject: [PATCH 39/59] test(live): cover oversized provider startup failures Add a test that drives a provider whose connect error exceeds the text limit and asserts the actor emits a bounded invalid_response error followed by a closed frame while still finishing the lease exactly once. Also fix import spacing in the transport tests. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/service/live/actor_tests.rs | 45 +++++++++++++++++++ .../src/service/live/transport_tests.rs | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/tinyvoice-module/src/service/live/actor_tests.rs b/crates/tinyvoice-module/src/service/live/actor_tests.rs index 8178d8e..659d1e8 100644 --- a/crates/tinyvoice-module/src/service/live/actor_tests.rs +++ b/crates/tinyvoice-module/src/service/live/actor_tests.rs @@ -107,3 +107,48 @@ async fn repeated_and_late_finish_only_revoke_known_host_context_once() -> tinyb assert_eq!(cleanup.finishes.load(Ordering::SeqCst), 1); Ok(()) } + +#[derive(Debug)] +struct FailingProvider(tinyagents_live::tinyliveagents::Capabilities); + +#[async_trait::async_trait] +impl tinyagents_live::tinyliveagents::LiveProvider for FailingProvider { + fn id(&self) -> &'static str { + "fixture" + } + fn capabilities(&self) -> tinyagents_live::tinyliveagents::Capabilities { + self.0.clone() + } + async fn connect( + &self, + _config: tinyagents_live::tinyliveagents::LiveConfig, + ) -> tinyagents_live::tinyliveagents::Result { + Err(Error::Provider("\0".repeat(MAX_TEXT_BYTES + 1))) + } +} + +#[tokio::test] +async fn oversized_provider_startup_failure_becomes_a_bounded_terminal_reply() -> anyhow::Result<()> +{ + let (module, _host, port, cleanup) = cleanup_fixture(false).await?; + let mut request: LiveStartRequest = serde_json::from_str(include_str!("fixtures/start.json"))?; + request.tool_port = port; + let mut prepared = super::super::prepare::prepare(&request, &module)?; + prepared.provider = Box::new(FailingProvider(prepared.provider.capabilities())); + let lease = Arc::new(Lease::new()); + run(lease.clone(), request.clone(), prepared, module).await; + let batch = super::super::table::read_lease( + &lease, + tinyvoice_bus::live::session::LiveReadRequest { + handle: request.handle, + acknowledged_batch: None, + }, + ) + .await?; + assert!( + matches!(batch.outputs.as_slice(), [LiveOutput::Json { frame: ServerFrame::Error { code, .. } }, LiveOutput::Json { frame: ServerFrame::Closed { .. } }] if code == "invalid_response") + ); + assert!(super::super::super::relay::fits(&batch, MAX_TEXT_BYTES)); + assert_eq!(cleanup.finishes.load(Ordering::SeqCst), 1); + Ok(()) +} diff --git a/crates/tinyvoice-module/src/service/live/transport_tests.rs b/crates/tinyvoice-module/src/service/live/transport_tests.rs index 7f9b20c..001b133 100644 --- a/crates/tinyvoice-module/src/service/live/transport_tests.rs +++ b/crates/tinyvoice-module/src/service/live/transport_tests.rs @@ -8,7 +8,7 @@ use std::sync::{ atomic::{AtomicUsize, Ordering}, }; use tinybus::{broker::Broker, transport::memory::MemoryBus}; -use tinyvoice_bus::live::{ServerFrame,TransportFrame, transport::LiveBindTransportRequest}; +use tinyvoice_bus::live::{ServerFrame, TransportFrame, transport::LiveBindTransportRequest}; #[derive(Clone, Default)] struct Socket { From 37f89f16a371c4778b6b4522433433db438f6650 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:30:43 +0300 Subject: [PATCH 40/59] refactor(live): extract bounded projection helper The oversized-event check and the projection call are now combined in a single bounded_projection helper, so the startup error path and the streaming path share the same bounding logic instead of duplicating it. This also ensures provider errors on the startup path are bounded before projection. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/service/live/actor.rs | 57 ++++++++++--------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/crates/tinyvoice-module/src/service/live/actor.rs b/crates/tinyvoice-module/src/service/live/actor.rs index d2f4071..5d1f500 100644 --- a/crates/tinyvoice-module/src/service/live/actor.rs +++ b/crates/tinyvoice-module/src/service/live/actor.rs @@ -56,11 +56,9 @@ pub(super) async fn run( drop(session); } Some(Err(error)) => { - let outputs = super::projection::map_event( + let outputs = bounded_projection( LiveAgentEvent::Live(LiveEvent::Closed(CloseReason::Error(error))), - &request.session_id, - &request.provider, - &request.thread_id, + &request, ); let _ = lease.outputs.send(outputs).await; } @@ -94,31 +92,12 @@ async fn forward(lease: &Lease, session: &mut LiveAgentSession, request: &LiveSt } } event => { - if oversized(&event) { - let outputs = vec![ - LiveOutput::Json { - frame: ServerFrame::Error { - code: "invalid_response".into(), - message: "live provider text exceeds the bounded output limit" - .into(), - fatal: true, - }, - }, - LiveOutput::Json { - frame: ServerFrame::Closed { - reason: "error".into(), - }, - }, - ]; + let rejected = oversized(&event); + let outputs = bounded_projection(event, request); + if rejected { let _ = lease.outputs.send(outputs).await; return; } - let outputs = super::projection::map_event( - event, - &request.session_id, - &request.provider, - &request.thread_id, - ); if !outputs.is_empty() && lease.outputs.send(outputs).await.is_err() { return; } @@ -130,6 +109,32 @@ async fn forward(lease: &Lease, session: &mut LiveAgentSession, request: &LiveSt } } +/// Bound provider errors on both startup and streaming paths before projection. +fn bounded_projection(event: LiveAgentEvent, request: &LiveStartRequest) -> Vec { + if oversized(&event) { + return vec![ + LiveOutput::Json { + frame: ServerFrame::Error { + code: "invalid_response".into(), + message: "live provider text exceeds the bounded output limit".into(), + fatal: true, + }, + }, + LiveOutput::Json { + frame: ServerFrame::Closed { + reason: "error".into(), + }, + }, + ]; + } + super::projection::map_event( + event, + &request.session_id, + &request.provider, + &request.thread_id, + ) +} + fn oversized(event: &LiveAgentEvent) -> bool { match event { LiveAgentEvent::Live( From cbdd735096c32be6837658dab64412c54a46776e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:31:34 +0300 Subject: [PATCH 41/59] test(live): cover bound transport and callback cancellation Extend the live verification example to bind a client transport and exercise input reads, effect delivery with a lost reply, and terminal close output. Add a regression test asserting the callback cancellation guard can be dropped outside its creating runtime context. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../examples/verify_module/live.rs | 131 +++++++++++++++++- .../src/service/live/callback_tests.rs | 24 ++++ 2 files changed, 153 insertions(+), 2 deletions(-) diff --git a/crates/tinyvoice-module/examples/verify_module/live.rs b/crates/tinyvoice-module/examples/verify_module/live.rs index a75b7f5..c4f32d7 100644 --- a/crates/tinyvoice-module/examples/verify_module/live.rs +++ b/crates/tinyvoice-module/examples/verify_module/live.rs @@ -5,12 +5,17 @@ use serde_json::{Value, json}; use std::io; use std::sync::Arc; use tinyvoice_bus::live::{ - ServerFrame, + ServerFrame, TranscriptRole, TransportFrame, session::{ ExecuteLiveToolReply, ExecuteLiveToolRequest, FinishLiveContextReply, FinishLiveContextRequest, LiveBatch, LiveCommand, LiveHandle, LiveHandleRequest, LiveOutput, LiveReadRequest, LiveSendRequest, LiveStartRequest, LiveState, LiveStatus, }, + transport::{ + CloseLiveTransportReply, CloseLiveTransportRequest, DeliverLiveEffectsReply, + DeliverLiveEffectsRequest, LiveBindTransportRequest, LiveTransportPort, ReadLiveInputReply, + ReadLiveInputRequest, + }, }; use tokio::sync::Mutex; @@ -75,7 +80,7 @@ pub(super) async fn verify_runtime( if started.state != LiveState::Starting { return Err(io::Error::other("start did not admit asynchronously").into()); } - let retry: LiveStatus = proxy.call("LiveStart", (request,)).await?; + let retry: LiveStatus = proxy.call("LiveStart", (request.clone(),)).await?; if retry.handle != started.handle { return Err(io::Error::other("start retry changed handle").into()); } @@ -105,6 +110,7 @@ pub(super) async fn verify_runtime( },), ) .await?; + verify_bound_transport(proxy, client, &host, request).await?; let _: () = proxy.call("LiveShutdown", ()).await?; if proxy.call::("LiveReserve", ()).await.is_ok() { return Err(io::Error::other("shutdown reopened admission").into()); @@ -256,3 +262,124 @@ async fn check_outputs(outputs: &[LiveOutput], host: &Host) -> CheckResult<()> { } Ok(()) } + +#[derive(Clone, Default)] +struct ClientTransport { + outputs: Arc>>, + committed: Arc>>, + closed: Arc, + attempts: Arc>, +} + +#[tinybus::interface(name = "ai.tinyhumans.tinyvoice.LiveTransport")] +impl ClientTransport { + async fn read_live_input( + &self, + request: ReadLiveInputRequest, + ) -> tinybus::Result { + // The module reads this replayable mechanical socket callback itself; + // the verification host never drives the provider or session pump. + tokio::task::yield_now().await; + Ok(ReadLiveInputReply { + sequence: if request.acknowledged_frame == 0 { + 1 + } else { + request.acknowledged_frame + }, + frame: (request.acknowledged_frame == 0).then(|| TransportFrame::Text { + text: r#"{"type":"text","text":"hello"}"#.into(), + }), + }) + } + async fn deliver_live_effects( + &self, + request: DeliverLiveEffectsRequest, + ) -> tinybus::Result { + let mut attempts = self.attempts.lock().await; + *attempts += 1; + if self.committed.lock().await.insert(request.delivery_id) { + self.outputs.lock().await.extend(request.outputs); + } + if *attempts == 1 { + return Err(tinybus::Error::failed( + "verification lost reply after committing effects", + )); + } + Ok(DeliverLiveEffectsReply { delivered: true }) + } + async fn close_live_transport( + &self, + _request: CloseLiveTransportRequest, + ) -> tinybus::Result { + self.closed.notify_one(); + Ok(CloseLiveTransportReply { closed: true }) + } +} + +async fn verify_bound_transport( + proxy: &tinybus::Proxy, + client: &tinybus::Connection, + host: &Host, + mut request: LiveStartRequest, +) -> CheckResult<()> { + host.calls.lock().await.clear(); + let transport = ClientTransport::default(); + client + .serve_at("/test/LiveTransport".try_into()?, transport.clone()) + .await?; + client.request_name("ai.test.LiveTransport").await?; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let address = listener.local_addr()?; + let provider = tokio::spawn(scripted_provider(listener)); + let reserved: LiveStatus = proxy.call("LiveReserve", ()).await?; + request.handle = reserved.handle.clone(); + request.connection = tinyvoice_bus::live::session::LiveConnection::GeminiRelay { + ws_url: format!("ws://{address}"), + session_id: "provider-session".into(), + model: "fixture-model".into(), + }; + let _: LiveStatus = proxy + .call( + "LiveBindTransport", + (LiveBindTransportRequest { + handle: reserved.handle.clone(), + port: LiveTransportPort { + bus_name: "ai.test.LiveTransport".into(), + object_path: "/test/LiveTransport".into(), + context_token: "known-client".into(), + }, + },), + ) + .await?; + let _: LiveStatus = proxy.call("LiveStart", (request,)).await?; + tokio::time::timeout( + std::time::Duration::from_secs(5), + transport.closed.notified(), + ) + .await?; + provider.await??; + let outputs = transport.outputs.lock().await; + check_outputs(&outputs, host).await?; + if outputs.iter().filter(|effect| matches!(effect, LiveOutput::Persist { role: TranscriptRole::User, text } if text == "hello")).count() != 1 { + return Err(io::Error::other("typed input persistence repeated or was lost").into()); + } + if !outputs.iter().any(|effect| { + matches!( + effect, + LiveOutput::Json { + frame: ServerFrame::Closed { .. } + } + ) + }) { + return Err(io::Error::other("module transport lost terminal output").into()); + } + let _: LiveStatus = proxy + .call( + "LiveRelease", + (LiveHandleRequest { + handle: reserved.handle, + },), + ) + .await?; + Ok(()) +} diff --git a/crates/tinyvoice-module/src/service/live/callback_tests.rs b/crates/tinyvoice-module/src/service/live/callback_tests.rs index c90d4c7..65dbc14 100644 --- a/crates/tinyvoice-module/src/service/live/callback_tests.rs +++ b/crates/tinyvoice-module/src/service/live/callback_tests.rs @@ -240,3 +240,27 @@ async fn dropping_native_callback_notifies_host_to_cancel_its_known_invocation() assert_eq!(seen[0].invocation_id.len(), 32); Ok(()) } + +#[test] +fn callback_cancellation_guard_can_drop_outside_its_runtime_context() -> anyhow::Result<()> { + let runtime = tokio::runtime::Runtime::new()?; + let (guard, _host) = runtime.block_on(async { + let (tool, _authority, host) = fixture().await?; + Ok::<_, tinybus::Error>(( + InvocationGuard { + proxy: tool.proxy.clone(), + request: Some(CancelLiveToolRequest { + context_token: "known-context".into(), + invocation_id: "known-invocation".into(), + call_id: "native-call".into(), + }), + }, + host, + )) + })?; + // Dropping a cancelled future is legal on another executor or after the + // creating executor has stopped. Destructors must never require its enter guard. + drop(runtime); + drop(guard); + Ok(()) +} From f977ecf941660cdea5da05a991ebe17c81174907 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:32:24 +0300 Subject: [PATCH 42/59] fix(live): capture runtime handle for invocation guard cancellation The invocation guard now stores the current tokio runtime handle and uses it to spawn the cancellation call on drop, instead of relying on the ambient runtime. This ensures the cancellation request is still dispatched when the guard is dropped outside of a runtime context. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyvoice-module/src/service/live/callback.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/tinyvoice-module/src/service/live/callback.rs b/crates/tinyvoice-module/src/service/live/callback.rs index 158bb79..988866d 100644 --- a/crates/tinyvoice-module/src/service/live/callback.rs +++ b/crates/tinyvoice-module/src/service/live/callback.rs @@ -91,6 +91,7 @@ impl ToolDispatch<(), ()> for CallbackTool { anyhow::bail!("live tool callback request exceeds its serialized limit"); } let mut cancellation = InvocationGuard { + runtime: tokio::runtime::Handle::current(), proxy: self.proxy.clone(), request: Some(CancelLiveToolRequest { context_token: request.context_token.clone(), @@ -119,6 +120,7 @@ mod tests; /// their invocation leases on endpoint/context teardown. Provider cancellation /// that permits a running native tool to finish does not drop this guard early. struct InvocationGuard { + runtime: tokio::runtime::Handle, proxy: tinybus::Proxy, request: Option, } @@ -127,7 +129,7 @@ impl Drop for InvocationGuard { fn drop(&mut self) { if let Some(request) = self.request.take() { let proxy = self.proxy.clone(); - tokio::spawn(async move { + self.runtime.spawn(async move { let _ = tokio::time::timeout( std::time::Duration::from_secs(1), proxy.call::(CANCEL_CALLBACK_METHOD, (request,)), From e2c8ed8d57e872aa7ce9e1a778c8ea1614caa79e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:32:48 +0300 Subject: [PATCH 43/59] chore(ci): check out submodules recursively CI and release workflows now fetch submodules recursively so nested submodules are present during builds. Sessions::with_connection takes the connection by reference instead of cloning it, and the callback test fixture records the current runtime handle. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .github/workflows/ci.yml | 10 +++++----- .github/workflows/release.yml | 6 +++--- .../src/service/live/callback_tests.rs | 1 + crates/tinyvoice-module/src/service/live/table.rs | 2 +- .../src/service/live/transport_tests.rs | 2 +- crates/tinyvoice-module/src/service/mod.rs | 2 +- 6 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec2d295..7f6b43e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: # This job executes repository code (cargo build/test); don't persist # the token in git config. persist-credentials: false - submodules: true + submodules: recursive - name: Install X11 development libraries run: sudo apt-get update && sudo apt-get install --yes libx11-dev libxtst-dev @@ -90,7 +90,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - submodules: true + submodules: recursive - uses: dtolnay/rust-toolchain@stable @@ -112,7 +112,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - submodules: true + submodules: recursive - name: Install X11 development libraries run: sudo apt-get update && sudo apt-get install --yes libx11-dev libxtst-dev @@ -136,7 +136,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - submodules: true + submodules: recursive - name: Install X11 development libraries run: sudo apt-get update && sudo apt-get install --yes libx11-dev libxtst-dev @@ -169,7 +169,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - submodules: true + submodules: recursive - name: Check advisories, licenses, bans, and sources uses: EmbarkStudios/cargo-deny-action@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 703e8fe..0f7c1c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 - submodules: true + submodules: recursive - name: Install ALSA development files run: sudo apt-get update && sudo apt-get install --yes libasound2-dev @@ -280,7 +280,7 @@ jobs: with: ref: ${{ needs.publish.outputs.tag }} persist-credentials: false - submodules: true + submodules: recursive - uses: dtolnay/rust-toolchain@stable @@ -397,7 +397,7 @@ jobs: with: ref: ${{ needs.publish.outputs.tag }} persist-credentials: false - submodules: true + submodules: recursive - name: Download workflow artifacts uses: actions/download-artifact@v8 diff --git a/crates/tinyvoice-module/src/service/live/callback_tests.rs b/crates/tinyvoice-module/src/service/live/callback_tests.rs index 65dbc14..2a812e1 100644 --- a/crates/tinyvoice-module/src/service/live/callback_tests.rs +++ b/crates/tinyvoice-module/src/service/live/callback_tests.rs @@ -248,6 +248,7 @@ fn callback_cancellation_guard_can_drop_outside_its_runtime_context() -> anyhow: let (tool, _authority, host) = fixture().await?; Ok::<_, tinybus::Error>(( InvocationGuard { + runtime: tokio::runtime::Handle::current(), proxy: tool.proxy.clone(), request: Some(CancelLiveToolRequest { context_token: "known-context".into(), diff --git a/crates/tinyvoice-module/src/service/live/table.rs b/crates/tinyvoice-module/src/service/live/table.rs index 14cc950..9ad96f3 100644 --- a/crates/tinyvoice-module/src/service/live/table.rs +++ b/crates/tinyvoice-module/src/service/live/table.rs @@ -129,7 +129,7 @@ impl Default for Sessions { } impl Sessions { - pub(in crate::service) fn with_connection(connection: tinybus::Connection) -> Self { + pub(in crate::service) fn with_connection(connection: &tinybus::Connection) -> Self { Self { connection: Mutex::new(Some(connection.downgrade())), closed: AtomicBool::new(false), diff --git a/crates/tinyvoice-module/src/service/live/transport_tests.rs b/crates/tinyvoice-module/src/service/live/transport_tests.rs index 001b133..5a872d9 100644 --- a/crates/tinyvoice-module/src/service/live/transport_tests.rs +++ b/crates/tinyvoice-module/src/service/live/transport_tests.rs @@ -84,7 +84,7 @@ async fn fixture( host.serve_at("/test/Transport".try_into()?, socket).await?; host.request_name("ai.test.Transport").await?; let module = tinybus::Connection::connect(bus.connect().await?).await?; - let sessions = Sessions::with_connection(module.clone()); + let sessions = Sessions::with_connection(&module); let handle = sessions.reserve()?.handle; let lease = sessions.lease(&handle)?; sessions.bind_transport(LiveBindTransportRequest { diff --git a/crates/tinyvoice-module/src/service/mod.rs b/crates/tinyvoice-module/src/service/mod.rs index e0ce94c..6649b1e 100644 --- a/crates/tinyvoice-module/src/service/mod.rs +++ b/crates/tinyvoice-module/src/service/mod.rs @@ -721,7 +721,7 @@ async fn setup(connection: Connection) -> TinyBusResult<()> { .serve_at( names::OBJECT_PATH.try_into()?, VoiceService { - live: std::sync::Arc::new(live::Sessions::with_connection(connection.clone())), + live: std::sync::Arc::new(live::Sessions::with_connection(&connection)), ..VoiceService::default() }, ) From 63736ed8c304cbadc9556ac85f6d6b4aceacb1fa Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:34:11 +0300 Subject: [PATCH 44/59] docs(live-module-runtime): document mechanical socket callbacks The spec now describes binding a LiveTransport endpoint to a reserved handle, the acknowledgment-based read protocol, ordered effect delivery with stable IDs, and the shutdown and weak-connection rules. The verify example and transport tests gained counters to assert that a bound socket is polled and closed exactly once. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../examples/verify_module/live.rs | 5 +++++ .../src/service/live/transport_tests.rs | 2 ++ docs/specs/live-module-runtime.md | 20 ++++++++++++++----- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/tinyvoice-module/examples/verify_module/live.rs b/crates/tinyvoice-module/examples/verify_module/live.rs index c4f32d7..cebbb84 100644 --- a/crates/tinyvoice-module/examples/verify_module/live.rs +++ b/crates/tinyvoice-module/examples/verify_module/live.rs @@ -269,6 +269,7 @@ struct ClientTransport { committed: Arc>>, closed: Arc, attempts: Arc>, + closes: Arc>, } #[tinybus::interface(name = "ai.tinyhumans.tinyvoice.LiveTransport")] @@ -311,6 +312,7 @@ impl ClientTransport { &self, _request: CloseLiveTransportRequest, ) -> tinybus::Result { + *self.closes.lock().await += 1; self.closed.notify_one(); Ok(CloseLiveTransportReply { closed: true }) } @@ -358,6 +360,9 @@ async fn verify_bound_transport( ) .await?; provider.await??; + if *transport.closes.lock().await != 1 { + return Err(io::Error::other("socket was not closed once").into()); + } let outputs = transport.outputs.lock().await; check_outputs(&outputs, host).await?; if outputs.iter().filter(|effect| matches!(effect, LiveOutput::Persist { role: TranscriptRole::User, text } if text == "hello")).count() != 1 { diff --git a/crates/tinyvoice-module/src/service/live/transport_tests.rs b/crates/tinyvoice-module/src/service/live/transport_tests.rs index 5a872d9..f91781b 100644 --- a/crates/tinyvoice-module/src/service/live/transport_tests.rs +++ b/crates/tinyvoice-module/src/service/live/transport_tests.rs @@ -15,6 +15,7 @@ struct Socket { committed: Arc>>, effects: Arc>>, attempts: Arc, + polls: Arc>, closes: Arc, closed: Arc, first_reply_lost: bool, @@ -28,6 +29,7 @@ impl Socket { &self, request: ReadLiveInputRequest, ) -> tinybus::Result { + *self.polls.lock().await += 1; Ok(ReadLiveInputReply { sequence: request.acknowledged_frame + if self.invalid_sequence { 2 } else { 1 }, frame: Some(TransportFrame::Closed), diff --git a/docs/specs/live-module-runtime.md b/docs/specs/live-module-runtime.md index c87a5f0..7dc2117 100644 --- a/docs/specs/live-module-runtime.md +++ b/docs/specs/live-module-runtime.md @@ -29,9 +29,7 @@ tasks. Audio and serialized control requests have bounded sizes. Event reads return ordered bounded batches with replay until acknowledgment, so a lost reply does not erase transcript or terminal events. Stop asks the provider to close and retains its terminal events; explicit release drops the session and -its tasks. Shutdown closes admission, cancels every native task and joins cleanups concurrently under one two-second deadline. Dropping a release/shutdown future aborts the worker rather than detaching its task. Module drop also aborts owned workers. A disconnected client must -explicitly release its handle; capacity limits prevent leaked clients from -growing the table indefinitely. +its tasks. Shutdown closes admission, cancels every native task and joins cleanups concurrently under one two-second deadline. Dropping a release/shutdown future aborts the worker rather than detaching its task. Module drop also aborts owned workers. A bound transport coordinator closes its provider on client EOF, input/write failure or cancellation. A host using the manual input/effect API must explicitly release its handle on disconnect. Capacity limits and reservation expiry prevent forgotten reservations from growing the table indefinitely. The realtime relay's local brain, progress selection, correlation admission, spoken acknowledgment deadline and delivery policy remain host choices. @@ -49,8 +47,7 @@ waits for accepted source and an available released module. ## Admission and transport bounds -Contract 1.7 adds PrepareLiveInput, LiveReserve, LiveStart, LiveSend, LiveRead, -LiveRelease and LiveShutdown. Reserve precedes provider connection; identical +Contract 1.7 adds PrepareLiveInput, LiveReserve, LiveBindTransport, LiveStart, LiveSend, LiveRead, LiveRelease and LiveShutdown. Reserve precedes provider connection; identical start retries preserve the same lease and a changed start request fails. Sixteen leases are admitted. Unstarted reservations expire after sixty seconds; active sessions are never evicted to admit another client. Startup requests @@ -87,3 +84,16 @@ leases on endpoint/context teardown as well. Remote notification is bounded best effort; native provider termination does not depend on receiving it. The runtime requires the non-owning connection capability proposed in TinyBus [PR #50](https://github.com/tinyhumansai/tinybus/pull/50). Served module objects store `WeakConnection`; active work upgrades it temporarily. OpenHuman adoption must wait for the prerequisite and a released, admitted TinyVoice artifact. + + +## Mechanical socket callbacks + +Bind a LiveTransport endpoint to a reserved handle before LiveStart. The module then owns client polling, native provider input, event batching, delivery retries and terminal cleanup. A bound lease rejects manual LiveSend/LiveRead to prevent competing readers and duplicate persistence. Identical bindings retry safely; changed ports or binding after start fail. + +ReadLiveInput receives the last acknowledged frame sequence. The host retains its next frame until that exact sequence is acknowledged; idle polls return no frame and retain the acknowledgment. The module accepts only contiguous sequences, parses portable controls and ignores malformed later controls as the existing wire protocol does. Socket callbacks return idle promptly; a read exceeding one second closes the module coordinator. Audio and typed text are bounded before native dispatch. + +DeliverLiveEffects carries an ordered group and a stable delivery ID (`input-N` or `output-N`) scoped to the random module handle. The host applies effects mechanically and remembers committed identities so a lost reply can retry without repeating persistence. The module retries twice under a one-second deadline per attempt. Typed input persistence precedes native text dispatch; final provider transcript frames precede their persistence projection. A failed delivery stops the coordinator. + +Client EOF, callback error, native closure and cancellation stop provider work and invoke CloseLiveTransport under a one-second deadline. This method closes only a saved known transport context; repeated known close succeeds and an unknown context returns false. Module release does not invent a successful cleanup acknowledgment. The host owns context leases and must also cancel independent callback tasks when their endpoint drops; TinyBus call timeout alone does not cancel work already running remotely. + +The module retains only a weak reference to its serving connection. Active actors borrow owning handles temporarily; dropping the module aborts its provider and transport workers. Cancellation destructors retain their original runtime handle so dropping a callback future outside a runtime enter context cannot panic. From e8402b17419702d5cebcb817934d2767cbe04f23 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:34:44 +0300 Subject: [PATCH 45/59] test(live): use async mutex in transport fixtures The transport test fixture now guards its committed and effects state with tokio's async mutex, so the delivery path no longer blocks a runtime thread while holding a lock. Assertions and the capabilities accessor were simplified to match. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/service/live/actor_tests.rs | 2 +- .../src/service/live/transport_tests.rs | 38 ++++--------------- 2 files changed, 8 insertions(+), 32 deletions(-) diff --git a/crates/tinyvoice-module/src/service/live/actor_tests.rs b/crates/tinyvoice-module/src/service/live/actor_tests.rs index 659d1e8..b332cbd 100644 --- a/crates/tinyvoice-module/src/service/live/actor_tests.rs +++ b/crates/tinyvoice-module/src/service/live/actor_tests.rs @@ -117,7 +117,7 @@ impl tinyagents_live::tinyliveagents::LiveProvider for FailingProvider { "fixture" } fn capabilities(&self) -> tinyagents_live::tinyliveagents::Capabilities { - self.0.clone() + self.0 } async fn connect( &self, diff --git a/crates/tinyvoice-module/src/service/live/transport_tests.rs b/crates/tinyvoice-module/src/service/live/transport_tests.rs index f91781b..779f6d9 100644 --- a/crates/tinyvoice-module/src/service/live/transport_tests.rs +++ b/crates/tinyvoice-module/src/service/live/transport_tests.rs @@ -3,17 +3,14 @@ use super::super::table::Sessions; use super::*; use std::collections::HashSet; -use std::sync::{ - Mutex, - atomic::{AtomicUsize, Ordering}, -}; +use std::sync::atomic::{AtomicUsize, Ordering}; use tinybus::{broker::Broker, transport::memory::MemoryBus}; use tinyvoice_bus::live::{ServerFrame, TransportFrame, transport::LiveBindTransportRequest}; #[derive(Clone, Default)] struct Socket { - committed: Arc>>, - effects: Arc>>, + committed: Arc>>, + effects: Arc>>, attempts: Arc, polls: Arc>, closes: Arc, @@ -40,16 +37,8 @@ impl Socket { request: DeliverLiveEffectsRequest, ) -> tinybus::Result { let attempt = self.attempts.fetch_add(1, Ordering::SeqCst); - if self - .committed - .lock() - .map_err(|_| tinybus::Error::failed("fixture lock"))? - .insert(request.delivery_id) - { - self.effects - .lock() - .map_err(|_| tinybus::Error::failed("fixture lock"))? - .extend(request.outputs); + if self.committed.lock().await.insert(request.delivery_id) { + self.effects.lock().await.extend(request.outputs); } if self.first_reply_lost && attempt == 0 { return Err(tinybus::Error::failed( @@ -135,21 +124,8 @@ async fn lost_delivery_reply_reuses_identity_and_preserves_order_without_duplica .await .map_err(|_| tinybus::Error::failed("fixture deadline"))?; assert_eq!(socket.attempts.load(Ordering::SeqCst), 2); - assert_eq!( - *socket - .effects - .lock() - .map_err(|_| tinybus::Error::failed("fixture lock"))?, - effects - ); - assert_eq!( - socket - .committed - .lock() - .map_err(|_| tinybus::Error::failed("fixture lock"))? - .len(), - 1 - ); + assert_eq!(*socket.effects.lock().await, effects); + assert_eq!(socket.committed.lock().await.len(), 1); sessions.release(handle).await?; Ok(()) } From c4b90978f7a96babcbac0efe6436d08df4541e76 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:35:48 +0300 Subject: [PATCH 46/59] docs: document relay ownership and library surface Co-authored-by: Medulla --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index aa06e85..561d011 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # TinyVoice -Host-agnostic voice primitives, extracted from [OpenHuman](https://github.com/tinyhumansai/openhuman): WAV framing, silence gating, voice-activity segmentation, wake-word gating, fast-path command routing, and STT hallucination detection. +Host-agnostic voice primitives, extracted from [OpenHuman](https://github.com/tinyhumansai/openhuman): WAV framing, silence gating, voice-activity segmentation, wake-word gating, fast-path command routing, STT hallucination detection, and relay-turn preparation. Ships two things from one repository: @@ -28,6 +28,7 @@ permission check. | VAD segmentation | The capture loop that drives it | | Wake-word gate, intent routing | What to *do* with an intent | | Hallucination detection | The STT transport, credentials, and retry policy | +| Relay user-text extraction, readback classification and turn preparation | Local agent execution, product prompts, acknowledgment timing and delivery | A visible consequence: `VadConfig` has no constructor that reads a config file. A host builds one from whatever it persists. A crate that guessed at that shape @@ -57,6 +58,13 @@ assert_eq!(route(&command), VoiceIntent::Pause); Run it: `cargo run -p tinyvoice --example basic`. +`tinyvoice::relay::prepare_turn` extracts user text from serialized messages, +classifies a host-supplied readback prefix, and prepares a provider-neutral +turn. It performs no inference or tool execution. A module host uses the +`PrepareRelayTurn` operation with the minimal `tinyvoice-bus` value types; +OpenHuman supplies its local agent, acknowledgment policy and delivery. +See the [relay contract](docs/specs/relay-turn-preparation.md). + ### Optional features - `hotkey` (off by default): `tinyvoice::hotkey`, a system-wide push-to-talk / From d0675ff1a1e8565b4c77c6cb02ac1dd40827e6db Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:36:57 +0300 Subject: [PATCH 47/59] chore(vendor): bump tinybus submodule Update the vendored tinybus submodule to a newer revision. Auto-committed-on: dragonfly Co-authored-by: Medulla --- vendor/tinybus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinybus b/vendor/tinybus index 10579d4..356bb24 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit 10579d486f276c78c9c4a2b5eaa8bfee0e302292 +Subproject commit 356bb24f30d7863d81569c7e1f07a7ae60feda48 From d91ae822f1e8171af39aee922fbaa43530524f48 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:37:32 +0300 Subject: [PATCH 48/59] chore(vendor): bump tinybus submodule Update the vendored tinybus submodule to a newer revision. Auto-committed-on: dragonfly Co-authored-by: Medulla --- vendor/tinybus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinybus b/vendor/tinybus index 356bb24..10579d4 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit 356bb24f30d7863d81569c7e1f07a7ae60feda48 +Subproject commit 10579d486f276c78c9c4a2b5eaa8bfee0e302292 From 5834bb5d71c1ac802efffde38507b42f9f2efee2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:39:00 +0300 Subject: [PATCH 49/59] test(live): cover native input validation and output batch splitting Add tests for the live session table covering native input handling and output batching. The input test verifies that malformed base64, odd-length audio, and oversized audio or text payloads are rejected while valid audio, interrupt, and stop commands are forwarded to the native session. The output test verifies that batches split between atomic groups and that a single group exceeding the batch limit is rejected. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/service/live/table_tests.rs | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/crates/tinyvoice-module/src/service/live/table_tests.rs b/crates/tinyvoice-module/src/service/live/table_tests.rs index 9bc07d1..b4f77ed 100644 --- a/crates/tinyvoice-module/src/service/live/table_tests.rs +++ b/crates/tinyvoice-module/src/service/live/table_tests.rs @@ -199,3 +199,114 @@ async fn cancelling_session_wakes_provider_and_transport_coordinators() -> tinyb .map_err(|_| unavailable())?; Ok(()) } + +#[tokio::test] +async fn native_input_preserves_audio_interrupt_and_close_and_rejects_malformed_bounds() +-> tinybus::Result<()> { + use base64::Engine as _; + use tinyagents_live::tinyliveagents::{ClientCommand, LiveSession}; + let sessions = Sessions::default(); + assert!(format!("{sessions:?}").contains("closed")); + let handle = sessions.reserve()?.handle; + let lease = sessions.lease(&handle)?; + let (commands, mut rx) = mpsc::channel(8); + let (_events, events) = mpsc::channel(8); + let native = LiveSession::from_channels(commands, events); + { + let mut phase = lease.phase.lock().map_err(|_| unavailable())?; + phase.sender = Some(native.sender()); + phase.state = LiveState::Running; + } + let encode = |bytes| base64::engine::general_purpose::STANDARD.encode(bytes); + for command in [ + LiveCommand::Audio { + pcm16_base64: "!!".into(), + }, + LiveCommand::Audio { + pcm16_base64: encode([1]), + }, + LiveCommand::Audio { + pcm16_base64: "x".repeat(MAX_AUDIO_BYTES * 2), + }, + LiveCommand::Text { + text: "x".repeat(MAX_TEXT_BYTES + 1), + }, + ] { + assert!( + send_lease( + &lease, + LiveSendRequest { + handle: handle.clone(), + command + } + ) + .await + .is_err() + ); + } + for command in [ + LiveCommand::Audio { + pcm16_base64: encode([1, 2]), + }, + LiveCommand::Interrupt, + LiveCommand::Stop, + ] { + send_lease( + &lease, + LiveSendRequest { + handle: handle.clone(), + command, + }, + ) + .await?; + } + assert!( + matches!(rx.recv().await, Some(ClientCommand::Audio(bytes)) if bytes.as_ref() == [1,2]) + ); + assert!(matches!(rx.recv().await, Some(ClientCommand::Interrupt))); + assert!(matches!(rx.recv().await, Some(ClientCommand::Close))); + sessions.shutdown().await +} + +#[tokio::test] +async fn output_batches_split_between_atomic_groups_and_reject_a_single_oversized_group() +-> tinybus::Result<()> { + let sessions = Sessions::default(); + let handle = sessions.reserve()?.handle; + let lease = sessions.lease(&handle)?; + let group = vec![LiveOutput::Persist { + role: tinyvoice_bus::live::TranscriptRole::User, + text: "x".repeat(600_000), + }]; + lease + .outputs + .send(group.clone()) + .await + .map_err(|_| unavailable())?; + lease + .outputs + .send(group.clone()) + .await + .map_err(|_| unavailable())?; + let first = sessions.read(read_request(&handle, None)).await?; + assert_eq!(first.outputs, group); + let second = sessions + .read(read_request(&handle, Some(first.batch))) + .await?; + assert_eq!(second.outputs, group); + lease + .outputs + .send(vec![LiveOutput::Persist { + role: tinyvoice_bus::live::TranscriptRole::User, + text: "\0".repeat(MAX_BATCH_BYTES), + }]) + .await + .map_err(|_| unavailable())?; + assert!( + sessions + .read(read_request(&handle, Some(second.batch))) + .await + .is_err() + ); + sessions.shutdown().await +} From 612c919b3723b9b787b4b2ffc8131b561d6e71de Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:39:58 +0300 Subject: [PATCH 50/59] test(live): cover audio chunking and pre-connect cancellation Add actor tests that verify oversized native audio is split into bounded chunks before the session closes, and that cancelling before connect still finishes the known host context. Also annotate the base64 helper closure in the table tests so it accepts borrowed slices. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/service/live/actor_tests.rs | 92 +++++++++++++++++++ .../src/service/live/table_tests.rs | 6 +- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/crates/tinyvoice-module/src/service/live/actor_tests.rs b/crates/tinyvoice-module/src/service/live/actor_tests.rs index b332cbd..cbb59dd 100644 --- a/crates/tinyvoice-module/src/service/live/actor_tests.rs +++ b/crates/tinyvoice-module/src/service/live/actor_tests.rs @@ -152,3 +152,95 @@ async fn oversized_provider_startup_failure_becomes_a_bounded_terminal_reply() - assert_eq!(cleanup.finishes.load(Ordering::SeqCst), 1); Ok(()) } + +#[derive(Debug)] +struct AudioProvider(tinyagents_live::tinyliveagents::Capabilities); + +#[async_trait::async_trait] +impl tinyagents_live::tinyliveagents::LiveProvider for AudioProvider { + fn id(&self) -> &'static str { + "fixture-audio" + } + fn capabilities(&self) -> tinyagents_live::tinyliveagents::Capabilities { + self.0 + } + async fn connect( + &self, + _config: tinyagents_live::tinyliveagents::LiveConfig, + ) -> tinyagents_live::tinyliveagents::Result { + let (commands, _commands) = tokio::sync::mpsc::channel(8); + let (events, receive) = tokio::sync::mpsc::channel(8); + events + .send(LiveEvent::Audio(vec![0_u8; MAX_AUDIO_BYTES * 2 + 2].into())) + .await + .map_err(|_| Error::Closed)?; + events + .send(LiveEvent::OutputTranscript { + text: "x".repeat(MAX_TEXT_BYTES + 1), + is_final: true, + }) + .await + .map_err(|_| Error::Closed)?; + Ok(tinyagents_live::tinyliveagents::LiveSession::from_channels( + commands, receive, + )) + } +} + +#[tokio::test] +async fn native_audio_is_chunked_before_an_oversized_stream_event_closes_the_session() +-> anyhow::Result<()> { + let (module, _host, port, _cleanup) = cleanup_fixture(false).await?; + let mut request: LiveStartRequest = serde_json::from_str(include_str!("fixtures/start.json"))?; + request.tool_port = port; + let mut prepared = super::super::prepare::prepare(&request, &module)?; + prepared.provider = Box::new(AudioProvider(prepared.provider.capabilities())); + let lease = Arc::new(Lease::new()); + run(lease.clone(), request.clone(), prepared, module).await; + let batch = super::super::table::read_lease( + &lease, + tinyvoice_bus::live::session::LiveReadRequest { + handle: request.handle, + acknowledged_batch: None, + }, + ) + .await?; + let sizes = batch + .outputs + .iter() + .filter_map(|output| match output { + LiveOutput::Audio { pcm16_base64 } => { + Some(BASE64.decode(pcm16_base64).map(|audio| audio.len())) + } + _ => None, + }) + .collect::, _>>()?; + assert_eq!(sizes, [MAX_AUDIO_BYTES, MAX_AUDIO_BYTES, 2]); + assert!(matches!( + batch.outputs.last(), + Some(LiveOutput::Json { + frame: ServerFrame::Closed { .. } + }) + )); + Ok(()) +} + +#[tokio::test] +async fn cancellation_before_connect_still_finishes_the_known_host_context() -> anyhow::Result<()> { + let (module, _host, port, cleanup) = cleanup_fixture(false).await?; + let mut request: LiveStartRequest = serde_json::from_str(include_str!("fixtures/start.json"))?; + request.tool_port = port; + let prepared = super::super::prepare::prepare(&request, &module)?; + let lease = Arc::new(Lease::new()); + lease.cancel(); + run(lease.clone(), request, prepared, module).await; + assert_eq!(cleanup.finishes.load(Ordering::SeqCst), 1); + assert!( + lease + .phase + .lock() + .map_err(|_| anyhow::anyhow!("fixture lock"))? + .cleanup_confirmed + ); + Ok(()) +} diff --git a/crates/tinyvoice-module/src/service/live/table_tests.rs b/crates/tinyvoice-module/src/service/live/table_tests.rs index b4f77ed..9012bc1 100644 --- a/crates/tinyvoice-module/src/service/live/table_tests.rs +++ b/crates/tinyvoice-module/src/service/live/table_tests.rs @@ -217,13 +217,13 @@ async fn native_input_preserves_audio_interrupt_and_close_and_rejects_malformed_ phase.sender = Some(native.sender()); phase.state = LiveState::Running; } - let encode = |bytes| base64::engine::general_purpose::STANDARD.encode(bytes); + let encode = |bytes: &[u8]| base64::engine::general_purpose::STANDARD.encode(bytes); for command in [ LiveCommand::Audio { pcm16_base64: "!!".into(), }, LiveCommand::Audio { - pcm16_base64: encode([1]), + pcm16_base64: encode(&[1]), }, LiveCommand::Audio { pcm16_base64: "x".repeat(MAX_AUDIO_BYTES * 2), @@ -246,7 +246,7 @@ async fn native_input_preserves_audio_interrupt_and_close_and_rejects_malformed_ } for command in [ LiveCommand::Audio { - pcm16_base64: encode([1, 2]), + pcm16_base64: encode(&[1, 2]), }, LiveCommand::Interrupt, LiveCommand::Stop, From 6361fb95f827fff834720c26c6b657a952737a86 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:41:16 +0300 Subject: [PATCH 51/59] test(live): cover transport input handling and delivery retries Add tests for the live transport covering control and audio input ordering, rejection of oversized text and audio, bounded retries when delivery is rejected, and expiry of forgotten bound reservations. The socket fixture gains a reject_delivery flag so the retry path can be exercised. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/service/live/transport_tests.rs | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/crates/tinyvoice-module/src/service/live/transport_tests.rs b/crates/tinyvoice-module/src/service/live/transport_tests.rs index 779f6d9..7c3b5ef 100644 --- a/crates/tinyvoice-module/src/service/live/transport_tests.rs +++ b/crates/tinyvoice-module/src/service/live/transport_tests.rs @@ -18,6 +18,7 @@ struct Socket { first_reply_lost: bool, unresponsive_close: bool, invalid_sequence: bool, + reject_delivery: bool, } #[tinybus::interface(name = "ai.tinyhumans.tinyvoice.LiveTransport")] @@ -37,6 +38,9 @@ impl Socket { request: DeliverLiveEffectsRequest, ) -> tinybus::Result { let attempt = self.attempts.fetch_add(1, Ordering::SeqCst); + if self.reject_delivery { + return Ok(DeliverLiveEffectsReply { delivered: false }); + } if self.committed.lock().await.insert(request.delivery_id) { self.effects.lock().await.extend(request.outputs); } @@ -200,3 +204,139 @@ async fn unresponsive_socket_cleanup_is_bounded_and_never_acknowledged_as_succes sessions.release(handle).await?; Ok(()) } + +fn port() -> LiveTransportPort { + LiveTransportPort { + bus_name: "ai.test.Transport".into(), + object_path: "/test/Transport".into(), + context_token: "known".into(), + } +} + +#[tokio::test] +async fn portable_transport_controls_preserve_native_input_and_ignore_late_noncontrols() +-> tinybus::Result<()> { + use tinyagents_live::tinyliveagents::{ClientCommand, LiveSession}; + let socket = Socket::default(); + let (sessions, module, _host, handle, lease) = fixture(socket).await?; + let proxy = module.proxy("ai.test.Transport", "/test/Transport", INTERFACE)?; + let (commands, mut receive) = tokio::sync::mpsc::channel(8); + let (_events, events) = tokio::sync::mpsc::channel(8); + let native = LiveSession::from_channels(commands, events); + lease + .phase + .lock() + .map_err(|_| tinybus::Error::failed("fixture lock"))? + .sender = Some(native.sender()); + for input in [ + PreparedLiveInput::Control(ClientFrame::Interrupt), + PreparedLiveInput::Control(ClientFrame::Stop), + PreparedLiveInput::Transport(LiveTransportAction::Audio { pcm16: vec![1, 2] }), + ] { + assert!(apply_input(&lease, &handle, &port(), &proxy, 1, input).await?); + } + assert!(matches!( + receive.recv().await, + Some(ClientCommand::Interrupt) + )); + assert!(matches!(receive.recv().await, Some(ClientCommand::Close))); + assert!( + matches!(receive.recv().await,Some(ClientCommand::Audio(bytes)) if bytes.as_ref() == [1,2]) + ); + assert!( + apply_input( + &lease, + &handle, + &port(), + &proxy, + 1, + PreparedLiveInput::Transport(LiveTransportAction::Ignore) + ) + .await? + ); + assert!( + apply_input( + &lease, + &handle, + &port(), + &proxy, + 1, + PreparedLiveInput::Control(ClientFrame::Start { + provider: None, + thread_id: None, + client_id: None, + input_sample_rate: 16000 + }) + ) + .await? + ); + for input in [ + PreparedLiveInput::Control(ClientFrame::Text { + text: "x".repeat(MAX_TEXT_BYTES + 1), + }), + PreparedLiveInput::Transport(LiveTransportAction::Audio { + pcm16: vec![0; MAX_AUDIO_BYTES + 1], + }), + ] { + assert!( + apply_input(&lease, &handle, &port(), &proxy, 1, input) + .await + .is_err() + ); + } + sessions.release(handle).await?; + Ok(()) +} + +#[tokio::test] +async fn rejected_delivery_retries_boundedly_without_persisting_or_dispatching_input() +-> tinybus::Result<()> { + let socket = Socket { + reject_delivery: true, + ..Socket::default() + }; + let (sessions, module, _host, handle, lease) = fixture(socket.clone()).await?; + let proxy = module.proxy("ai.test.Transport", "/test/Transport", INTERFACE)?; + assert!( + apply_input( + &lease, + &handle, + &port(), + &proxy, + 1, + PreparedLiveInput::Control(ClientFrame::Text { + text: "uncommitted".into() + }) + ) + .await + .is_err() + ); + assert_eq!(socket.attempts.load(Ordering::SeqCst), 2); + assert!(socket.effects.lock().await.is_empty()); + let outputs = vec![LiveOutput::Persist { + role: TranscriptRole::User, + text: "\0".repeat(2 * MAX_BATCH_BYTES), + }]; + assert!( + deliver(&proxy, &port(), &handle, "oversized".into(), outputs) + .await + .is_err() + ); + assert_eq!(socket.attempts.load(Ordering::SeqCst), 2); + sessions.release(handle).await?; + Ok(()) +} + +#[tokio::test(start_paused = true)] +async fn forgotten_bound_reservation_expires_and_closes_its_mechanical_socket() +-> tinybus::Result<()> { + let socket = Socket::default(); + let (sessions, _module, _host, handle, lease) = fixture(socket.clone()).await?; + tokio::time::advance(Duration::from_secs(60)).await; + tokio::time::timeout(Duration::from_secs(1), socket.closed.notified()) + .await + .map_err(|_| tinybus::Error::failed("fixture deadline"))?; + assert!(lease.cancelled.load(Ordering::SeqCst)); + sessions.release(handle).await?; + Ok(()) +} From 69fe8eef75ca4e2617820feb5ce3899f2eac0065 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:43:04 +0300 Subject: [PATCH 52/59] test(live): cover admission and cleanup rejection paths Add tests asserting that oversized provider errors are detected while timeouts are not, and that a malformed finish endpoint cannot acknowledge host cleanup. Also cover live admission rejecting changed, expired, and oversized inputs before any connection is opened. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/service/live/actor_tests.rs | 16 +++++++ .../src/service/live/table_tests.rs | 44 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/crates/tinyvoice-module/src/service/live/actor_tests.rs b/crates/tinyvoice-module/src/service/live/actor_tests.rs index cbb59dd..ae29bd2 100644 --- a/crates/tinyvoice-module/src/service/live/actor_tests.rs +++ b/crates/tinyvoice-module/src/service/live/actor_tests.rs @@ -4,6 +4,13 @@ use super::*; #[test] fn oversized_provider_errors_are_rejected_before_event_projection() { + for error in [ + Error::InvalidConfig("x".repeat(MAX_TEXT_BYTES + 1)), + Error::Connect("x".repeat(MAX_TEXT_BYTES + 1)), + ] { + assert!(oversized_error(&error)); + } + assert!(!oversized_error(&Error::Timeout)); let error = Error::Provider("x".repeat(MAX_TEXT_BYTES + 1)); assert!(oversized(&LiveAgentEvent::Live(LiveEvent::Error { error: error.clone(), @@ -244,3 +251,12 @@ async fn cancellation_before_connect_still_finishes_the_known_host_context() -> ); Ok(()) } + +#[tokio::test] +async fn malformed_finish_endpoint_cannot_acknowledge_host_cleanup() -> tinybus::Result<()> { + let (module, _host, mut port, cleanup) = cleanup_fixture(false).await?; + port.object_path = "invalid path".into(); + assert!(!finish_context(&module, &port).await); + assert_eq!(cleanup.finishes.load(Ordering::SeqCst), 0); + Ok(()) +} diff --git a/crates/tinyvoice-module/src/service/live/table_tests.rs b/crates/tinyvoice-module/src/service/live/table_tests.rs index 9012bc1..e0f15ce 100644 --- a/crates/tinyvoice-module/src/service/live/table_tests.rs +++ b/crates/tinyvoice-module/src/service/live/table_tests.rs @@ -310,3 +310,47 @@ async fn output_batches_split_between_atomic_groups_and_reject_a_single_oversize ); sessions.shutdown().await } + +#[tokio::test(start_paused = true)] +async fn live_admission_rejects_changed_expired_and_oversized_inputs_before_connecting() +-> anyhow::Result<()> { + use tinyvoice_bus::live::transport::{LiveBindTransportRequest, LiveTransportPort}; + let bus = tinybus::transport::memory::MemoryBus::new(); + tinybus::broker::Broker::new().spawn(bus.clone()); + let connection = tinybus::Connection::connect(bus.connect().await?).await?; + let sessions = Sessions::with_connection(&connection); + let mut request: LiveStartRequest = serde_json::from_str(include_str!("fixtures/start.json"))?; + request.handle = sessions.reserve()?.handle; + let mut oversized = request.clone(); + oversized.configuration.system_instruction = Some("x".repeat(MAX_BATCH_BYTES)); + assert!(sessions.start(oversized).is_err()); + assert_eq!(sessions.start(request.clone())?.state, LiveState::Starting); + assert_eq!(sessions.start(request.clone())?.state, LiveState::Starting); + let mut changed = request.clone(); + changed.thread_id = "changed".into(); + assert!(sessions.start(changed).is_err()); + let bind = LiveBindTransportRequest { + handle: request.handle.clone(), + port: LiveTransportPort { + bus_name: "ai.test.Transport".into(), + object_path: "/test/Transport".into(), + context_token: "known".into(), + }, + }; + assert!(sessions.bind_transport(bind.clone()).is_err()); + sessions.lease(&request.handle)?.cancel(); + // Cancel before yielding; the scripted connection URL is never opened. + assert!(sessions.release(request.handle.clone()).await.is_err()); + request.handle = sessions.reserve()?.handle; + let mut bind = bind; + bind.handle = request.handle.clone(); + bind.port.context_token = "x".repeat(8193); + assert!(sessions.bind_transport(bind.clone()).is_err()); + bind.port.context_token = "known".into(); + bind.port.object_path = "invalid path".into(); + assert!(sessions.bind_transport(bind).is_err()); + tokio::time::advance(std::time::Duration::from_secs(60)).await; + assert!(sessions.start(request.clone()).is_err()); + sessions.release(request.handle).await?; + Ok(()) +} From f6ee23fe378fd6ac450c66d0862c3dd47c7e57c1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:45:03 +0300 Subject: [PATCH 53/59] test(live): cover idle session cancellation and tidy transport fixtures Add a test asserting that cancelling an active idle session terminates the native provider driver and runs host cleanup exactly once. The transport fixture's two independent delivery flags are also folded into a single DeliveryMode enum so the mutually exclusive cases are expressed directly. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/service/live/actor_tests.rs | 76 +++++++++++++++++++ .../src/service/live/transport_tests.rs | 19 +++-- 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/crates/tinyvoice-module/src/service/live/actor_tests.rs b/crates/tinyvoice-module/src/service/live/actor_tests.rs index ae29bd2..3d24059 100644 --- a/crates/tinyvoice-module/src/service/live/actor_tests.rs +++ b/crates/tinyvoice-module/src/service/live/actor_tests.rs @@ -260,3 +260,79 @@ async fn malformed_finish_endpoint_cannot_acknowledge_host_cleanup() -> tinybus: assert_eq!(cleanup.finishes.load(Ordering::SeqCst), 0); Ok(()) } + +#[derive(Debug)] +struct IdleProvider { + capabilities: tinyagents_live::tinyliveagents::Capabilities, + stopped: Arc, +} +struct ProviderStopped(Arc); +impl Drop for ProviderStopped { + fn drop(&mut self) { + self.0.notify_one(); + } +} +#[async_trait::async_trait] +impl tinyagents_live::tinyliveagents::LiveProvider for IdleProvider { + fn id(&self) -> &'static str { + "fixture-idle" + } + fn capabilities(&self) -> tinyagents_live::tinyliveagents::Capabilities { + self.capabilities + } + async fn connect( + &self, + _config: tinyagents_live::tinyliveagents::LiveConfig, + ) -> tinyagents_live::tinyliveagents::Result { + let (commands, receive_commands) = tokio::sync::mpsc::channel(8); + let (events, receive_events) = tokio::sync::mpsc::channel(8); + let stopped = self.stopped.clone(); + let task = tokio::spawn(async move { + let _guard = ProviderStopped(stopped); + let _channels = (events, receive_commands); + std::future::pending::<()>().await; + }); + Ok( + tinyagents_live::tinyliveagents::LiveSession::from_channels(commands, receive_events) + .with_task(task), + ) + } +} + +#[tokio::test] +async fn cancelling_an_active_idle_session_terminates_the_native_provider_driver() +-> anyhow::Result<()> { + let (module, _host, port, cleanup) = cleanup_fixture(false).await?; + let mut request: LiveStartRequest = serde_json::from_str(include_str!("fixtures/start.json"))?; + request.tool_port = port; + let mut prepared = super::super::prepare::prepare(&request, &module)?; + let stopped = Arc::new(tokio::sync::Notify::new()); + prepared.provider = Box::new(IdleProvider { + capabilities: prepared.provider.capabilities(), + stopped: stopped.clone(), + }); + let lease = Arc::new(Lease::new()); + let actor = tokio::spawn(run(lease.clone(), request, prepared, module)); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if lease + .phase + .lock() + .map_err(|_| anyhow::anyhow!("fixture lock"))? + .state + == LiveState::Running + { + break; + } + tokio::task::yield_now().await; + } + Ok::<_, anyhow::Error>(()) + }) + .await??; + tokio::task::yield_now().await; + lease.cancel(); + actor.await?; + tokio::time::timeout(Duration::from_secs(1), stopped.notified()).await?; + assert_eq!(cleanup.finishes.load(Ordering::SeqCst), 1); + Ok(()) +} diff --git a/crates/tinyvoice-module/src/service/live/transport_tests.rs b/crates/tinyvoice-module/src/service/live/transport_tests.rs index 7c3b5ef..39fe290 100644 --- a/crates/tinyvoice-module/src/service/live/transport_tests.rs +++ b/crates/tinyvoice-module/src/service/live/transport_tests.rs @@ -7,6 +7,14 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use tinybus::{broker::Broker, transport::memory::MemoryBus}; use tinyvoice_bus::live::{ServerFrame, TransportFrame, transport::LiveBindTransportRequest}; +#[derive(Clone, Copy, Default)] +enum DeliveryMode { + #[default] + Accept, + LoseFirstReply, + Reject, +} + #[derive(Clone, Default)] struct Socket { committed: Arc>>, @@ -15,10 +23,9 @@ struct Socket { polls: Arc>, closes: Arc, closed: Arc, - first_reply_lost: bool, + delivery: DeliveryMode, unresponsive_close: bool, invalid_sequence: bool, - reject_delivery: bool, } #[tinybus::interface(name = "ai.tinyhumans.tinyvoice.LiveTransport")] @@ -38,13 +45,13 @@ impl Socket { request: DeliverLiveEffectsRequest, ) -> tinybus::Result { let attempt = self.attempts.fetch_add(1, Ordering::SeqCst); - if self.reject_delivery { + if matches!(self.delivery, DeliveryMode::Reject) { return Ok(DeliverLiveEffectsReply { delivered: false }); } if self.committed.lock().await.insert(request.delivery_id) { self.effects.lock().await.extend(request.outputs); } - if self.first_reply_lost && attempt == 0 { + if matches!(self.delivery, DeliveryMode::LoseFirstReply) && attempt == 0 { return Err(tinybus::Error::failed( "fixture lost acknowledgement after commit", )); @@ -97,7 +104,7 @@ async fn fixture( async fn lost_delivery_reply_reuses_identity_and_preserves_order_without_duplicate_persistence() -> tinybus::Result<()> { let socket = Socket { - first_reply_lost: true, + delivery: DeliveryMode::LoseFirstReply, ..Socket::default() }; let (sessions, _module, _host, handle, lease) = fixture(socket.clone()).await?; @@ -292,7 +299,7 @@ async fn portable_transport_controls_preserve_native_input_and_ignore_late_nonco async fn rejected_delivery_retries_boundedly_without_persisting_or_dispatching_input() -> tinybus::Result<()> { let socket = Socket { - reject_delivery: true, + delivery: DeliveryMode::Reject, ..Socket::default() }; let (sessions, module, _host, handle, lease) = fixture(socket.clone()).await?; From 910f8768fe8a014e605494a478b31ba78c9b6ef4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:46:06 +0300 Subject: [PATCH 54/59] test(live): cover dropped output consumer during active provider Add a test asserting that when the output consumer disappears while a provider is active, native audio stops and the session ends in the closed state without leaving provider work behind. The table test module is made visible to the live actor tests so they can reuse a helper that closes the output reader. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/service/live/actor_tests.rs | 23 +++++++++++++++++++ .../src/service/live/table.rs | 2 +- .../src/service/live/table_tests.rs | 5 ++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/tinyvoice-module/src/service/live/actor_tests.rs b/crates/tinyvoice-module/src/service/live/actor_tests.rs index 3d24059..75ab33a 100644 --- a/crates/tinyvoice-module/src/service/live/actor_tests.rs +++ b/crates/tinyvoice-module/src/service/live/actor_tests.rs @@ -336,3 +336,26 @@ async fn cancelling_an_active_idle_session_terminates_the_native_provider_driver assert_eq!(cleanup.finishes.load(Ordering::SeqCst), 1); Ok(()) } + +#[tokio::test] +async fn dropped_output_consumer_stops_native_audio_without_leaving_provider_work() +-> anyhow::Result<()> { + let (module, _host, port, cleanup) = cleanup_fixture(false).await?; + let mut request: LiveStartRequest = serde_json::from_str(include_str!("fixtures/start.json"))?; + request.tool_port = port; + let mut prepared = super::super::prepare::prepare(&request, &module)?; + prepared.provider = Box::new(AudioProvider(prepared.provider.capabilities())); + let lease = Arc::new(Lease::new()); + super::super::table::tests::close_output_reader(&lease).await; + run(lease.clone(), request, prepared, module).await; + assert_eq!(cleanup.finishes.load(Ordering::SeqCst), 1); + assert_eq!( + lease + .phase + .lock() + .map_err(|_| anyhow::anyhow!("fixture lock"))? + .state, + LiveState::Closed + ); + Ok(()) +} diff --git a/crates/tinyvoice-module/src/service/live/table.rs b/crates/tinyvoice-module/src/service/live/table.rs index 9ad96f3..5404c70 100644 --- a/crates/tinyvoice-module/src/service/live/table.rs +++ b/crates/tinyvoice-module/src/service/live/table.rs @@ -456,7 +456,7 @@ fn unavailable() -> tinybus::Error { #[cfg(test)] #[path = "table_tests.rs"] -mod tests; +pub(super) mod tests; /// Generate unpredictable correlation identities without deriving host secrets. pub(super) fn random_id() -> tinybus::Result { diff --git a/crates/tinyvoice-module/src/service/live/table_tests.rs b/crates/tinyvoice-module/src/service/live/table_tests.rs index e0f15ce..579a545 100644 --- a/crates/tinyvoice-module/src/service/live/table_tests.rs +++ b/crates/tinyvoice-module/src/service/live/table_tests.rs @@ -354,3 +354,8 @@ async fn live_admission_rejects_changed_expired_and_oversized_inputs_before_conn sessions.release(request.handle).await?; Ok(()) } + +/// Script the module's output consumer disappearing while a provider is active. +pub(in crate::service::live) async fn close_output_reader(lease: &Lease) { + lease.reader.lock().await.rx.close(); +} From cff14f055fd5929c1d64143c384f065e1a524dd8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:47:24 +0300 Subject: [PATCH 55/59] test(live): wire up session and transport test modules Register the new session_tests and transport_tests files as cfg(test) modules so the live session and transport suites are compiled and run with the crate. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyvoice-bus/src/live/session.rs | 4 + .../tinyvoice-bus/src/live/session_tests.rs | 97 +++++++++++++++++++ crates/tinyvoice-bus/src/live/transport.rs | 4 + .../tinyvoice-bus/src/live/transport_tests.rs | 55 +++++++++++ 4 files changed, 160 insertions(+) create mode 100644 crates/tinyvoice-bus/src/live/session_tests.rs create mode 100644 crates/tinyvoice-bus/src/live/transport_tests.rs diff --git a/crates/tinyvoice-bus/src/live/session.rs b/crates/tinyvoice-bus/src/live/session.rs index 615bbd1..ead3116 100644 --- a/crates/tinyvoice-bus/src/live/session.rs +++ b/crates/tinyvoice-bus/src/live/session.rs @@ -380,3 +380,7 @@ pub struct CancelLiveToolReply { /// False for an unknown invocation; never invent a successful cancellation. pub cancelled: bool, } + +#[cfg(test)] +#[path = "session_tests.rs"] +mod tests; diff --git a/crates/tinyvoice-bus/src/live/session_tests.rs b/crates/tinyvoice-bus/src/live/session_tests.rs new file mode 100644 index 0000000..69864a8 --- /dev/null +++ b/crates/tinyvoice-bus/src/live/session_tests.rs @@ -0,0 +1,97 @@ +//! Contract round trips preserve provenance while debug output redacts authority. + +use super::*; +use serde_json::json; + +#[test] +fn live_connection_debug_never_contains_keys_signed_urls_or_provider_identity() { + for connection in [ + LiveConnection::Gemini { + api_key: "secret-key".into(), + }, + LiveConnection::Sarvam { + api_key: "secret-key".into(), + }, + LiveConnection::ElevenLabs { + signed_url: "wss://host/?secret-key".into(), + }, + LiveConnection::GeminiRelay { + ws_url: "wss://host/?secret-key".into(), + session_id: "private-provider-id".into(), + model: "private-model".into(), + }, + ] { + let debug = format!("{connection:?}"); + assert!(!debug.contains("secret-key")); + assert!(!debug.contains("private-provider-id")); + assert!(!debug.contains("private-model")); + } +} + +#[test] +fn live_start_debug_redacts_configuration_credentials_and_context_but_wire_preserves_them() +-> Result<(), serde_json::Error> { + let mut request: LiveStartRequest = serde_json::from_str(include_str!( + "../../../tinyvoice-module/src/service/live/fixtures/start.json" + ))?; + request.configuration.system_instruction = Some("private-prompt".into()); + request.configuration.provider_options = json!({"credential":"secret-value"}); + request.tool_port.context_token = "private-authority".into(); + request.connection = LiveConnection::Gemini { + api_key: "secret-value".into(), + }; + let debug = format!("{request:?}"); + for private in ["private-prompt", "secret-value", "private-authority"] { + assert!(!debug.contains(private)); + } + assert!(debug.contains("16000")); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&request)?)?, + request + ); + Ok(()) +} + +#[test] +fn tool_callback_wire_preserves_distinct_native_and_cancellation_identities_without_debug_payloads() +-> Result<(), serde_json::Error> { + let execute = ExecuteLiveToolRequest { + context_token: "private-authority".into(), + invocation_id: "module-invocation".into(), + call_id: "native-call".into(), + name: "approved".into(), + arguments: json!({"private":"user-input"}), + prefer_markdown: true, + }; + let wire = serde_json::to_value(&execute)?; + assert_eq!( + wire, + json!({"context_token":"private-authority","invocation_id":"module-invocation","call_id":"native-call","name":"approved","arguments":{"private":"user-input"},"prefer_markdown":true}) + ); + let cancel = CancelLiveToolRequest { + context_token: execute.context_token.clone(), + invocation_id: execute.invocation_id.clone(), + call_id: execute.call_id.clone(), + }; + let finish = FinishLiveContextRequest { + context_token: execute.context_token.clone(), + }; + let result = ExecuteLiveToolReply { + result: json!({"content":[{"type":"text","text":"private-result"}],"is_error":true}), + }; + for debug in [ + format!("{execute:?}"), + format!("{cancel:?}"), + format!("{finish:?}"), + format!("{result:?}"), + ] { + for private in ["private-authority", "user-input", "private-result"] { + assert!(!debug.contains(private)); + } + } + assert_eq!( + serde_json::to_value(&cancel)?, + json!({"context_token":"private-authority","invocation_id":"module-invocation","call_id":"native-call"}) + ); + Ok(()) +} diff --git a/crates/tinyvoice-bus/src/live/transport.rs b/crates/tinyvoice-bus/src/live/transport.rs index 99820a3..df203b3 100644 --- a/crates/tinyvoice-bus/src/live/transport.rs +++ b/crates/tinyvoice-bus/src/live/transport.rs @@ -117,3 +117,7 @@ macro_rules! redacted_debug { redacted_debug!(ReadLiveInputRequest); redacted_debug!(DeliverLiveEffectsRequest); redacted_debug!(CloseLiveTransportRequest); + +#[cfg(test)] +#[path = "transport_tests.rs"] +mod tests; diff --git a/crates/tinyvoice-bus/src/live/transport_tests.rs b/crates/tinyvoice-bus/src/live/transport_tests.rs new file mode 100644 index 0000000..eb172e9 --- /dev/null +++ b/crates/tinyvoice-bus/src/live/transport_tests.rs @@ -0,0 +1,55 @@ +//! Mechanical transport values retain ordered effects without debug authority leaks. + +use super::*; +use crate::live::TranscriptRole; +use serde_json::json; + +#[test] +fn transport_callback_wire_preserves_sequence_and_delivery_identity_while_debug_redacts_payloads() +-> Result<(), serde_json::Error> { + let port = LiveTransportPort { + bus_name: "ai.test.Transport".into(), + object_path: "/test/Transport".into(), + context_token: "private-token".into(), + }; + let read = ReadLiveInputRequest { + context_token: port.context_token.clone(), + acknowledged_frame: 7, + }; + let close = CloseLiveTransportRequest { + context_token: port.context_token.clone(), + }; + let delivery = DeliverLiveEffectsRequest { + context_token: port.context_token.clone(), + handle: LiveHandle { + id: "opaque".into(), + }, + delivery_id: "input-8".into(), + outputs: vec![LiveOutput::Persist { + role: TranscriptRole::User, + text: "private-user-text".into(), + }], + }; + for debug in [ + format!("{port:?}"), + format!("{read:?}"), + format!("{close:?}"), + format!("{delivery:?}"), + ] { + assert!(!debug.contains("private-token")); + assert!(!debug.contains("private-user-text")); + } + assert_eq!( + serde_json::to_value(&read)?, + json!({"context_token":"private-token","acknowledged_frame":7}) + ); + assert_eq!( + serde_json::to_value(&delivery)?, + json!({"context_token":"private-token","handle":{"id":"opaque"},"delivery_id":"input-8","outputs":[{"kind":"persist","role":"user","text":"private-user-text"}]}) + ); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&delivery)?)?, + delivery + ); + Ok(()) +} From cdb54e9023d2dff1ca1c4350975183444f440941 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:47:48 +0300 Subject: [PATCH 56/59] docs(live): document the live session runtime contract Add a README section and module-level docs describing how the compiled module owns the live provider loop through TinyAgentsLive and the bounded client/provider coordinator, while hosts supply connection material, tool authority, socket callbacks and persistence. Also fix rustdoc links for the Gemini and ElevenLabs connection variants. Auto-committed-on: dragonfly Co-authored-by: Medulla --- README.md | 16 ++++++++++++++++ crates/tinyvoice-bus/src/live/session.rs | 4 ++-- crates/tinyvoice-module/src/lib.rs | 7 +++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 561d011..d890d02 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ permission check. | Wake-word gate, intent routing | What to *do* with an intent | | Hallucination detection | The STT transport, credentials, and retry policy | | Relay user-text extraction, readback classification and turn preparation | Local agent execution, product prompts, acknowledgment timing and delivery | +| Portable live controls and module-owned provider/socket coordinator | Prepared credentials, provider selection, current tool policy and transcript persistence | A visible consequence: `VadConfig` has no constructor that reads a config file. A host builds one from whatever it persists. A crate that guessed at that shape @@ -98,6 +99,21 @@ on a realtime thread where blocking causes dropouts, so callbacks forward raw interleaved samples into a bounded queue. Hosts poll batches and call `PrepareFrames` from a worker. +### Live sessions + +The compiled module owns the live provider loop through TinyAgentsLive and a +bounded client/provider coordinator. Hosts reserve a lease, bind mechanical +socket callbacks with `LiveBindTransport`, and supply prepared connection +material and explicit declarations through `LiveStart`. The module handles +polling, ordered effects, replay and cleanup; callbacks retain product policy +and persistence. A declaration never grants execution permission. + +The minimal contract also supports manual `LiveSend`/`LiveRead` for hosts that +need a custom transport, with replay until acknowledgment. Those operations +are excluded once a module transport is bound. See the +[live runtime contract](docs/specs/live-module-runtime.md) for bounds and +callback cancellation requirements. + ## Layout ```text diff --git a/crates/tinyvoice-bus/src/live/session.rs b/crates/tinyvoice-bus/src/live/session.rs index ead3116..ba3f1ed 100644 --- a/crates/tinyvoice-bus/src/live/session.rs +++ b/crates/tinyvoice-bus/src/live/session.rs @@ -175,7 +175,7 @@ impl std::fmt::Debug for ExecuteLiveToolReply { #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum LiveConnection { - /// Direct Gemini Live connection. + /// Direct `Gemini` Live connection. Gemini { /// Host-provided API key. api_key: String, @@ -189,7 +189,7 @@ pub enum LiveConnection { /// Prepared provider model. model: String, }, - /// Prepared ElevenLabs Conversational AI connection. + /// Prepared `ElevenLabs` Conversational AI connection. ElevenLabs { /// Prepared signed websocket URL. signed_url: String, diff --git a/crates/tinyvoice-module/src/lib.rs b/crates/tinyvoice-module/src/lib.rs index 0b572a4..90fd294 100644 --- a/crates/tinyvoice-module/src/lib.rs +++ b/crates/tinyvoice-module/src/lib.rs @@ -32,6 +32,13 @@ //! feeds can be composed from approved Computer accessibility facts by the //! host; `TinyVoice` does not link `Computer` or own accessibility algorithms. //! +//! # Live sessions +//! +//! The module owns generic provider/tool execution through TinyAgentsLive and +//! the bounded client/provider coordinator. Hosts supply connection material, +//! current tool authority, mechanical socket callbacks and persistence. No +//! module method obtains product credentials or selects product policy. +//! //! # Bounded session state //! //! `VoiceService` retains capture, recording, VAD and hotkey owners between bus From b238e7fe59046f2e3ed6b76b89a6f94927992a03 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:50:16 +0300 Subject: [PATCH 57/59] ci: enforce live module coverage and boundary checks The CI workflow now builds the live module and runs its boundary verification example, then enforces the 90% per-file coverage threshold against the live module coverage report and uploads that report alongside the existing one. A doc comment in the bus session was also reworded to satisfy the lint on the TinyTools name. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .github/scripts/check-live-module-coverage.sh | 22 +++++++++++++++++++ .github/workflows/ci.yml | 12 +++++++++- crates/tinyvoice-bus/src/live/session.rs | 2 +- 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100755 .github/scripts/check-live-module-coverage.sh diff --git a/.github/scripts/check-live-module-coverage.sh b/.github/scripts/check-live-module-coverage.sh new file mode 100755 index 0000000..2f0ae48 --- /dev/null +++ b/.github/scripts/check-live-module-coverage.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Exercise native fixtures and the real artifact before checking live-runtime files. +set -euo pipefail +minimum="${1:-90}" +report="${2:-live-module-coverage.json}" +manifest="crates/tinyvoice-module/Cargo.toml" +cargo llvm-cov --locked --manifest-path "$manifest" --all-features --lib --no-report +# all-features selects static-link; the loader needs the default dynamic build. +cargo llvm-cov run --locked --manifest-path "$manifest" --no-report --example verify_module -- crates/tinyvoice-module/target/llvm-cov-target/debug/deps/libtinyvoice_module.so +cargo llvm-cov report --manifest-path "$manifest" --json --output-path "$report" +prefix="$(pwd -P)/crates/tinyvoice-module/src/service/live/" +count="$(jq --arg prefix "$prefix" '[.data[].files[] | select(.filename | startswith($prefix)) | select(.filename | endswith("_tests.rs") | not) | select(.summary.lines.count > 0)] | length' "$report")" +if [[ "$count" -ne 6 ]]; then + echo "expected six covered live-runtime source files, found $count" >&2 + exit 1 +fi +jq -r --arg prefix "$prefix" '.data[].files[] | select(.filename | startswith($prefix)) | select(.filename | endswith("_tests.rs") | not) | select(.summary.lines.count > 0) | "\(.filename): \(.summary.lines.percent)%"' "$report" +failures="$(jq -r --arg prefix "$prefix" --argjson minimum "$minimum" '.data[].files[] | select(.filename | startswith($prefix)) | select(.filename | endswith("_tests.rs") | not) | select(.summary.lines.count > 0) | select(.summary.lines.percent < $minimum) | "\(.filename): \(.summary.lines.percent)%"' "$report")" +if [[ -n "$failures" ]]; then + printf 'Live runtime files below %s%% line coverage:\n%s\n' "$minimum" "$failures" >&2 + exit 1 +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f6b43e..971d115 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,15 +72,25 @@ jobs: - name: Test default features run: cargo test + - name: Verify compiled live module boundary + run: | + cargo build --locked --manifest-path crates/tinyvoice-module/Cargo.toml --lib + cargo run --locked --manifest-path crates/tinyvoice-module/Cargo.toml --example verify_module -- crates/tinyvoice-module/target/debug/libtinyvoice_module.so + - name: Require 90% line coverage in every source file run: .github/scripts/check-file-coverage.sh 90 coverage.json + - name: Require 90% live module file coverage + run: .github/scripts/check-live-module-coverage.sh 90 live-module-coverage.json + - name: Upload coverage report if: ${{ always() }} uses: actions/upload-artifact@v7 with: name: coverage-json - path: coverage.json + path: | + coverage.json + live-module-coverage.json if-no-files-found: ignore windows-hotkeys: diff --git a/crates/tinyvoice-bus/src/live/session.rs b/crates/tinyvoice-bus/src/live/session.rs index ba3f1ed..c9a82c9 100644 --- a/crates/tinyvoice-bus/src/live/session.rs +++ b/crates/tinyvoice-bus/src/live/session.rs @@ -156,7 +156,7 @@ impl std::fmt::Debug for ExecuteLiveToolRequest { /// A canonical tool result serialized mechanically by the host. /// -/// It uses TinyTools' complete result JSON, including its reported-error flag. +/// It uses `TinyTools`' complete result JSON, including its reported-error flag. /// The module decodes that existing vocabulary rather than inventing success. #[derive(Clone, PartialEq, Serialize, Deserialize)] pub struct ExecuteLiveToolReply { From 6c7d5fa5cdbae55d14b00283ac1daf8d01192b73 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:51:11 +0300 Subject: [PATCH 58/59] docs: format TinyAgentsLive as code and note relay coordinator follow-up Wrap the TinyAgentsLive reference in backticks in the module docs and mark the portable relay coordinator step as a separate owner follow-up so the plan reflects that it is not part of the current extraction. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyvoice-module/src/lib.rs | 2 +- docs/plans/live-module-runtime.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinyvoice-module/src/lib.rs b/crates/tinyvoice-module/src/lib.rs index 90fd294..4db16b8 100644 --- a/crates/tinyvoice-module/src/lib.rs +++ b/crates/tinyvoice-module/src/lib.rs @@ -34,7 +34,7 @@ //! //! # Live sessions //! -//! The module owns generic provider/tool execution through TinyAgentsLive and +//! The module owns generic provider/tool execution through `TinyAgentsLive` and //! the bounded client/provider coordinator. Hosts supply connection material, //! current tool authority, mechanical socket callbacks and persistence. No //! module method obtains product credentials or selects product policy. diff --git a/docs/plans/live-module-runtime.md b/docs/plans/live-module-runtime.md index a98d551..fb68749 100644 --- a/docs/plans/live-module-runtime.md +++ b/docs/plans/live-module-runtime.md @@ -13,7 +13,7 @@ Specification: ../specs/live-module-runtime.md. events until explicit release. 4. Verify a real compiled native module against a local provider and a policy-aware callback; migrate websocket and lifecycle fixtures. -5. Add the portable relay coordinator with host timing/completion ports where +5. In a separate owner follow-up, add the portable relay coordinator with host timing/completion ports where extraction preserves the existing acknowledgment and deferred delivery semantics. Keep product model, progress filtering and delivery choices out of the module. From bc5742142925ea3802d8ce3b8cfe9850990af929 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 21:53:50 +0300 Subject: [PATCH 59/59] docs: clarify live transport binding method Co-authored-by: Medulla --- crates/tinyvoice-bus/src/names/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinyvoice-bus/src/names/mod.rs b/crates/tinyvoice-bus/src/names/mod.rs index fa9de7e..8133b08 100644 --- a/crates/tinyvoice-bus/src/names/mod.rs +++ b/crates/tinyvoice-bus/src/names/mod.rs @@ -8,7 +8,7 @@ pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinyvoice/Voice"; /// One constant per member of [`BUS_NAME`]. pub mod methods { - /// Reserve a bounded live session before startup. + /// Bind the module-owned socket coordinator before provider startup. pub const LIVE_BIND_TRANSPORT: &str = "LiveBindTransport"; /// Reserve a live provider session. pub const LIVE_RESERVE: &str = "LiveReserve";