diff --git a/.github/scripts/check-live-module-coverage.sh b/.github/scripts/check-live-module-coverage.sh new file mode 100755 index 0000000..ecfd8a9 --- /dev/null +++ b/.github/scripts/check-live-module-coverage.sh @@ -0,0 +1,28 @@ +#!/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" +require_runtime_coverage() { + local prefix="$1" + local expected="$2" +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 "$expected" ]]; then + echo "expected $expected covered runtime source files under $prefix, 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 'Runtime files below %s%% line coverage:\n%s\n' "$minimum" "$failures" >&2 + exit 1 +fi + +} +require_runtime_coverage "$(pwd -P)/crates/tinyvoice-module/src/service/live/" 6 +require_runtime_coverage "$(pwd -P)/crates/tinyvoice-module/src/service/relay_runtime/" 2 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..8901e7d 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 47 methods under contract 1.8. ## Methods @@ -21,6 +21,19 @@ 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 | +| `RelayConfigure` | explicit concurrent/pending turn limits | immutable idempotent `RelayLimits` | +| `RelayReserve` | none | bounded opaque `RelayStatus` before native work | +| `RelayStart` | reserved handle, saved host callback context, explicit timing/fallbacks | asynchronous idempotent `RelayStatus` | +| `RelayRelease` | known relay handle | bounded cleanup and closed status | +| `RelayShutdown` | none | permanent closed admission and bounded concurrent cleanup | +| `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 | @@ -114,3 +127,5 @@ TinyBus modules are trusted in-process code: a loaded module shares the address space, the privileges and the crash domain, and TinyBus never unloads one. Install release artifacts only from a trusted source, and restart the host after replacing a loaded module. + +Relay coordination uses context-bound host callbacks; see the [relay runtime contract](docs/specs/relay-module-runtime.md). diff --git a/README.md b/README.md index 561d011..19e4f3a 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,29 @@ 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. +### Relay turn coordination + +The compiled module also coordinates admitted local-brain turns through +explicit RelayHost callbacks: scheduling, selected progress, an acknowledgment +clock that starts before queueing, and deferred completion. Hosts supply the +native brain and retain correlation, phrase and delivery policy. See the +[relay runtime contract](docs/specs/relay-module-runtime.md). + +### 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 031a50e..9c8389c 100644 --- a/crates/tinyvoice-bus/src/lib.rs +++ b/crates/tinyvoice-bus/src/lib.rs @@ -15,6 +15,7 @@ mod error; pub mod capture; pub mod hotkey; pub mod intent; +pub mod live; pub mod names; pub mod provider; pub mod relay; 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..7483ea0 100644 --- a/crates/tinyvoice-bus/src/names/mod.rs +++ b/crates/tinyvoice-bus/src/names/mod.rs @@ -8,6 +8,32 @@ pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinyvoice/Voice"; /// One constant per member of [`BUS_NAME`]. pub mod methods { + /// Fix explicit host concurrency and reservation bounds. + pub const RELAY_CONFIGURE: &str = "RelayConfigure"; + /// Reserve a relay turn before local-brain startup. + pub const RELAY_RESERVE: &str = "RelayReserve"; + /// Own foreground timing and deferred progress on a known reservation. + pub const RELAY_START: &str = "RelayStart"; + /// Stop one owned relay turn and notify current host cleanup. + pub const RELAY_RELEASE: &str = "RelayRelease"; + /// Close relay admission and stop all owned coordinators. + pub const RELAY_SHUTDOWN: &str = "RelayShutdown"; + /// 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,14 +128,27 @@ 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, methods::RECORDING_FINISH, methods::RECORDING_START, methods::RECORDING_SUMMARY, + methods::RELAY_CONFIGURE, + methods::RELAY_RELEASE, + methods::RELAY_RESERVE, + methods::RELAY_SHUTDOWN, + methods::RELAY_START, methods::RELEASE_AUDIO_OUTPUT, methods::RESERVE_CAPTURE, methods::ROUTE, diff --git a/crates/tinyvoice-bus/src/relay.rs b/crates/tinyvoice-bus/src/relay.rs index f28a07f..65b8726 100644 --- a/crates/tinyvoice-bus/src/relay.rs +++ b/crates/tinyvoice-bus/src/relay.rs @@ -1,5 +1,8 @@ //! Vocabulary for preparing provider-relayed conversational turns. +/// Compiled generic scheduling and local-brain completion callbacks. +pub mod runtime; + use serde::{Deserialize, Serialize}; /// Maximum serialized request size accepted by the compiled relay operation. diff --git a/crates/tinyvoice-bus/src/relay/runtime.rs b/crates/tinyvoice-bus/src/relay/runtime.rs new file mode 100644 index 0000000..2af3a33 --- /dev/null +++ b/crates/tinyvoice-bus/src/relay/runtime.rs @@ -0,0 +1,246 @@ +//! Portable scheduling and completion callbacks for an admitted local-brain turn. + +use serde::{Deserialize, Serialize}; + +/// Mechanical host endpoint for native brain work and product effects. +pub const INTERFACE: &str = "ai.tinyhumans.tinyvoice.RelayHost"; +/// Start one known host invocation idempotently. +pub const START_METHOD: &str = "StartRelayBrain"; +/// Read selected deltas and completion, replaying the next unacknowledged event. +pub const READ_METHOD: &str = "ReadRelayBrain"; +/// Apply one ordered group with a stable idempotency identity. +pub const APPLY_METHOD: &str = "ApplyRelayEffects"; +/// Revoke only the known invocation and stop its host-owned work. +pub const FINISH_METHOD: &str = "FinishRelayBrain"; + +/// Host-selected module scheduling bounds, fixed for one module instance. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct RelayLimits { + /// Concurrent native brain invocations, between one and sixteen. + pub max_active_turns: u16, + /// Total reserved, queued, running or retained turns, up to 256. + pub max_pending_turns: u16, +} + +/// Opaque module reservation, independent of product correlation identity. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct RelayHandle { + /// Unpredictable module-generated identity. + pub id: String, +} + +/// Generic lifecycle facts; no product delivery decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RelayState { + /// Waiting for a start request. + Reserved, + /// Waiting for a native execution slot. + Queued, + /// The native brain is running. + Running, + /// Spoken acknowledgment completed; native work continues. + Deferred, + /// Terminal; explicit release removes the lease. + Closed, +} + +/// State of a known reservation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RelayStatus { + /// Referenced reservation. + pub handle: RelayHandle, + /// Module lifecycle state. + pub state: RelayState, +} + +/// Saved host authority; the module cannot recover it from model arguments. +/// +/// Before start, bind the reserved module handle to this existing host context. +/// Every callback verifies that exact pair, including queued acknowledgments +/// and cleanup before native execution. Callbacks never register authority from +/// request fields or invent an executor for an unknown context. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RelayHostPort { + /// Host bus name. + pub bus_name: String, + /// Host object path. + pub object_path: String, + /// Existing native turn context, including product correlation and policy. + pub context_token: String, +} + +/// Start generic coordination on an already admitted product turn. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RelayStartRequest { + /// Reserved module lease. + pub handle: RelayHandle, + /// Saved current native execution authority. + pub port: RelayHostPort, + /// Acknowledgment clock starts before waiting for a concurrency slot. + pub acknowledgment_deadline_ms: u64, + /// Overall deadline including time spent queued. + pub turn_timeout_ms: u64, + /// Host-selected spoken fallback for an empty successful reply. + pub empty_reply_text: String, + /// Host-selected spoken acknowledgment when no delta preceded the deadline. + pub deadline_reply_text: String, + /// Host-selected fallback when native work disappears without a result. + pub lost_reply_text: String, +} + +/// Release one known turn. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RelayHandleRequest { + /// Referenced module lease. + pub handle: RelayHandle, +} + +/// One known native invocation; repeats must not create another brain. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RelayInvocationRequest { + /// Saved known host authority. + pub context_token: String, + /// Fresh module identity, separate from product correlation. + pub invocation_id: String, +} + +/// Whether the existing native authority accepted the invocation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct StartRelayBrainReply { + /// False for revoked or unknown authority; never fabricate an executor. + pub started: bool, +} + +/// Read one retained native event. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReadRelayBrainRequest { + /// Existing host authority. + pub context_token: String, + /// Known invocation. + pub invocation_id: String, + /// Last fully applied event, zero before any event. + pub acknowledged_event: u64, +} + +/// Completion supplied by the host's native local brain. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RelayOutcome { + /// Actual successful native reply. + Success { + /// Unmodified reply; the host screens delivery. + text: String, + }, + /// Actual reported native failure. + Failure { + /// Native error, never inferred from a missing result. + message: String, + }, + /// Native work disappeared without completing. + Lost, +} + +/// Host-selected speech progress and actual native completion. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RelayBrainEvent { + /// Already filtered by current host speech policy. + Delta { + /// Selected text; whitespace remains significant. + text: String, + }, + /// Only after all preceding selected deltas; no later deltas. + Complete { + /// Actual native completion. + outcome: RelayOutcome, + }, +} + +/// An idle poll or the retained next contiguous event. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReadRelayBrainReply { + /// Idle polls keep the acknowledged sequence. + pub sequence: u64, + /// Hosts return idle promptly; remote call deadlines do not cancel remote work. + pub event: Option, +} + +/// Generic effects; the host chooses wire events and product destinations. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RelayEffect { + /// Selected or explicitly supplied fallback speech. + Delta { + /// Text to forward without discarding whitespace. + text: String, + }, + /// End the foreground spoken turn after all preceding deltas. + Done, + /// Report a foreground native or coordination failure. + Error { + /// Actual native error or a bounded generic coordination error. + message: String, + }, + /// Native completion after the foreground deadline. + Deferred { + /// Actual outcome; host decides chat/readback eligibility. + outcome: RelayOutcome, + }, + /// Native work disappeared; host decides whether a failure notice is warranted. + LostBrain, +} + +/// Apply these ordered effects once even if the acknowledgment reply is lost. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApplyRelayEffectsRequest { + /// Existing host turn context. + pub context_token: String, + /// Known native invocation. + pub invocation_id: String, + /// Stable identity within this invocation. + pub delivery_id: u64, + /// Ordered effects; a done never precedes its foreground deltas. + pub effects: Vec, +} + +/// Acknowledgment after effects commit. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApplyRelayEffectsReply { + /// False when current authority or delivery is unavailable. + pub applied: bool, +} + +/// Cleanup never creates an unknown invocation or revives a finished one. +/// Stops native work without revoking its registered context: bounded terminal +/// timeout effects may follow finish before the host retires the context. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct FinishRelayBrainReply { + /// True for a known invocation that has stopped, including repeat cleanup. + pub finished: 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!( + RelayHostPort, + RelayStartRequest, + RelayInvocationRequest, + ReadRelayBrainRequest, + RelayOutcome, + RelayBrainEvent, + ReadRelayBrainReply, + RelayEffect, + ApplyRelayEffectsRequest, +); + +#[cfg(test)] +#[path = "runtime_tests.rs"] +mod tests; diff --git a/crates/tinyvoice-bus/src/relay/runtime_tests.rs b/crates/tinyvoice-bus/src/relay/runtime_tests.rs new file mode 100644 index 0000000..3f0a496 --- /dev/null +++ b/crates/tinyvoice-bus/src/relay/runtime_tests.rs @@ -0,0 +1,101 @@ +//! Relay wire provenance remains distinct from product identity and debug payloads. + +use super::*; +use serde_json::json; + +#[test] +fn relay_callbacks_preserve_reserved_authority_and_ordered_effects_without_debug_payloads() +-> Result<(), serde_json::Error> { + let port = RelayHostPort { + bus_name: "ai.test.Relay".into(), + object_path: "/test/Relay".into(), + context_token: "private-context".into(), + }; + let invocation = RelayInvocationRequest { + context_token: port.context_token.clone(), + invocation_id: "module-reservation".into(), + }; + let start = RelayStartRequest { + handle: RelayHandle { + id: invocation.invocation_id.clone(), + }, + port: port.clone(), + acknowledgment_deadline_ms: 8000, + turn_timeout_ms: 120_000, + empty_reply_text: "private-pause".into(), + deadline_reply_text: "private-ack".into(), + lost_reply_text: "private-lost".into(), + }; + let read = ReadRelayBrainRequest { + context_token: port.context_token.clone(), + invocation_id: invocation.invocation_id.clone(), + acknowledged_event: 7, + }; + let outcome = RelayOutcome::Success { + text: "private-result".into(), + }; + let event = RelayBrainEvent::Complete { + outcome: outcome.clone(), + }; + let reply = ReadRelayBrainReply { + sequence: 8, + event: Some(event.clone()), + }; + let effect = RelayEffect::Deferred { + outcome: outcome.clone(), + }; + let apply = ApplyRelayEffectsRequest { + context_token: port.context_token.clone(), + invocation_id: invocation.invocation_id.clone(), + delivery_id: 4, + effects: vec![ + RelayEffect::Delta { + text: "private-delta".into(), + }, + RelayEffect::Done, + ], + }; + for debug in [ + format!("{port:?}"), + format!("{invocation:?}"), + format!("{start:?}"), + format!("{read:?}"), + format!("{outcome:?}"), + format!("{event:?}"), + format!("{reply:?}"), + format!("{effect:?}"), + format!("{apply:?}"), + ] { + for private in [ + "private-context", + "private-result", + "private-delta", + "private-pause", + "private-ack", + "private-lost", + ] { + assert!(!debug.contains(private)); + } + } + assert_eq!( + serde_json::to_value(&read)?, + json!({"context_token":"private-context","invocation_id":"module-reservation","acknowledged_event":7}) + ); + assert_eq!( + serde_json::to_value(&reply)?, + json!({"sequence":8,"event":{"kind":"complete","outcome":{"kind":"success","text":"private-result"}}}) + ); + assert_eq!( + serde_json::to_value(&apply)?, + json!({"context_token":"private-context","invocation_id":"module-reservation","delivery_id":4,"effects":[{"kind":"delta","text":"private-delta"},{"kind":"done"}]}) + ); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&apply)?)?, + apply + ); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&start)?)?, + start + ); + Ok(()) +} diff --git a/crates/tinyvoice-bus/src/version/mod.rs b/crates/tinyvoice-bus/src/version/mod.rs index f141097..704619b 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, 8); /// 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 e4ec775..6fd6ad1 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" @@ -1283,17 +2295,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", ] @@ -1304,8 +2323,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]] @@ -1319,6 +2343,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" @@ -1327,11 +2403,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]] @@ -1343,6 +2428,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" @@ -1350,9 +2449,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]] @@ -1361,15 +2460,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" @@ -1401,6 +2551,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" @@ -1433,7 +2607,7 @@ dependencies = [ "rustls-pki-types", "ureq-proto", "utf8-zero", - "webpki-roots", + "webpki-roots 1.0.9", ] [[package]] @@ -1448,18 +2622,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" @@ -1470,12 +2682,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" @@ -1489,6 +2719,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" @@ -1521,6 +2762,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" @@ -1531,6 +2785,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" @@ -1763,6 +3036,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" @@ -1772,6 +3054,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" @@ -1809,12 +3103,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..0ee5dd6 100644 --- a/crates/tinyvoice-module/examples/verify_module.rs +++ b/crates/tinyvoice-module/examples/verify_module.rs @@ -13,7 +13,13 @@ 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; + +#[path = "verify_module/relay_runtime.rs"] +mod relay_runtime; #[tokio::main] async fn main() -> Result<(), Box> { @@ -67,7 +73,7 @@ async fn main() -> Result<(), Box> { if declared != tinyvoice_bus::METHODS { return Err(io::Error::other("capture manifest differs from contract").into()); } - verify_relay_turn(&proxy).await?; + verify_portable_runtimes(&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 +381,38 @@ 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(()) +} + +async fn verify_portable_runtimes( + proxy: &tinybus::Proxy, + client: &Connection, +) -> Result<(), Box> { + verify_relay_turn(proxy).await?; + verify_live_input(proxy).await?; + live::verify_runtime(proxy, client).await?; + relay_runtime::verify(proxy, client).await?; + 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/examples/verify_module/relay_runtime.rs b/crates/tinyvoice-module/examples/verify_module/relay_runtime.rs new file mode 100644 index 0000000..2b2398a --- /dev/null +++ b/crates/tinyvoice-module/examples/verify_module/relay_runtime.rs @@ -0,0 +1,285 @@ +//! Loaded-artifact relay proof with admitted native fixture work and mechanical callbacks. + +use std::{ + collections::{HashMap, HashSet, VecDeque}, + io, + sync::Arc, + time::Duration, +}; +use tinyvoice_bus::relay::runtime::{ + ApplyRelayEffectsReply, ApplyRelayEffectsRequest, FinishRelayBrainReply, ReadRelayBrainReply, + ReadRelayBrainRequest, RelayBrainEvent, RelayEffect, RelayHandleRequest, RelayHostPort, + RelayInvocationRequest, RelayLimits, RelayOutcome, RelayStartRequest, RelayState, RelayStatus, + StartRelayBrainReply, +}; +use tokio::sync::{Mutex, Notify}; +type CheckResult = Result>; + +#[derive(Clone, Default)] +struct NativeBrain { + expected: Arc>>, + tasks: Arc>>>, + events: Arc>>>, + retained: Arc>>, + effects: Arc>>>, + committed: Arc>>, + starts: Arc>, + completed: Arc, + continue_late: Arc, + finished: Arc>>, +} +#[tinybus::interface(name = "ai.tinyhumans.tinyvoice.RelayHost")] +impl NativeBrain { + async fn start_relay_brain( + &self, + request: RelayInvocationRequest, + ) -> tinybus::Result { + if self.expected.lock().await.get(&request.context_token) != Some(&request.invocation_id) + || request.context_token == "revoked" + || self.finished.lock().await.contains(&request.invocation_id) + { + return Ok(StartRelayBrainReply { started: false }); + } + let mut tasks = self.tasks.lock().await; + if tasks.contains_key(&request.invocation_id) { + return Ok(StartRelayBrainReply { started: true }); + } + *self.starts.lock().await += 1; + let context = request.context_token; + let events = self.events.clone(); + let continue_late = self.continue_late.clone(); + tasks.insert( + request.invocation_id, + tokio::spawn(async move { + if context == "delayed" { + continue_late.notified().await; + } + // Native host work computes its reply; the module only coordinates + // selected progress and completion. No tool execution is invented. + let words = ["module", " ", "relay"]; + let reply = words.concat(); + let mut events = events.lock().await; + let queue = events.entry(context).or_default(); + queue.extend(words.map(|text| RelayBrainEvent::Delta { text: text.into() })); + queue.push_back(RelayBrainEvent::Complete { + outcome: RelayOutcome::Success { text: reply }, + }); + }), + ); + Ok(StartRelayBrainReply { started: true }) + } + async fn read_relay_brain( + &self, + request: ReadRelayBrainRequest, + ) -> tinybus::Result { + if self.expected.lock().await.get(&request.context_token) != Some(&request.invocation_id) { + return Err(tinybus::Error::failed("unknown native fixture context")); + } + let mut retained = self.retained.lock().await; + if retained + .get(&request.context_token) + .is_some_and(|(sequence, _)| *sequence == request.acknowledged_event) + { + retained.remove(&request.context_token); + } + if !retained.contains_key(&request.context_token) + && let Some(event) = self + .events + .lock() + .await + .entry(request.context_token.clone()) + .or_default() + .pop_front() + { + retained.insert( + request.context_token.clone(), + (request.acknowledged_event + 1, event), + ); + } + Ok(match retained.get(&request.context_token) { + Some((sequence, event)) => ReadRelayBrainReply { + sequence: *sequence, + event: Some(event.clone()), + }, + None => ReadRelayBrainReply { + sequence: request.acknowledged_event, + event: None, + }, + }) + } + async fn apply_relay_effects( + &self, + request: ApplyRelayEffectsRequest, + ) -> tinybus::Result { + if self.expected.lock().await.get(&request.context_token) != Some(&request.invocation_id) { + return Ok(ApplyRelayEffectsReply { applied: false }); + } + let fresh = self + .committed + .lock() + .await + .insert((request.invocation_id, request.delivery_id)); + if fresh { + if request.context_token == "delayed" && request.effects.contains(&RelayEffect::Done) { + self.continue_late.notify_one(); + } + self.effects + .lock() + .await + .entry(request.context_token) + .or_default() + .extend(request.effects); + // Lose the first reply after effects commit. A retry must reuse its + // identity and must not duplicate an already committed delta. + if request.delivery_id == 1 { + return Err(tinybus::Error::failed("fixture lost effect acknowledgment")); + } + } + Ok(ApplyRelayEffectsReply { applied: true }) + } + async fn finish_relay_brain( + &self, + request: RelayInvocationRequest, + ) -> tinybus::Result { + if self.expected.lock().await.get(&request.context_token) != Some(&request.invocation_id) { + return Ok(FinishRelayBrainReply { finished: false }); + } + if let Some(mut task) = self.tasks.lock().await.remove(&request.invocation_id) { + // Native work is already complete on the happy path; cleanup also + // owns cancellation if the module ends this invocation early. + if !task.is_finished() { + task.abort(); + } + let _ = (&mut task).await; + } + self.finished.lock().await.insert(request.invocation_id); + self.completed.notify_one(); + Ok(FinishRelayBrainReply { finished: true }) + } +} +pub(super) async fn verify( + proxy: &tinybus::Proxy, + client: &tinybus::Connection, +) -> CheckResult<()> { + let brain = NativeBrain::default(); + client + .serve_at("/test/RelayHost".try_into()?, brain.clone()) + .await?; + client.request_name("ai.test.RelayHost").await?; + let limits = RelayLimits { + max_active_turns: 1, + max_pending_turns: 8, + }; + let configured: RelayLimits = proxy.call("RelayConfigure", (limits,)).await?; + if configured != limits { + return Err(io::Error::other("relay scheduling limits changed").into()); + } + for context in ["foreground", "delayed", "revoked"] { + verify_context(proxy, &brain, context).await?; + } + if *brain.starts.lock().await != 2 { + return Err( + io::Error::other("retry or revoked authority started another native brain").into(), + ); + } + let _: () = proxy.call("RelayShutdown", ()).await?; + if proxy.call::("RelayReserve", ()).await.is_ok() { + return Err(io::Error::other("relay shutdown reopened admission").into()); + } + Ok(()) +} + +async fn verify_context( + proxy: &tinybus::Proxy, + brain: &NativeBrain, + context: &str, +) -> CheckResult<()> { + let reserved: RelayStatus = proxy.call("RelayReserve", ()).await?; + brain + .expected + .lock() + .await + .insert(context.into(), reserved.handle.id.clone()); + let request = RelayStartRequest { + handle: reserved.handle.clone(), + port: RelayHostPort { + bus_name: "ai.test.RelayHost".into(), + object_path: "/test/RelayHost".into(), + context_token: context.into(), + }, + acknowledgment_deadline_ms: if context == "delayed" { 20 } else { 1000 }, + turn_timeout_ms: 3000, + empty_reply_text: "pause".into(), + deadline_reply_text: "working".into(), + lost_reply_text: "lost".into(), + }; + let _: RelayStatus = proxy.call("RelayStart", (request.clone(),)).await?; + let _: RelayStatus = proxy.call("RelayStart", (request,)).await?; + tokio::time::timeout(Duration::from_secs(5), brain.completed.notified()).await?; + let effects = brain + .effects + .lock() + .await + .get(context) + .cloned() + .unwrap_or_default(); + let expected = match context { + "foreground" => vec![ + RelayEffect::Delta { + text: "module".into(), + }, + RelayEffect::Delta { text: " ".into() }, + RelayEffect::Delta { + text: "relay".into(), + }, + RelayEffect::Done, + ], + "delayed" => vec![ + RelayEffect::Delta { + text: "working".into(), + }, + RelayEffect::Done, + RelayEffect::Delta { + text: "module".into(), + }, + RelayEffect::Delta { text: " ".into() }, + RelayEffect::Delta { + text: "relay".into(), + }, + RelayEffect::Deferred { + outcome: RelayOutcome::Success { + text: "module relay".into(), + }, + }, + ], + _ => vec![ + RelayEffect::LostBrain, + RelayEffect::Error { + message: "relay coordination failed".into(), + }, + ], + }; + if effects != expected { + return Err(io::Error::other(format!("relay effect order changed for {context}")).into()); + } + let released: RelayStatus = proxy + .call( + "RelayRelease", + (RelayHandleRequest { + handle: reserved.handle.clone(), + },), + ) + .await?; + if released.state != RelayState::Closed { + return Err(io::Error::other("relay release did not close").into()); + } + let _: RelayStatus = proxy + .call( + "RelayRelease", + (RelayHandleRequest { + 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..95bada7 --- /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, random_id}; 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..4d24854 --- /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(in crate::service) 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..12a8823 100644 --- a/crates/tinyvoice-module/src/service/mod.rs +++ b/crates/tinyvoice-module/src/service/mod.rs @@ -4,7 +4,9 @@ #[path = "mod_tests.rs"] mod test; +mod live; mod relay; +mod relay_runtime; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; @@ -58,6 +60,8 @@ pub struct VoiceService { sessions: std::sync::Mutex, capture: std::sync::Arc, hotkeys: std::sync::Arc, + live: std::sync::Arc, + relay: std::sync::Arc, } /// The session table and the counter that names its entries. @@ -187,6 +191,86 @@ fn to_json(value: &T) -> TinyBusResult { )] #[tinybus::interface(name = "ai.tinyhumans.tinyvoice.Voice")] impl VoiceService { + /// Fix explicit host scheduling bounds for this module instance. + async fn relay_configure( + &self, + limits: tinyvoice_bus::relay::runtime::RelayLimits, + ) -> TinyBusResult { + self.relay.configure(limits) + } + /// Reserve a bounded turn before native local-brain startup. + async fn relay_reserve(&self) -> TinyBusResult { + self.relay.reserve() + } + /// Start generic timing and progress coordination on a known reservation. + async fn relay_start( + &self, + request: tinyvoice_bus::relay::runtime::RelayStartRequest, + ) -> TinyBusResult { + self.relay.start(request) + } + /// Cancel one owned coordinator and notify current host invocation cleanup. + async fn relay_release( + &self, + request: tinyvoice_bus::relay::runtime::RelayHandleRequest, + ) -> TinyBusResult { + self.relay.release(request.handle).await + } + /// Close admission and stop all owned relay coordinators. + async fn relay_shutdown(&self) -> TinyBusResult<()> { + self.relay.shutdown().await + } + /// 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 +749,14 @@ 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)), + relay: std::sync::Arc::new(relay_runtime::Turns::with_connection(&connection)), + ..VoiceService::default() + }, + ) .await?; connection.request_name(names::BUS_NAME).await?; Ok(()) @@ -688,6 +779,19 @@ tinybus_module::module_export_optional_static! { "VadClose", "PrepareFrames", "PrepareRelayTurn", + "RelayConfigure", + "RelayReserve", + "RelayStart", + "RelayRelease", + "RelayShutdown", + "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..b794091 100644 --- a/crates/tinyvoice-module/src/service/mod_tests.rs +++ b/crates/tinyvoice-module/src/service/mod_tests.rs @@ -1059,3 +1059,124 @@ 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; +} + +#[tokio::test] +async fn relay_limits_are_configured_once_before_lease_admission() { + let proxy = connect().await.unwrap(); + let limits = serde_json::json!({"max_active_turns":3,"max_pending_turns":16}); + let first: serde_json::Value = proxy.call("RelayConfigure", &limits).await.unwrap(); + assert_eq!(first, limits); + let repeated: serde_json::Value = proxy.call("RelayConfigure", &limits).await.unwrap(); + assert_eq!(repeated, first); + let changed: tinybus::Result = proxy + .call( + "RelayConfigure", + serde_json::json!({"max_active_turns":2,"max_pending_turns":16}), + ) + .await; + assert!(changed.is_err()); + let lease: serde_json::Value = proxy.call("RelayReserve", ()).await.unwrap(); + assert_eq!(lease["state"], "reserved"); + assert_eq!(lease["handle"]["id"].as_str().unwrap().len(), 32); +} 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-module/src/service/relay_runtime/actor.rs b/crates/tinyvoice-module/src/service/relay_runtime/actor.rs new file mode 100644 index 0000000..8579e32 --- /dev/null +++ b/crates/tinyvoice-module/src/service/relay_runtime/actor.rs @@ -0,0 +1,354 @@ +//! Deadline coordination over retained selected progress and host-owned native work. + +use super::table::Lease; +use std::{future::Future, pin::Pin, sync::Arc, time::Duration}; +use tinyvoice_bus::relay::runtime::{ + APPLY_METHOD, ApplyRelayEffectsReply, ApplyRelayEffectsRequest, FINISH_METHOD, + FinishRelayBrainReply, INTERFACE, READ_METHOD, ReadRelayBrainReply, ReadRelayBrainRequest, + RelayBrainEvent, RelayEffect, RelayInvocationRequest, RelayOutcome, RelayStartRequest, + RelayState, START_METHOD, StartRelayBrainReply, +}; + +const CALLBACK_TIMEOUT: Duration = Duration::from_secs(1); +const MAX_TEXT: usize = 256 * 1024; +#[derive(Default)] +struct Delivery { + sequence: u64, + unacknowledged: bool, + expiry: Option, + terminal: Option>, +} + +type Incoming = Pin> + Send>>; + +/// Own foreground timing and background completion for one admitted turn. +pub(super) async fn run( + lease: Arc, + request: RelayStartRequest, + slots: Arc, + connection: tinybus::Connection, +) { + let Ok(proxy) = connection.proxy( + request.port.bus_name.as_str(), + request.port.object_path.as_str(), + INTERFACE, + ) else { + lease.cancel(); + return; + }; + let invocation = RelayInvocationRequest { + context_token: request.port.context_token.clone(), + invocation_id: request.handle.id.clone(), + }; + let mut finish = FinishGuard { + runtime: tokio::runtime::Handle::current(), + proxy: proxy.clone(), + request: Some(invocation.clone()), + slots: slots.clone(), + permit: None, + }; + let mut delivery = Delivery::default(); + let result = tokio::select! { + biased; + ()=lease.wait_cancelled()=>Ok(()), + result=coordinate(&lease,&request,&proxy,&invocation,slots,&mut finish.permit,&mut delivery)=>result, + }; + if result.is_err() && !delivery.unacknowledged { + // A missing completion is a fact, not an invented native reported error. + // The host applies answerability/delivery policy to this loss. + let _ = apply( + &proxy, + &invocation, + &mut delivery, + vec![ + RelayEffect::LostBrain, + RelayEffect::Error { + message: "relay coordination failed".into(), + }, + ], + ) + .await; + } + let confirmed = finish_host(&proxy, invocation.clone()).await; + finish.request = None; + if !confirmed && finish.permit.is_some() { + finish.slots.close(); + } + finish.permit.take(); + if let Some(effects) = delivery.terminal.take() { + // Native cleanup precedes this bounded final notification. The turn + // budget must never be extended while its brain is still executing. + delivery.expiry = None; + let _ = apply(&proxy, &invocation, &mut delivery, effects).await; + } + if let Ok(mut phase) = lease.phase.lock() { + phase.state = RelayState::Closed; + phase.cleanup_confirmed = confirmed; + } +} + +async fn coordinate( + lease: &Lease, + request: &RelayStartRequest, + proxy: &tinybus::Proxy, + invocation: &RelayInvocationRequest, + slots: Arc, + owned_permit: &mut Option, + delivery: &mut Delivery, +) -> tinybus::Result<()> { + let began = tokio::time::Instant::now(); + let deadline = began + Duration::from_millis(request.acknowledgment_deadline_ms); + let expiry = began + Duration::from_millis(request.turn_timeout_ms); + delivery.expiry = Some(expiry); + let mut foreground_closed = false; + let mut streamed = false; + let acquire = slots.acquire_owned(); + tokio::pin!(acquire); + *owned_permit = Some(loop { + tokio::select! { + biased; + permit=&mut acquire=>break permit.map_err(|_|tinybus::Error::failed("relay scheduler is closed"))?, + ()=tokio::time::sleep_until(deadline),if !foreground_closed=>{ + acknowledge(lease,request,proxy,invocation,delivery,streamed).await?;foreground_closed=true; + }, + ()=tokio::time::sleep_until(expiry)=>{lose(request,delivery,streamed,foreground_closed); return Ok(());}, + } + }); + + let admission = tokio::time::timeout( + CALLBACK_TIMEOUT, + proxy.call::(START_METHOD, (invocation.clone(),)), + ); + tokio::pin!(admission); + let started = loop { + tokio::select! { + biased; + result = &mut admission => break result.map_err(|_| tinybus::Error::failed("relay brain admission exceeded its deadline"))??, + () = tokio::time::sleep_until(deadline), if !foreground_closed => { + acknowledge(lease, request, proxy, invocation, delivery, streamed).await?; + foreground_closed = true; + }, + () = tokio::time::sleep_until(expiry) => {lose(request, delivery, streamed, foreground_closed); return Ok(());}, + } + }; + if !started.started { + return Err(tinybus::Error::failed( + "relay brain authority is unavailable", + )); + } + if let Ok(mut phase) = lease.phase.lock() { + phase.state = if foreground_closed { + RelayState::Deferred + } else { + RelayState::Running + }; + } + let mut acknowledged = 0_u64; + let mut incoming = read(proxy.clone(), invocation.clone(), acknowledged); + loop { + tokio::select! { + // Complete already retained progress before closing the foreground; + // the host appends completion only after its selected deltas. + biased; + reply=&mut incoming=>{ + let reply=reply?; + if !super::super::relay::fits(&reply,1024*1024) {return Err(tinybus::Error::failed("relay progress exceeds its serialized limit"));} + let Some(event)=reply.event else { + if reply.sequence!=acknowledged {return Err(tinybus::Error::failed("idle relay progress advanced its sequence"));} + tokio::time::sleep(Duration::from_millis(10)).await; + incoming=read(proxy.clone(),invocation.clone(),acknowledged);continue; + }; + if Some(reply.sequence)!=acknowledged.checked_add(1) {return Err(tinybus::Error::failed("relay progress sequence is not contiguous"));} + match event { + RelayBrainEvent::Delta {text}=>{ + if text.len()>MAX_TEXT {return Err(tinybus::Error::failed("relay delta exceeds its text limit"));} + if !text.is_empty() {apply(proxy,invocation,delivery,vec![RelayEffect::Delta {text}]).await?;streamed=true;} + }, + RelayBrainEvent::Complete {outcome}=>{ + validate_outcome(&outcome)?; + let effects=if foreground_closed {vec![RelayEffect::Deferred {outcome}]} else {complete(outcome,streamed,request)}; + apply(proxy,invocation,delivery,effects).await?;return Ok(()); + }, + } + acknowledged=reply.sequence;incoming=read(proxy.clone(),invocation.clone(),acknowledged); + }, + ()=tokio::time::sleep_until(deadline),if !foreground_closed=>{ + acknowledge(lease,request,proxy,invocation,delivery,streamed).await?;foreground_closed=true; + }, + ()=tokio::time::sleep_until(expiry)=>{lose(request,delivery,streamed,foreground_closed); return Ok(());}, + } + } +} +fn validate_outcome(outcome: &RelayOutcome) -> tinybus::Result<()> { + let text = match outcome { + RelayOutcome::Success { text } => text, + RelayOutcome::Failure { message } => message, + RelayOutcome::Lost => return Ok(()), + }; + if text.len() > MAX_TEXT { + return Err(tinybus::Error::failed( + "relay completion exceeds its text limit", + )); + } + Ok(()) +} +fn complete( + outcome: RelayOutcome, + streamed: bool, + request: &RelayStartRequest, +) -> Vec { + match outcome { + RelayOutcome::Success { text } => { + let mut effects = Vec::new(); + if !streamed { + let text = text.trim(); + effects.push(RelayEffect::Delta { + text: if text.is_empty() { + request.empty_reply_text.clone() + } else { + text.into() + }, + }); + } + effects.push(RelayEffect::Done); + effects + } + RelayOutcome::Failure { message } => vec![RelayEffect::Error { message }], + RelayOutcome::Lost => lost_effects(streamed, request), + } +} +fn lost_effects(streamed: bool, request: &RelayStartRequest) -> Vec { + let mut effects = vec![RelayEffect::LostBrain]; + if !streamed { + effects.push(RelayEffect::Delta { + text: request.lost_reply_text.clone(), + }); + } + effects.push(RelayEffect::Done); + effects +} +fn lose( + request: &RelayStartRequest, + delivery: &mut Delivery, + streamed: bool, + foreground_closed: bool, +) { + let effects = if foreground_closed { + vec![RelayEffect::Deferred { + outcome: RelayOutcome::Lost, + }] + } else { + lost_effects(streamed, request) + }; + delivery.terminal = Some(effects); +} +async fn acknowledge( + lease: &Lease, + request: &RelayStartRequest, + proxy: &tinybus::Proxy, + invocation: &RelayInvocationRequest, + delivery: &mut Delivery, + streamed: bool, +) -> tinybus::Result<()> { + let mut effects = Vec::new(); + if !streamed { + effects.push(RelayEffect::Delta { + text: request.deadline_reply_text.clone(), + }); + } + effects.push(RelayEffect::Done); + apply(proxy, invocation, delivery, effects).await?; + if let Ok(mut phase) = lease.phase.lock() { + phase.state = RelayState::Deferred; + } + Ok(()) +} +fn read(proxy: tinybus::Proxy, invocation: RelayInvocationRequest, acknowledged: u64) -> Incoming { + Box::pin(async move { + tokio::time::timeout( + CALLBACK_TIMEOUT, + proxy.call( + READ_METHOD, + (ReadRelayBrainRequest { + context_token: invocation.context_token, + invocation_id: invocation.invocation_id, + acknowledged_event: acknowledged, + },), + ), + ) + .await + .map_err(|_| tinybus::Error::failed("relay progress callback exceeded its deadline"))? + }) +} +async fn apply( + proxy: &tinybus::Proxy, + invocation: &RelayInvocationRequest, + delivery: &mut Delivery, + effects: Vec, +) -> tinybus::Result<()> { + if effects.is_empty() { + return Ok(()); + } + delivery.sequence = delivery + .sequence + .checked_add(1) + .ok_or_else(|| tinybus::Error::failed("relay delivery sequence is exhausted"))?; + let request = ApplyRelayEffectsRequest { + context_token: invocation.context_token.clone(), + invocation_id: invocation.invocation_id.clone(), + delivery_id: delivery.sequence, + effects, + }; + if !super::super::relay::fits(&request, 1024 * 1024) { + return Err(tinybus::Error::failed( + "relay effect group exceeds its serialized limit", + )); + } + delivery.unacknowledged = true; + for attempt in 0..2 { + if attempt > 0 + && delivery + .expiry + .is_some_and(|expiry| tokio::time::Instant::now() >= expiry) + { + break; + } + if matches!(tokio::time::timeout_at(delivery.expiry.map_or(tokio::time::Instant::now() + CALLBACK_TIMEOUT, |expiry| expiry.min(tokio::time::Instant::now() + CALLBACK_TIMEOUT)),proxy.call::(APPLY_METHOD,(request.clone(),))).await,Ok(Ok(reply)) if reply.applied) + { + delivery.unacknowledged = false; + return Ok(()); + } + } + Err(tinybus::Error::failed( + "relay effects were not acknowledged", + )) +} +async fn finish_host(proxy: &tinybus::Proxy, request: RelayInvocationRequest) -> bool { + matches!(tokio::time::timeout(CALLBACK_TIMEOUT,proxy.call::(FINISH_METHOD,(request,))).await,Ok(Ok(reply)) if reply.finished) +} +struct FinishGuard { + runtime: tokio::runtime::Handle, + proxy: tinybus::Proxy, + request: Option, + slots: Arc, + permit: Option, +} +impl Drop for FinishGuard { + fn drop(&mut self) { + if let Some(request) = self.request.take() { + let proxy = self.proxy.clone(); + let slots = self.slots.clone(); + let permit = self.permit.take(); + self.runtime.spawn(async move { + if !finish_host(&proxy, request).await && permit.is_some() { + slots.close(); + } + drop(permit); + }); + } + } +} + +#[cfg(test)] +#[path = "actor_tests.rs"] +mod tests; diff --git a/crates/tinyvoice-module/src/service/relay_runtime/actor_tests.rs b/crates/tinyvoice-module/src/service/relay_runtime/actor_tests.rs new file mode 100644 index 0000000..3d32e65 --- /dev/null +++ b/crates/tinyvoice-module/src/service/relay_runtime/actor_tests.rs @@ -0,0 +1,793 @@ +//! Native bus fixtures pin scheduling clocks, ordered effects and current authority. + +use super::*; +use std::collections::{HashMap, HashSet, VecDeque}; +use tinybus::{broker::Broker, transport::memory::MemoryBus}; +use tinyvoice_bus::relay::runtime::{RelayHandle, RelayHostPort, RelayLimits}; +use tokio::sync::{Mutex, Notify}; + +#[derive(Clone, Default)] +enum EffectMode { + #[default] + Confirm, + LoseAcknowledgment, + Pending, +} +#[derive(Clone, Default)] +enum FinishMode { + #[default] + Confirm, + WaitForRelease, + Pending, +} + +#[derive(Clone, Default)] +struct Brain { + authorities: Arc>>, + invocations: Arc>>, + events: Arc>>>, + retained: Arc>>, + acknowledged: Arc>>, + effects: Arc>>>, + committed: Arc>>, + finishes: Arc>>, + entered: Arc, + applied: Arc, + finished: Arc, + admission_cancel: Arc, + admission_exited: Arc, + admission_stopped: Arc, + effect_mode: EffectMode, + finish_entered: Arc, + finish_release: Arc, + finish_mode: FinishMode, + hold_start: bool, + + reject_authority: bool, +} +#[tinybus::interface(name = "ai.tinyhumans.tinyvoice.RelayHost")] +impl Brain { + async fn start_relay_brain( + &self, + request: RelayInvocationRequest, + ) -> tinybus::Result { + if self.authorities.lock().await.get(&request.context_token) != Some(&request.invocation_id) + || self.reject_authority + { + return Ok(StartRelayBrainReply { started: false }); + } + if self.finishes.lock().await.contains(&request.invocation_id) { + return Ok(StartRelayBrainReply { started: false }); + } + self.invocations + .lock() + .await + .entry(request.context_token) + .or_insert(request.invocation_id); + self.entered.notify_one(); + if self.hold_start { + self.admission_cancel.notified().await; + self.admission_stopped + .store(true, std::sync::atomic::Ordering::SeqCst); + self.admission_exited.notify_one(); + return Err(tinybus::Error::failed("fixture native admission cancelled")); + } + Ok(StartRelayBrainReply { started: true }) + } + async fn read_relay_brain( + &self, + request: ReadRelayBrainRequest, + ) -> tinybus::Result { + if self.invocations.lock().await.get(&request.context_token) != Some(&request.invocation_id) + { + return Err(tinybus::Error::failed("fixture unknown invocation")); + } + let mut retained = self.retained.lock().await; + if retained + .get(&request.context_token) + .is_some_and(|(sequence, _)| *sequence == request.acknowledged_event) + { + retained.remove(&request.context_token); + self.acknowledged + .lock() + .await + .insert(request.context_token.clone(), request.acknowledged_event); + } + if !retained.contains_key(&request.context_token) + && let Some(event) = self + .events + .lock() + .await + .entry(request.context_token.clone()) + .or_default() + .pop_front() + { + retained.insert( + request.context_token.clone(), + (request.acknowledged_event + 1, event), + ); + } + Ok(match retained.get(&request.context_token) { + Some((sequence, event)) => ReadRelayBrainReply { + sequence: *sequence, + event: Some(event.clone()), + }, + None => ReadRelayBrainReply { + sequence: request.acknowledged_event, + event: None, + }, + }) + } + async fn apply_relay_effects( + &self, + request: ApplyRelayEffectsRequest, + ) -> tinybus::Result { + if self.authorities.lock().await.get(&request.context_token) != Some(&request.invocation_id) + { + return Ok(ApplyRelayEffectsReply { applied: false }); + } + if self + .committed + .lock() + .await + .insert((request.invocation_id, request.delivery_id)) + { + self.effects + .lock() + .await + .entry(request.context_token) + .or_default() + .extend(request.effects); + } + self.applied.notify_one(); + if matches!(self.effect_mode, EffectMode::Pending) { + std::future::pending::<()>().await; + } + if matches!(self.effect_mode, EffectMode::LoseAcknowledgment) { + return Err(tinybus::Error::failed("fixture lost commit acknowledgment")); + } + Ok(ApplyRelayEffectsReply { applied: true }) + } + async fn finish_relay_brain( + &self, + request: RelayInvocationRequest, + ) -> tinybus::Result { + if self.authorities.lock().await.get(&request.context_token) != Some(&request.invocation_id) + { + return Ok(FinishRelayBrainReply { finished: false }); + } + self.finish_entered.notify_one(); + if matches!(self.finish_mode, FinishMode::WaitForRelease) { + self.finish_release.notified().await; + } + if matches!(self.finish_mode, FinishMode::Pending) { + std::future::pending::<()>().await; + } + if self.hold_start + && !self + .admission_stopped + .load(std::sync::atomic::Ordering::SeqCst) + { + self.admission_cancel.notify_one(); + self.admission_exited.notified().await; + } + self.finishes.lock().await.insert(request.invocation_id); + self.finished.notify_one(); + Ok(FinishRelayBrainReply { finished: true }) + } +} +async fn fixture( + brain: Brain, + active: u16, +) -> tinybus::Result<( + super::super::table::Turns, + tinybus::Connection, + tinybus::Connection, +)> { + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let host = tinybus::Connection::connect(bus.connect().await?).await?; + host.serve_at("/test/Relay".try_into()?, brain).await?; + host.request_name("ai.test.Relay").await?; + let module = tinybus::Connection::connect(bus.connect().await?).await?; + let turns = super::super::table::Turns::with_connection(&module); + turns.configure(RelayLimits { + max_active_turns: active, + max_pending_turns: 16, + })?; + Ok((turns, module, host)) +} +fn request(handle: RelayHandle, context: &str, deadline: u64) -> RelayStartRequest { + RelayStartRequest { + handle, + port: RelayHostPort { + bus_name: "ai.test.Relay".into(), + object_path: "/test/Relay".into(), + context_token: context.into(), + }, + acknowledgment_deadline_ms: deadline, + turn_timeout_ms: 10_000, + empty_reply_text: "pause".into(), + deadline_reply_text: "working".into(), + lost_reply_text: "lost".into(), + } +} +async fn wait_effect(brain: &Brain, context: &str) -> tinybus::Result> { + tokio::time::timeout(Duration::from_secs(1), async { + loop { + let effects = brain + .effects + .lock() + .await + .get(context) + .cloned() + .unwrap_or_default(); + if !effects.is_empty() { + return effects; + } + brain.applied.notified().await; + } + }) + .await + .map_err(|_| tinybus::Error::failed("fixture effect deadline")) +} + +#[tokio::test(start_paused = true)] +async fn acknowledgment_deadline_remains_live_while_the_host_start_callback_is_pending() +-> tinybus::Result<()> { + let brain = Brain { + hold_start: true, + ..Brain::default() + }; + let (turns, _module, _host) = fixture(brain.clone(), 1).await?; + let handle = turns.reserve()?.handle; + start(&turns, &brain, request(handle.clone(), "pending", 100)).await?; + brain.entered.notified().await; + tokio::time::advance(Duration::from_millis(100)).await; + let effects = tokio::time::timeout(Duration::from_millis(350), wait_effect(&brain, "pending")) + .await + .map_err(|_| tinybus::Error::failed("acknowledgment waited for native admission"))??; + assert_eq!( + effects, + [ + RelayEffect::Delta { + text: "working".into() + }, + RelayEffect::Done + ] + ); + turns.release(handle).await?; + assert!( + brain + .admission_stopped + .load(std::sync::atomic::Ordering::SeqCst) + ); + Ok(()) +} + +#[tokio::test] +async fn selected_deltas_preserve_whitespace_and_precede_done_without_whole_reply_duplication() +-> tinybus::Result<()> { + let brain = Brain::default(); + brain.events.lock().await.insert( + "stream".into(), + VecDeque::from([ + RelayBrainEvent::Delta { text: "one".into() }, + RelayBrainEvent::Delta { text: " ".into() }, + RelayBrainEvent::Delta { text: "two".into() }, + RelayBrainEvent::Complete { + outcome: RelayOutcome::Success { + text: "whole reply".into(), + }, + }, + ]), + ); + let (turns, _module, _host) = fixture(brain.clone(), 1).await?; + let handle = turns.reserve()?.handle; + let request = request(handle.clone(), "stream", 1000); + start(&turns, &brain, request.clone()).await?; + turns.start(request)?; + tokio::time::timeout(Duration::from_secs(1), brain.finished.notified()) + .await + .map_err(|_| tinybus::Error::failed("fixture finish deadline"))?; + assert_eq!( + brain.effects.lock().await["stream"], + [ + RelayEffect::Delta { text: "one".into() }, + RelayEffect::Delta { text: " ".into() }, + RelayEffect::Delta { text: "two".into() }, + RelayEffect::Done, + ] + ); + assert_eq!(brain.invocations.lock().await.len(), 1); + turns.release(handle.clone()).await?; + turns.release(handle).await?; + Ok(()) +} + +#[tokio::test(start_paused = true)] +async fn queued_turn_acknowledges_before_a_native_execution_slot_becomes_available() +-> tinybus::Result<()> { + let brain = Brain::default(); + let (turns, _module, _host) = fixture(brain.clone(), 1).await?; + let first = turns.reserve()?.handle; + start(&turns, &brain, request(first.clone(), "first", 5000)).await?; + brain.entered.notified().await; + let second = turns.reserve()?.handle; + start(&turns, &brain, request(second.clone(), "second", 100)).await?; + tokio::time::advance(Duration::from_millis(100)).await; + assert_eq!( + wait_effect(&brain, "second").await?, + [ + RelayEffect::Delta { + text: "working".into() + }, + RelayEffect::Done + ] + ); + assert!(!brain.invocations.lock().await.contains_key("second")); + turns.release(first).await?; + brain.entered.notified().await; + brain.events.lock().await.insert( + "second".into(), + VecDeque::from([ + RelayBrainEvent::Delta { + text: "late".into(), + }, + RelayBrainEvent::Complete { + outcome: RelayOutcome::Success { + text: "answer".into(), + }, + }, + ]), + ); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if brain + .effects + .lock() + .await + .get("second") + .is_some_and(|effects| { + effects + .iter() + .any(|effect| matches!(effect, RelayEffect::Deferred { .. })) + }) + { + break; + } + brain.applied.notified().await; + } + }) + .await + .map_err(|_| tinybus::Error::failed("fixture deferred deadline"))?; + assert_eq!( + brain.effects.lock().await["second"], + [ + RelayEffect::Delta { + text: "working".into() + }, + RelayEffect::Done, + RelayEffect::Delta { + text: "late".into() + }, + RelayEffect::Deferred { + outcome: RelayOutcome::Success { + text: "answer".into() + } + }, + ] + ); + turns.release(second).await?; + Ok(()) +} + +/// The host binds the returned module lease to its existing saved context. +/// Neither a callback nor a provider-selected id can manufacture authority. +async fn start( + turns: &super::super::table::Turns, + brain: &Brain, + request: RelayStartRequest, +) -> tinybus::Result<()> { + brain.authorities.lock().await.insert( + request.port.context_token.clone(), + request.handle.id.clone(), + ); + turns.start(request)?; + Ok(()) +} + +#[tokio::test] +async fn native_completion_fallback_failure_and_lost_work_preserve_foreground_effects() +-> tinybus::Result<()> { + for (outcome, expected) in [ + ( + RelayOutcome::Success { text: " ".into() }, + vec![ + RelayEffect::Delta { + text: "pause".into(), + }, + RelayEffect::Done, + ], + ), + ( + RelayOutcome::Success { + text: " answer ".into(), + }, + vec![ + RelayEffect::Delta { + text: "answer".into(), + }, + RelayEffect::Done, + ], + ), + ( + RelayOutcome::Failure { + message: "reported failure".into(), + }, + vec![RelayEffect::Error { + message: "reported failure".into(), + }], + ), + ( + RelayOutcome::Lost, + vec![ + RelayEffect::LostBrain, + RelayEffect::Delta { + text: "lost".into(), + }, + RelayEffect::Done, + ], + ), + ] { + let brain = Brain::default(); + brain.events.lock().await.insert( + "case".into(), + VecDeque::from([ + RelayBrainEvent::Delta { + text: String::new(), + }, + RelayBrainEvent::Complete { outcome }, + ]), + ); + let (turns, _module, _host) = fixture(brain.clone(), 1).await?; + let handle = turns.reserve()?.handle; + start(&turns, &brain, request(handle.clone(), "case", 1000)).await?; + tokio::time::timeout(Duration::from_secs(1), brain.finished.notified()) + .await + .map_err(|_| tinybus::Error::failed("fixture finish deadline"))?; + assert_eq!(brain.effects.lock().await["case"], expected); + turns.release(handle).await?; + } + Ok(()) +} + +#[tokio::test(start_paused = true)] +async fn release_expires_unresponsive_host_cleanup_without_claiming_success() -> tinybus::Result<()> +{ + let brain = Brain { + finish_mode: FinishMode::Pending, + ..Brain::default() + }; + let (turns, _module, _host) = fixture(brain.clone(), 1).await?; + let handle = turns.reserve()?.handle; + start(&turns, &brain, request(handle.clone(), "known", 1000)).await?; + brain.entered.notified().await; + let began = tokio::time::Instant::now(); + assert!(turns.release(handle.clone()).await.is_err()); + assert!(began.elapsed() <= Duration::from_secs(2)); + assert!(brain.finishes.lock().await.is_empty()); + assert_eq!(turns.release(handle).await?.state, RelayState::Closed); + Ok(()) +} + +#[tokio::test] +async fn unknown_or_currently_revoked_native_authority_never_starts_an_executor() +-> tinybus::Result<()> { + let brain = Brain { + reject_authority: true, + ..Brain::default() + }; + let (turns, _module, _host) = fixture(brain.clone(), 1).await?; + let handle = turns.reserve()?.handle; + start(&turns, &brain, request(handle.clone(), "revoked", 1000)).await?; + tokio::time::timeout(Duration::from_secs(1), brain.finished.notified()) + .await + .map_err(|_| tinybus::Error::failed("fixture finish deadline"))?; + assert!(brain.invocations.lock().await.is_empty()); + assert!( + brain.effects.lock().await["revoked"] + .iter() + .any(|effect| matches!(effect, RelayEffect::Error { .. })) + ); + assert!( + !brain.effects.lock().await["revoked"] + .iter() + .any(|effect| matches!( + effect, + RelayEffect::Deferred { + outcome: RelayOutcome::Success { .. } + } + )) + ); + turns.release(handle).await?; + Ok(()) +} + +#[tokio::test] +async fn dropping_the_scheduler_notifies_only_the_existing_host_invocation() -> tinybus::Result<()> +{ + let brain = Brain::default(); + let (turns, _module, _host) = fixture(brain.clone(), 1).await?; + let handle = turns.reserve()?.handle; + start(&turns, &brain, request(handle.clone(), "drop", 1000)).await?; + brain.entered.notified().await; + drop(turns); + tokio::time::timeout(Duration::from_secs(1), brain.finished.notified()) + .await + .map_err(|_| tinybus::Error::failed("fixture drop cleanup deadline"))?; + assert_eq!(brain.finishes.lock().await.len(), 1); + assert!(brain.finishes.lock().await.contains(&handle.id)); + Ok(()) +} + +#[tokio::test] +async fn unknown_and_late_finish_cannot_create_or_revive_native_work() -> tinybus::Result<()> { + let brain = Brain::default(); + let (turns, module, _host) = fixture(brain.clone(), 1).await?; + let handle = turns.reserve()?.handle; + let request = request(handle.clone(), "known", 1000); + brain + .authorities + .lock() + .await + .insert("known".into(), handle.id.clone()); + let proxy = module.proxy("ai.test.Relay", "/test/Relay", INTERFACE)?; + let invocation = RelayInvocationRequest { + context_token: "known".into(), + invocation_id: handle.id.clone(), + }; + assert!(finish_host(&proxy, invocation.clone()).await); + assert!(finish_host(&proxy, invocation).await); + assert!( + !finish_host( + &proxy, + RelayInvocationRequest { + context_token: "unknown".into(), + invocation_id: handle.id.clone() + } + ) + .await + ); + assert!( + !finish_host( + &proxy, + RelayInvocationRequest { + context_token: "known".into(), + invocation_id: "invented".into() + } + ) + .await + ); + assert!(brain.invocations.lock().await.is_empty()); + assert_eq!(brain.finishes.lock().await.len(), 1); + // This known context is already finished; the fixture must not reopen it. + let reply: StartRelayBrainReply = proxy + .call( + START_METHOD, + (RelayInvocationRequest { + context_token: request.port.context_token, + invocation_id: handle.id.clone(), + },), + ) + .await?; + assert!(!reply.started); + turns.release(handle).await?; + Ok(()) +} + +#[tokio::test(start_paused = true)] +async fn overall_deadline_finishes_queued_and_active_turns_without_inventing_native_completion() +-> tinybus::Result<()> { + for queued in [false, true] { + let brain = Brain::default(); + let (turns, _module, _host) = fixture(brain.clone(), 1).await?; + let first = if queued { + let first = turns.reserve()?.handle; + start(&turns, &brain, request(first.clone(), "occupant", 5000)).await?; + brain.entered.notified().await; + Some(first) + } else { + None + }; + let handle = turns.reserve()?.handle; + let mut request = request(handle.clone(), "expiring", 10); + request.turn_timeout_ms = 100; + start(&turns, &brain, request).await?; + if !queued { + brain.entered.notified().await; + } + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_millis(10)).await; + let _ = wait_effect(&brain, "expiring").await?; + tokio::time::advance(Duration::from_millis(90)).await; + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if brain.finishes.lock().await.contains(&handle.id) + && brain + .effects + .lock() + .await + .get("expiring") + .is_some_and(|effects| effects.len() == 3) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .map_err(|_| tinybus::Error::failed("fixture overall expiry deadline"))?; + assert_eq!( + brain.effects.lock().await["expiring"], + [ + RelayEffect::Delta { + text: "working".into() + }, + RelayEffect::Done, + RelayEffect::Deferred { + outcome: RelayOutcome::Lost + }, + ] + ); + assert_eq!( + brain.invocations.lock().await.contains_key("expiring"), + !queued + ); + turns.release(handle).await?; + if let Some(first) = first { + turns.release(first).await?; + } + } + Ok(()) +} + +#[tokio::test(start_paused = true)] +async fn exhausted_commit_acknowledgments_do_not_emit_contradictory_new_effects() +-> tinybus::Result<()> { + let brain = Brain { + effect_mode: EffectMode::LoseAcknowledgment, + ..Brain::default() + }; + brain.events.lock().await.insert( + "committed".into(), + VecDeque::from([RelayBrainEvent::Complete { + outcome: RelayOutcome::Success { + text: "answer".into(), + }, + }]), + ); + let (turns, _module, _host) = fixture(brain.clone(), 1).await?; + let handle = turns.reserve()?.handle; + start(&turns, &brain, request(handle.clone(), "committed", 1000)).await?; + brain.finished.notified().await; + assert_eq!( + brain.effects.lock().await["committed"], + [ + RelayEffect::Delta { + text: "answer".into() + }, + RelayEffect::Done + ] + ); + assert_eq!(brain.committed.lock().await.len(), 1); + turns.release(handle).await?; + Ok(()) +} + +#[tokio::test(start_paused = true)] +async fn native_execution_slot_is_retained_until_finish_confirms_stopped_work() +-> tinybus::Result<()> { + let brain = Brain { + finish_mode: FinishMode::WaitForRelease, + ..Brain::default() + }; + brain.events.lock().await.insert( + "first".into(), + VecDeque::from([RelayBrainEvent::Complete { + outcome: RelayOutcome::Success { + text: "answer".into(), + }, + }]), + ); + let (turns, _module, _host) = fixture(brain.clone(), 1).await?; + let first = turns.reserve()?.handle; + start(&turns, &brain, request(first.clone(), "first", 1000)).await?; + brain.finish_entered.notified().await; + let second = turns.reserve()?.handle; + start(&turns, &brain, request(second.clone(), "second", 1000)).await?; + tokio::time::advance(Duration::from_millis(100)).await; + for _ in 0..20 { + tokio::task::yield_now().await; + } + assert!(!brain.invocations.lock().await.contains_key("second")); + brain.finish_release.notify_one(); + brain.finished.notified().await; + for _ in 0..100 { + if brain.invocations.lock().await.contains_key("second") { + break; + } + tokio::task::yield_now().await; + } + assert!(brain.invocations.lock().await.contains_key("second")); + turns.release(first).await?; + brain.finish_release.notify_one(); + turns.release(second).await?; + Ok(()) +} + +#[tokio::test(start_paused = true)] +async fn overall_deadline_cancels_stalled_effect_delivery_before_callback_retry_budget() +-> tinybus::Result<()> { + let brain = Brain { + effect_mode: EffectMode::Pending, + ..Brain::default() + }; + brain.events.lock().await.insert( + "deadline".into(), + VecDeque::from([RelayBrainEvent::Delta { + text: "selected".into(), + }]), + ); + let (turns, _module, _host) = fixture(brain.clone(), 1).await?; + let handle = turns.reserve()?.handle; + let mut req = request(handle.clone(), "deadline", 100); + req.turn_timeout_ms = 200; + start(&turns, &brain, req).await?; + brain.applied.notified().await; + tokio::time::timeout(Duration::from_millis(350), brain.finished.notified()) + .await + .map_err(|_| { + tinybus::Error::failed("overall deadline was suspended during effect delivery") + })?; + assert_eq!( + brain.effects.lock().await["deadline"], + [RelayEffect::Delta { + text: "selected".into() + }] + ); + turns.release(handle).await?; + Ok(()) +} + +#[tokio::test(start_paused = true)] +async fn unconfirmed_native_cleanup_closes_slots_instead_of_starting_queued_work() +-> tinybus::Result<()> { + let brain = Brain { + finish_mode: FinishMode::Pending, + ..Brain::default() + }; + brain.events.lock().await.insert( + "first".into(), + VecDeque::from([RelayBrainEvent::Complete { + outcome: RelayOutcome::Success { + text: "answer".into(), + }, + }]), + ); + let (turns, _module, _host) = fixture(brain.clone(), 1).await?; + let first = turns.reserve()?.handle; + start(&turns, &brain, request(first.clone(), "first", 1000)).await?; + brain.finish_entered.notified().await; + let second = turns.reserve()?.handle; + start(&turns, &brain, request(second.clone(), "second", 1000)).await?; + tokio::time::advance(Duration::from_secs(2)).await; + for _ in 0..50 { + tokio::task::yield_now().await; + } + assert!(!brain.invocations.lock().await.contains_key("second")); + assert!(brain.finishes.lock().await.is_empty()); + assert!(turns.release(first).await.is_err()); + let _ = turns.release(second).await; + Ok(()) +} diff --git a/crates/tinyvoice-module/src/service/relay_runtime/mod.rs b/crates/tinyvoice-module/src/service/relay_runtime/mod.rs new file mode 100644 index 0000000..5323b49 --- /dev/null +++ b/crates/tinyvoice-module/src/service/relay_runtime/mod.rs @@ -0,0 +1,6 @@ +//! Module-owned local-brain scheduling, acknowledgment and deferred completion. + +mod actor; +mod table; + +pub(super) use table::Turns; diff --git a/crates/tinyvoice-module/src/service/relay_runtime/table.rs b/crates/tinyvoice-module/src/service/relay_runtime/table.rs new file mode 100644 index 0000000..6883e5c --- /dev/null +++ b/crates/tinyvoice-module/src/service/relay_runtime/table.rs @@ -0,0 +1,350 @@ +//! Bounded relay reservations and owned worker teardown. + +use std::{ + collections::HashMap, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; +use tinyvoice_bus::relay::runtime::{ + RelayHandle, RelayLimits, RelayStartRequest, RelayState, RelayStatus, +}; +use tokio::{ + sync::{Notify, Semaphore}, + task::JoinHandle, +}; + +pub(super) struct Phase { + pub(super) state: RelayState, + request: Option, + task: Option>, + pub(super) cleanup_confirmed: bool, +} +pub(super) struct Lease { + pub(super) phase: Mutex, + cancelled: AtomicBool, + changed: Notify, + created: tokio::time::Instant, +} +impl Lease { + fn new() -> Self { + Self { + phase: Mutex::new(Phase { + state: RelayState::Reserved, + request: None, + task: None, + cleanup_confirmed: true, + }), + cancelled: AtomicBool::new(false), + changed: Notify::new(), + 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); + notified.as_mut().enable(); + if !self.cancelled.load(Ordering::SeqCst) { + notified.await; + } + } +} +struct Quota { + limits: RelayLimits, + slots: Arc, +} +#[derive(Default)] +struct State { + quota: Option>, + leases: HashMap>, +} + +/// One configured relay scheduler, independent of host model and correlation policy. +#[derive(Default)] +pub(in crate::service) struct Turns { + closed: AtomicBool, + state: Mutex, + connection: Option, +} +impl std::fmt::Debug for Turns { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Turns") + .field("closed", &self.closed) + .finish_non_exhaustive() + } +} +impl Turns { + pub(in crate::service) fn with_connection(connection: &tinybus::Connection) -> Self { + Self { + connection: Some(connection.downgrade()), + closed: AtomicBool::new(false), + state: Mutex::new(State::default()), + } + } + pub(in crate::service) fn configure( + &self, + limits: RelayLimits, + ) -> tinybus::Result { + if limits.max_active_turns == 0 + || limits.max_active_turns > 16 + || limits.max_pending_turns < limits.max_active_turns + || limits.max_pending_turns > 256 + { + return Err(tinybus::Error::failed( + "relay scheduling bounds are invalid", + )); + } + if self.closed.load(Ordering::SeqCst) { + return Err(tinybus::Error::failed("relay admission is closed")); + } + let mut state = self.state.lock().map_err(|_| unavailable())?; + if let Some(quota) = &state.quota { + return if quota.limits == limits { + Ok(limits) + } else { + Err(tinybus::Error::failed( + "relay scheduling bounds were already configured", + )) + }; + } + state.quota = Some(Arc::new(Quota { + limits, + slots: Arc::new(Semaphore::new(usize::from(limits.max_active_turns))), + })); + Ok(limits) + } + pub(in crate::service) fn reserve(&self) -> tinybus::Result { + let mut state = self.state.lock().map_err(|_| unavailable())?; + if self.closed.load(Ordering::SeqCst) { + return Err(tinybus::Error::failed("relay admission is closed")); + } + let limit = state + .quota + .as_ref() + .ok_or_else(|| { + tinybus::Error::failed("relay scheduling must be configured before reservation") + })? + .limits + .max_pending_turns; + state.leases.retain(|_, lease| { + lease.phase.lock().is_ok_and(|phase| { + let keep = phase.state != RelayState::Reserved + || lease.created.elapsed() < Duration::from_secs(60); + if !keep { + lease.cancel(); + } + keep + }) + }); + if state.leases.len() >= usize::from(limit) { + return Err(tinybus::Error::failed( + "relay reservation capacity is exhausted", + )); + } + loop { + let handle = RelayHandle { + id: super::super::live::random_id()?, + }; + if let std::collections::hash_map::Entry::Vacant(entry) = + state.leases.entry(handle.clone()) + { + entry.insert(Arc::new(Lease::new())); + return Ok(RelayStatus { + handle, + state: RelayState::Reserved, + }); + } + } + } + fn lease(&self, handle: &RelayHandle) -> tinybus::Result> { + self.state + .lock() + .map_err(|_| unavailable())? + .leases + .get(handle) + .cloned() + .ok_or_else(|| tinybus::Error::failed("unknown relay turn handle")) + } + pub(in crate::service) fn start( + &self, + request: RelayStartRequest, + ) -> tinybus::Result { + if !super::super::relay::fits(&request, 64 * 1024) + || request.acknowledgment_deadline_ms == 0 + || request.acknowledgment_deadline_ms > 60_000 + || request.turn_timeout_ms < request.acknowledgment_deadline_ms + || request.turn_timeout_ms > 1_800_000 + { + return Err(tinybus::Error::failed( + "relay startup timing or payload bounds are invalid", + )); + } + let connection = self + .connection + .as_ref() + .and_then(tinybus::WeakConnection::upgrade) + .ok_or_else(|| tinybus::Error::failed("relay callback connection is unavailable"))?; + connection.proxy( + request.port.bus_name.as_str(), + request.port.object_path.as_str(), + tinyvoice_bus::relay::runtime::INTERFACE, + )?; + let lease = self.lease(&request.handle)?; + let slots = self + .state + .lock() + .map_err(|_| unavailable())? + .quota + .as_ref() + .ok_or_else(unavailable)? + .slots + .clone(); + let mut phase = lease.phase.lock().map_err(|_| unavailable())?; + if let Some(previous) = &phase.request { + return if previous == &request { + Ok(RelayStatus { + handle: request.handle, + state: phase.state, + }) + } else { + Err(tinybus::Error::failed( + "relay turn was already started with different inputs", + )) + }; + } + if self.closed.load(Ordering::SeqCst) + || lease.cancelled.load(Ordering::SeqCst) + || phase.state != RelayState::Reserved + || lease.created.elapsed() >= Duration::from_secs(60) + { + return Err(tinybus::Error::failed( + "relay reservation is closed or expired", + )); + } + phase.state = RelayState::Queued; + phase.cleanup_confirmed = false; + phase.request = Some(request.clone()); + let handle = request.handle.clone(); + phase.task = Some(tokio::spawn(super::actor::run( + lease.clone(), + request, + slots, + connection, + ))); + Ok(RelayStatus { + handle, + state: RelayState::Queued, + }) + } + pub(in crate::service) async fn release( + &self, + handle: RelayHandle, + ) -> tinybus::Result { + let lease = self + .state + .lock() + .map_err(|_| unavailable())? + .leases + .remove(&handle); + if let Some(lease) = lease { + stop(&lease).await?; + } + Ok(RelayStatus { + handle, + state: RelayState::Closed, + }) + } + pub(in crate::service) async fn shutdown(&self) -> tinybus::Result<()> { + self.closed.store(true, Ordering::SeqCst); + let leases = { + let mut state = self.state.lock().map_err(|_| unavailable())?; + if let Some(quota) = &state.quota { + quota.slots.close(); + } + state + .leases + .drain() + .map(|(_, lease)| lease) + .collect::>() + }; + let mut pending = tokio::task::JoinSet::new(); + for lease in leases { + lease.cancel(); + pending.spawn(async move { stop(&lease).await }); + } + let mut failure = None; + while let Some(result) = pending.join_next().await { + match result { + Ok(Ok(())) => {} + Ok(Err(error)) => failure = Some(error), + Err(_) => failure = Some(unavailable()), + } + } + failure.map_or(Ok(()), Err) + } +} +struct OwnedTask(JoinHandle<()>); +impl Drop for OwnedTask { + fn drop(&mut self) { + self.0.abort(); + } +} +async fn stop(lease: &Lease) -> tinybus::Result<()> { + lease.cancel(); + let task = { + let mut phase = lease.phase.lock().map_err(|_| unavailable())?; + phase.state = RelayState::Closed; + phase.task.take().map(OwnedTask) + }; + if let Some(mut task) = task { + match tokio::time::timeout(Duration::from_secs(2), &mut task.0).await { + Ok(Ok(())) => {} + Ok(Err(_)) => return Err(unavailable()), + Err(_) => { + task.0.abort(); + let _ = (&mut task.0).await; + return Err(tinybus::Error::failed( + "relay cleanup exceeded its deadline", + )); + } + } + } + if !lease + .phase + .lock() + .map_err(|_| unavailable())? + .cleanup_confirmed + { + return Err(tinybus::Error::failed( + "relay host cleanup was not acknowledged", + )); + } + Ok(()) +} +impl Drop for Turns { + fn drop(&mut self) { + if let Ok(state) = self.state.get_mut() { + for lease in state.leases.values() { + lease.cancel(); + if let Ok(mut phase) = lease.phase.lock() + && let Some(task) = phase.task.take() + { + task.abort(); + } + } + } + } +} +fn unavailable() -> tinybus::Error { + tinybus::Error::failed("relay state is unavailable") +} + +#[cfg(test)] +#[path = "table_tests.rs"] +mod tests; diff --git a/crates/tinyvoice-module/src/service/relay_runtime/table_tests.rs b/crates/tinyvoice-module/src/service/relay_runtime/table_tests.rs new file mode 100644 index 0000000..0409f0d --- /dev/null +++ b/crates/tinyvoice-module/src/service/relay_runtime/table_tests.rs @@ -0,0 +1,192 @@ +//! Native reservation bounds and owned-worker cleanup fixtures. + +use super::*; + +fn limits(active: u16, pending: u16) -> RelayLimits { + RelayLimits { + max_active_turns: active, + max_pending_turns: pending, + } +} +fn request(handle: RelayHandle) -> RelayStartRequest { + RelayStartRequest { + handle, + port: tinyvoice_bus::relay::runtime::RelayHostPort { + bus_name: "ai.test.Relay".into(), + object_path: "/test/Relay".into(), + context_token: "known".into(), + }, + acknowledgment_deadline_ms: 100, + turn_timeout_ms: 1000, + empty_reply_text: "pause".into(), + deadline_reply_text: "working".into(), + lost_reply_text: "lost".into(), + } +} +async fn fixture() -> tinybus::Result<(Turns, tinybus::Connection)> { + let bus = tinybus::transport::memory::MemoryBus::new(); + tinybus::broker::Broker::new().spawn(bus.clone()); + let connection = tinybus::Connection::connect(bus.connect().await?).await?; + Ok((Turns::with_connection(&connection), connection)) +} + +#[tokio::test(start_paused = true)] +async fn reservations_require_valid_fixed_limits_and_expire_without_evicting_running_turns() +-> tinybus::Result<()> { + let turns = Turns::default(); + assert!(turns.reserve().is_err()); + assert!(format!("{turns:?}").contains("closed")); + for invalid in [limits(0, 1), limits(17, 17), limits(1, 257), limits(2, 1)] { + assert!(turns.configure(invalid).is_err()); + } + assert_eq!(turns.configure(limits(1, 2))?, limits(1, 2)); + assert_eq!(turns.configure(limits(1, 2))?, limits(1, 2)); + assert!(turns.configure(limits(2, 2)).is_err()); + let active = turns.reserve()?.handle; + turns + .lease(&active)? + .phase + .lock() + .map_err(|_| unavailable())? + .state = RelayState::Running; + turns.reserve()?; + assert!(turns.reserve().is_err()); + tokio::time::advance(Duration::from_secs(60)).await; + turns.reserve()?; + assert_eq!( + turns.state.lock().map_err(|_| unavailable())?.leases.len(), + 2 + ); + assert!(turns.lease(&active).is_ok()); + turns.shutdown().await?; + assert!(turns.configure(limits(1, 2)).is_err()); + assert!(turns.reserve().is_err()); + turns.shutdown().await +} + +#[tokio::test(start_paused = true)] +async fn relay_start_rejects_bad_timing_payload_changed_or_expired_inputs_before_host_work() +-> tinybus::Result<()> { + let (turns, _connection) = fixture().await?; + turns.configure(limits(1, 16))?; + let handle = turns.reserve()?.handle; + let base = request(handle.clone()); + for bad in [ + RelayStartRequest { + acknowledgment_deadline_ms: 0, + ..base.clone() + }, + RelayStartRequest { + acknowledgment_deadline_ms: 60_001, + ..base.clone() + }, + RelayStartRequest { + turn_timeout_ms: 1, + ..base.clone() + }, + RelayStartRequest { + turn_timeout_ms: 1_800_001, + ..base.clone() + }, + RelayStartRequest { + deadline_reply_text: "x".repeat(64 * 1024), + ..base.clone() + }, + ] { + assert!(turns.start(bad).is_err()); + } + let mut bad = base.clone(); + bad.port.object_path = "invalid path".into(); + assert!(turns.start(bad).is_err()); + assert_eq!(turns.start(base.clone())?.state, RelayState::Queued); + assert_eq!(turns.start(base.clone())?.state, RelayState::Queued); + let mut changed = base; + changed.lost_reply_text = "changed".into(); + assert!(turns.start(changed).is_err()); + turns.lease(&handle)?.cancel(); + assert!(turns.release(handle.clone()).await.is_err()); + assert_eq!(turns.release(handle).await?.state, RelayState::Closed); + let expired = turns.reserve()?.handle; + tokio::time::advance(Duration::from_secs(60)).await; + assert!(turns.start(request(expired.clone())).is_err()); + turns.release(expired).await?; + let no_connection = Turns::default(); + no_connection.configure(limits(1, 1))?; + let handle = no_connection.reserve()?.handle; + assert!(no_connection.start(request(handle)).is_err()); + Ok(()) +} + +struct Dropped(Arc); +impl Drop for Dropped { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } +} +fn block(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 relay_shutdown_joins_all_stalled_workers_under_one_cleanup_deadline() -> tinybus::Result<()> +{ + let turns = Turns::default(); + turns.configure(limits(1, 16))?; + let mut dropped = Vec::new(); + for _ in 0..3 { + let handle = turns.reserve()?.handle; + let guard = Arc::new(AtomicBool::new(false)); + block(turns.lease(&handle)?.as_ref(), guard.clone())?; + dropped.push(guard); + } + tokio::task::yield_now().await; + let before = tokio::time::Instant::now(); + assert!(turns.shutdown().await.is_err()); + assert_eq!(before.elapsed(), Duration::from_secs(2)); + assert!(dropped.iter().all(|guard| guard.load(Ordering::SeqCst))); + turns.shutdown().await +} +#[tokio::test] +async fn dropped_release_future_and_scheduler_abort_their_owned_worker() -> tinybus::Result<()> { + let turns = Arc::new(Turns::default()); + turns.configure(limits(1, 16))?; + let handle = turns.reserve()?.handle; + let lease = turns.lease(&handle)?; + let dropped = Arc::new(AtomicBool::new(false)); + block(&lease, dropped.clone())?; + tokio::task::yield_now().await; + let release_turns = turns.clone(); + let release = tokio::spawn(async move { release_turns.release(handle).await }); + tokio::time::timeout(Duration::from_secs(1), async { + while !lease.cancelled.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .map_err(|_| tinybus::Error::failed("fixture cancel deadline"))?; + release.abort(); + assert!(release.await.is_err()); + tokio::time::timeout(Duration::from_secs(1), async { + while !dropped.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .map_err(|_| tinybus::Error::failed("fixture worker drop deadline"))?; + let handle = turns.reserve()?.handle; + let dropped = Arc::new(AtomicBool::new(false)); + block(turns.lease(&handle)?.as_ref(), dropped.clone())?; + tokio::task::yield_now().await; + drop(turns); + tokio::time::timeout(Duration::from_secs(1), async { + while !dropped.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .map_err(|_| tinybus::Error::failed("fixture scheduler drop deadline"))?; + 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..1a777bf --- /dev/null +++ b/docs/plans/live-module-runtime.md @@ -0,0 +1,23 @@ +# 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. The separate relay runtime owner follow-up implements the portable queue, + acknowledgment and completion coordinator through saved-context host ports. + Its contract and regression proof are documented in + [relay module runtime](../specs/relay-module-runtime.md). Product model, + progress filtering and delivery choices remain host-owned. +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/README.md b/docs/specs/README.md index b54ae15..e153142 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -27,3 +27,7 @@ The accepted hotkey lifecycle is specified in Relay turn preparation is documented in [`relay-turn-preparation.md`](relay-turn-preparation.md). + +The module-owned relay queue, acknowledgment deadlines and saved native +context callbacks are specified in +[`relay-module-runtime.md`](relay-module-runtime.md). 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..d250c4e 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 47 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.8 (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/docs/specs/relay-module-runtime.md b/docs/specs/relay-module-runtime.md new file mode 100644 index 0000000..8ca35bf --- /dev/null +++ b/docs/specs/relay-module-runtime.md @@ -0,0 +1,96 @@ +# Compiled relay turn coordination + +The compiled voice module owns generic execution-slot scheduling, selected +progress polling, foreground acknowledgment and deferred completion. The host +retains its local brain, model/thread selection, progress eligibility, +correlation admission, spoken phrases, failure-notice eligibility and product +destinations. The module never obtains a credential or fetches a task source. + +Configure explicit concurrency and reservation limits with RelayConfigure. +Configuration is idempotent for identical limits and fixed for the module +instance. Active turns range from one to sixteen; pending/retained leases +range from the active limit to 256. RelayShutdown permanently closes +admission. Reserved leases expire after sixty seconds without evicting +active turns. + +RelayReserve returns an unpredictable opaque handle. Before RelayStart, the +host binds that handle to an existing saved turn context. Every RelayHost +callback verifies the context/handle pair, including effects emitted while +queued and cleanup before execution starts. Request fields never manufacture +authority. A declaration or reservation never grants a native executor. + +RelayStart accepts the callback endpoint, an acknowledgment deadline, an +overall timeout and explicit empty/deadline/lost spoken fallback text. +Identical retries retain the existing task; changed requests fail. Startup +JSON is at most 64 KiB. The acknowledgment deadline is positive and at most +sixty seconds; the overall timeout includes queue/admission time, must cover +the acknowledgment deadline and is at most thirty minutes. + +## Host callbacks + +The fixed interface is ai.tinyhumans.tinyvoice.RelayHost. + +| Method | Responsibility | +| --- | --- | +| StartRelayBrain | Start the saved native brain once, returning false for unknown or currently revoked authority. Repeating a known invocation never creates another brain. | +| ReadRelayBrain | Return an idle poll or the retained next selected delta/completion. Acknowledge only the last completely applied event. Native completion follows all preceding selected deltas. | +| ApplyRelayEffects | Apply one ordered group and remember its delivery ID within the known invocation. Lost replies can retry without repeating committed effects. | +| FinishRelayBrain | Stop only the known invocation and its owned work; repeated known cleanup succeeds, unknown cleanup returns false and never revives work. | + +A callback waits at most one second. Idle reads return promptly rather than +waiting for the whole native turn. Remote TinyBus timeout does not cancel work +already running on the host: the host owns invocation leases and must stop +them on finish and endpoint teardown. Finish reports true only when its known +work has stopped. Do not synchronously call RelayRelease from a finish +callback that the coordinator is awaiting; release after replying or schedule +mechanical cleanup without blocking the reply. + +The acknowledgment clock starts before waiting for an execution slot and +stays live while native admission is pending. A slow turn emits the explicit +host acknowledgment only if no selected text has streamed, then emits done. +Native work continues within the overall deadline. Late selected deltas +retain their existing ordering; a late completion emits a deferred outcome +for the host to apply its own chat/readback policy. + +A timely success emits the trimmed whole reply only if no selected delta was +streamed; empty replies use the explicit host fallback. Otherwise selected +deltas precede done without a duplicate whole reply. Whitespace deltas remain +significant; truly empty deltas are skipped. A reported native failure emits +an error. Lost native work emits a loss fact and the supplied fallback/done +when foreground is still open. A coordination failure reports bounded +generic error/loss facts without inventing a native result or successful +execution. + +Progress JSON and effect groups are at most one MiB; individual selected +deltas and completion text are at most 256 KiB. Apply retries at most twice +with the same delivery ID and within the remaining turn budget. Exhausted +acknowledgments are indeterminate: no new contradictory failure effects are +sent after a possibly committed group. These bounds refuse new/unacknowledged work rather +than pretending it committed. + +## Lifecycle and verification + +The execution slot remains owned until native finish completes. If native +cleanup cannot be confirmed, execution-slot admission closes instead of +starting more work while the earlier brain may still be running. Final +overall-timeout notifications follow native cleanup under their separate +bounded callback budget; the host keeps the registered context available for +these terminal effects after finish stops the native work. + +Release owns its worker join under a two-second deadline. Dropping a release +future aborts the worker rather than detaching it. Shutdown joins cleanups +concurrently; module drop aborts owned coordinators and sends bounded +best-effort finish notifications through the original runtime handle. Cleanup +failure remains observable instead of becoming an invented success. + +Native deterministic fixtures cover acknowledgment before a slot is +available, acknowledgment while admission is pending, selected delta order, +deferred completion, current authority, reservation bounds and cancellation. +The real loaded-module example runs a native host fixture task, verifies +ordered effects and lost-reply deduplication, and proves a revoked context +starts no native task. + +This is separate from the live provider/socket runtime in +[live-module-runtime.md](live-module-runtime.md). Source prerequisites must +land and an actual released, admitted module must exist before OpenHuman +adopts either runtime; no host release pin changes here. diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh index 2ca5592..54ff0ae 100755 --- a/scripts/check-coverage.sh +++ b/scripts/check-coverage.sh @@ -3,3 +3,4 @@ set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" cd "$root" .github/scripts/check-file-coverage.sh 90 coverage.json +.github/scripts/check-live-module-coverage.sh 90 live-module-coverage.json diff --git a/scripts/test-features.sh b/scripts/test-features.sh index c215ed0..861fc3d 100755 --- a/scripts/test-features.sh +++ b/scripts/test-features.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/.." +.github/scripts/test-release-version-bump.sh python3 scripts/test-e2e-version.py python3 scripts/test-native-portability.py cargo test --locked --workspace 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