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 ec2d295..971d115 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 @@ -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: @@ -90,7 +100,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - submodules: true + submodules: recursive - uses: dtolnay/rust-toolchain@stable @@ -112,7 +122,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 +146,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 +179,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/.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/MODULE.md b/MODULE.md index af80d63..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 34 methods under contract 1.6. +`/ai/tinyhumans/tinyvoice/Voice`, and provides 42 methods under contract 1.7. ## Methods @@ -21,6 +21,14 @@ 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 | +| `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 | +| `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/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/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/mod.rs b/crates/tinyvoice-bus/src/live/mod.rs new file mode 100644 index 0000000..ef2c97b --- /dev/null +++ b/crates/tinyvoice-bus/src/live/mod.rs @@ -0,0 +1,168 @@ +//! 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}; + +/// 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, +} + +/// 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..c9a82c9 --- /dev/null +++ b/crates/tinyvoice-bus/src/live/session.rs @@ -0,0 +1,386 @@ +//! 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"; +/// 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"; + +/// An opaque live lease, reserved before provider connection begins. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct LiveHandle { + /// Unpredictable module-generated lease identity. + pub id: String, +} + +/// 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, + /// 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. +#[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, + /// 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. + 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("invocation_id", &self.invocation_id) + .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() + } +} + +/// 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 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. + 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, 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, +} + +/// 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, +} + +#[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 new file mode 100644 index 0000000..df203b3 --- /dev/null +++ b/crates/tinyvoice-bus/src/live/transport.rs @@ -0,0 +1,123 @@ +//! Mechanical client socket and persistence callback contract for the module pump. + +use super::{ + TransportFrame, + session::{LiveHandle, LiveOutput}, +}; +use serde::{Deserialize, Serialize}; + +/// 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); + +#[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(()) +} diff --git a/crates/tinyvoice-bus/src/names/mod.rs b/crates/tinyvoice-bus/src/names/mod.rs index dab5956..8133b08 100644 --- a/crates/tinyvoice-bus/src/names/mod.rs +++ b/crates/tinyvoice-bus/src/names/mod.rs @@ -8,6 +8,22 @@ pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinyvoice/Voice"; /// One constant per member of [`BUS_NAME`]. pub mod methods { + /// 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"; + /// 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. pub const PREPARE_RELAY_TURN: &str = "PrepareRelayTurn"; /// Reserves a hotkey lease before native startup. @@ -102,8 +118,16 @@ 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, + methods::LIVE_SEND, + methods::LIVE_SHUTDOWN, + methods::LIVE_START, 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/Cargo.lock b/crates/tinyvoice-module/Cargo.lock index 40e6201..a19ce67 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,47 +2043,223 @@ 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" +version = "0.1.3" dependencies = [ "async-trait", "flate2", + "libc", "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.3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tinybus-module" +version = "0.1.3" +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,17 +2294,24 @@ dependencies = [ name = "tinyvoice-module" version = "0.1.16" dependencies = [ + "anyhow", + "async-trait", "base64 0.22.1", + "futures-util", "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", "tokio", + "tokio-tungstenite", "x11rb", ] @@ -1303,8 +2322,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 +2342,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 +2402,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 +2427,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 +2448,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 +2459,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 +2550,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 +2606,7 @@ dependencies = [ "rustls-pki-types", "ureq-proto", "utf8-zero", - "webpki-roots", + "webpki-roots 1.0.9", ] [[package]] @@ -1447,18 +2621,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 +2681,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 +2718,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 +2761,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 +2784,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 +3035,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 +3053,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 +3102,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..db1b5f6 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" @@ -28,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"] } @@ -73,7 +81,9 @@ broken_intra_doc_links = "warn" private_intra_doc_links = "warn" [dev-dependencies] -tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } +futures-util = "0.3" +tokio-tungstenite = "0.29" +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/examples/verify_module.rs b/crates/tinyvoice-module/examples/verify_module.rs index 8362fa9..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> { @@ -68,6 +71,8 @@ async fn main() -> Result<(), Box> { return Err(io::Error::other("capture manifest differs from contract").into()); } 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 = @@ -375,3 +380,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/examples/verify_module/live.rs b/crates/tinyvoice-module/examples/verify_module/live.rs new file mode 100644 index 0000000..cebbb84 --- /dev/null +++ b/crates/tinyvoice-module/examples/verify_module/live.rs @@ -0,0 +1,390 @@ +//! 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; +use tinyvoice_bus::live::{ + 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; + +#[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().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}), + }) + } + async fn finish_live_context( + &self, + request: FinishLiveContextRequest, + ) -> tinybus::Result { + if request.context_token != "known-live-context" { + return Ok(FinishLiveContextReply { finished: false }); + } + *self.finishes.lock().await = 1; + 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.clone(),)).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).await?; + let released: LiveStatus = proxy + .call( + "LiveRelease", + (LiveHandleRequest { + handle: reserved.handle.clone(), + },), + ) + .await?; + 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 + .call( + "LiveRelease", + (LiveHandleRequest { + handle: reserved.handle, + },), + ) + .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()); + } + 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) +} + +async 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 + && 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().await; + 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(()) +} + +#[derive(Clone, Default)] +struct ClientTransport { + outputs: Arc>>, + committed: Arc>>, + closed: Arc, + attempts: Arc>, + closes: 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.closes.lock().await += 1; + 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??; + 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 { + 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/lib.rs b/crates/tinyvoice-module/src/lib.rs index 0b572a4..4db16b8 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 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..5d1f500 --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/actor.rs @@ -0,0 +1,197 @@ +//! 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 = bounded_projection( + LiveAgentEvent::Live(LiveEvent::Closed(CloseReason::Error(error))), + &request, + ); + let _ = lease.outputs.send(outputs).await; + } + None => {} + } + let confirmed = finish_context(&connection, &request.tool_port).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 => { + let rejected = oversized(&event); + let outputs = bounded_projection(event, request); + if rejected { + let _ = lease.outputs.send(outputs).await; + return; + } + if !outputs.is_empty() && lease.outputs.send(outputs).await.is_err() { + return; + } + } + } + if terminal { + return; + } + } +} + +/// 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( + 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 + } + 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 + } + LiveAgentEvent::Live(_) => 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, + } +} + +/// 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, + port: &tinyvoice_bus::live::session::LiveToolPort, +) -> bool { + let Ok(proxy) = connection.proxy( + port.bus_name.as_str(), + port.object_path.as_str(), + TOOL_CALLBACK_INTERFACE, + ) else { + return false; + }; + let call = proxy.call::( + FINISH_CALLBACK_METHOD, + (FinishLiveContextRequest { + context_token: port.context_token.clone(), + },), + ); + 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..75ab33a --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/actor_tests.rs @@ -0,0 +1,361 @@ +//! Bounded provider text and host cleanup regressions. + +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(), + 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) + } + )))); +} + +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(()) +} + +#[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 + } + 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(()) +} + +#[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(()) +} + +#[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(()) +} + +#[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(()) +} + +#[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/callback.rs b/crates/tinyvoice-module/src/service/live/callback.rs new file mode 100644 index 0000000..988866d --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/callback.rs @@ -0,0 +1,141 @@ +//! 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::{ + 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. +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 { + 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(), + 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 mut cancellation = InvocationGuard { + runtime: tokio::runtime::Handle::current(), + 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"); + } + // 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)?) + } +} + +#[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 { + runtime: tokio::runtime::Handle, + 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(); + self.runtime.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 new file mode 100644 index 0000000..2a812e1 --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/callback_tests.rs @@ -0,0 +1,267 @@ +//! 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>>, + hanging: Arc, + entered: Arc, + cancel_execution: Arc, + cancelled: Arc, + cancellations: Arc>>, +} + +#[tinybus::interface(name = "ai.tinyhumans.tinyvoice.LiveTools")] +impl Authority { + 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 + .lock() + .map_err(|_| tinybus::Error::failed("fixture lock"))? + .iter() + .any(|seen| { + request.invocation_id == seen.invocation_id && request.call_id == seen.call_id + }); + if !known { + return Ok(CancelLiveToolReply { cancelled: false }); + } + let mut cancellations = self.cancellations.lock().await; + if !cancellations.contains(&request) { + cancellations.push(request); + self.cancel_execution.notify_one(); + self.cancelled.notify_one(); + } + Ok(CancelLiveToolReply { cancelled: true }) + } + 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.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}), + }); + } + 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.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_eq!( + authority + .seen + .lock() + .map_err(|_| anyhow::anyhow!("fixture lock"))? + .len(), + 0 + ); + 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?; + let cancellations = authority.cancellations.lock().await; + 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(()) +} + +#[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 { + runtime: tokio::runtime::Handle::current(), + 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(()) +} 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/mod.rs b/crates/tinyvoice-module/src/service/live/mod.rs new file mode 100644 index 0000000..e279645 --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/mod.rs @@ -0,0 +1,10 @@ +//! Compiled live provider runtime and bounded session lifecycle. + +mod actor; +mod callback; +mod prepare; +mod projection; +mod table; +mod transport; + +pub(super) use table::Sessions; 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..9025a85 --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/prepare.rs @@ -0,0 +1,132 @@ +//! 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 + || 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", + )); + } + 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 { + 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(), + } +} + +#[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..1e549b1 --- /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: String::new(), + 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), + (1_800_001, 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(()) +} 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..a1c139e --- /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..0ed9c78 --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/projection_tests.rs @@ -0,0 +1,172 @@ +//! Portable live-session regression fixtures. + +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()) + })] + ); + 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] +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_eq!( + map(LiveEvent::ToolCall(ToolCall { + call_id: "c".into(), + name: "n".into(), + args: serde_json::json!({}) + })), + vec![] + ); +} + +#[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/live/table.rs b/crates/tinyvoice-module/src/service/live/table.rs new file mode 100644 index 0000000..5404c70 --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/table.rs @@ -0,0 +1,594 @@ +//! Bounded leases, cancellation and replayable live event batches. + +use std::collections::HashMap; +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; +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>, + transport_task: Option>, + transport: Option, + pub(super) transport_cleanup_confirmed: bool, + request: Option, + pub(super) cleanup_confirmed: bool, +} + +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 { + 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, + }), + 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_waiters(); + } + + pub(super) async fn wait_cancelled(&self) { + 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; + } + } + + pub(super) 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(in crate::service) 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(in crate::service) fn with_connection(connection: &tinybus::Connection) -> Self { + Self { + connection: Mutex::new(Some(connection.downgrade())), + closed: AtomicBool::new(false), + leases: Mutex::new(HashMap::new()), + } + } + + 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")); + } + 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(); + } + keep + }) + }); + if leases.len() >= MAX_LEASES { + return Err(tinybus::Error::failed("live session capacity is exhausted")); + } + loop { + let id = random_id()?; + let handle = LiveHandle { id }; + if !leases.contains_key(&handle) { + leases.insert(handle.clone(), Arc::new(Lease::new())); + return Ok(LiveStatus { + handle, + state: LiveState::Reserved, + }); + } + } + } + + pub(super) 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(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", + )); + } + let lease = self.lease(&request.handle)?; + let connection = self + .connection + .lock() + .map_err(|_| unavailable())? + .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 { + 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 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", + )); + } + 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, + }) + } + + 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 { + 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 { + if !super::super::relay::fits(&request.port, 8 * 1024) { + 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())? + .as_ref() + .and_then(tinybus::WeakConnection::upgrade) + .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", + )) + }; + } + 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, + ))); + Ok(LiveStatus { + handle: request.handle, + state: phase.state, + }) + } + + pub(in crate::service) 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(in crate::service) 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(); + } + // 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 { + 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) + } +} + +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().map(OwnedTask) + }; + 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", + )); + } + Ok(Ok(())) => {} + } + } + 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() { + for lease in leases.values() { + lease.cancel(); + if let Ok(mut phase) = lease.phase.lock() + && let Some(task) = phase.task.take() + { + task.abort(); + } + if let Ok(mut phase) = lease.phase.lock() + && let Some(task) = phase.transport_task.take() + { + task.abort(); + } + } + } + } +} + +fn unavailable() -> tinybus::Error { + tinybus::Error::failed("live session state is unavailable") +} + +#[cfg(test)] +#[path = "table_tests.rs"] +pub(super) 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))) +} + +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 new file mode 100644 index 0000000..579a545 --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/table_tests.rs @@ -0,0 +1,361 @@ +//! Portable live-session regression fixtures. + +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_eq!(empty.outputs, vec![]); + 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)); + let lease = sessions.lease(&handle)?; + blocked_task(&lease, 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)); + let lease = sessions.lease(&handle)?; + blocked_task(&lease, dropped.clone())?; + tokio::task::yield_now().await; + drop(sessions); + tokio::task::yield_now().await; + 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(()) +} + +#[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(()) +} + +#[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: &[u8]| 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 +} + +#[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(()) +} + +/// 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(); +} 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..142bb06 --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/transport.rs @@ -0,0 +1,217 @@ +//! 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", + )) +} + +#[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..39fe290 --- /dev/null +++ b/crates/tinyvoice-module/src/service/live/transport_tests.rs @@ -0,0 +1,349 @@ +//! 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::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>>, + effects: Arc>>, + attempts: Arc, + polls: Arc>, + closes: Arc, + closed: Arc, + delivery: DeliveryMode, + 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 { + *self.polls.lock().await += 1; + 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 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 matches!(self.delivery, DeliveryMode::LoseFirstReply) && 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); + 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 { + delivery: DeliveryMode::LoseFirstReply, + ..Socket::default() + }; + let (sessions, _module, _host, handle, lease) = fixture(socket.clone()).await?; + let effects = vec![ + LiveOutput::Json { + frame: ServerFrame::Transcript { + role: TranscriptRole::Agent, + text: "complete".into(), + is_final: 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().await, effects); + assert_eq!(socket.committed.lock().await.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(()) +} + +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 { + delivery: DeliveryMode::Reject, + ..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(()) +} diff --git a/crates/tinyvoice-module/src/service/mod.rs b/crates/tinyvoice-module/src/service/mod.rs index 7e83f25..6649b1e 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 _; @@ -58,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. @@ -187,6 +189,57 @@ 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() + } + /// 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, + 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, + 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, @@ -665,7 +718,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)), + ..VoiceService::default() + }, + ) .await?; connection.request_name(names::BUS_NAME).await?; Ok(()) @@ -688,6 +747,14 @@ tinybus_module::module_export_optional_static! { "VadClose", "PrepareFrames", "PrepareRelayTurn", + "PrepareLiveInput", + "LiveReserve", + "LiveBindTransport", + "LiveStart", + "LiveSend", + "LiveRead", + "LiveRelease", + "LiveShutdown", "FrameEnergies", "CaptureStart", "CapturePoll", diff --git a/crates/tinyvoice-module/src/service/mod_tests.rs b/crates/tinyvoice-module/src/service/mod_tests.rs index f4d7795..fbebf4d 100644 --- a/crates/tinyvoice-module/src/service/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/mod_tests.rs @@ -1059,3 +1059,104 @@ 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"); +} + +#[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"); +} + +#[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 { + 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; +} 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() } 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..a0fedc4 --- /dev/null +++ b/crates/tinyvoice/src/live/mod.rs @@ -0,0 +1,52 @@ +//! 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), + }) +} + +#[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..ebb7dd2 --- /dev/null +++ b/crates/tinyvoice/src/live/mod_tests.rs @@ -0,0 +1,103 @@ +//! Portable live-session regression fixtures. + +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(()) +} diff --git a/docs/plans/live-module-runtime.md b/docs/plans/live-module-runtime.md new file mode 100644 index 0000000..fb68749 --- /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 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 + policy-aware callback; migrate websocket and lifecycle fixtures. +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. +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..7dc2117 --- /dev/null +++ b/docs/specs/live-module-runtime.md @@ -0,0 +1,99 @@ +# 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 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. +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 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 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. +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. + +## Admission and transport bounds + +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 +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. + +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. diff --git a/docs/specs/module-capture.md b/docs/specs/module-capture.md index 4459fba..6a5a54c 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 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. 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`. 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 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