//! RufloRunner trait + runner implementations. //! //! The ruflo agent is a Node.js process that exposes an MCP-over-stdio //! interface for LLM-grade intent disambiguation. HOMECORE-ASSIST manages //! a long-lived subprocess via `tokio::process::Child`. //! //! ## Runners //! //! - [`LocalRunner`] — the real, dependency-free response path. It runs an //! actual [`IntentRecognizer`](crate::recognizer::IntentRecognizer) over the //! incoming utterance and returns a fully-formed [`RufloResponse`] with the //! resolved intent and a spoken acknowledgement. No external process — this //! is the honest production path when no `ruflo-agent.js` is installed. //! - [`NoopRunner`] — an explicit, honest no-op. Before `spawn`, `send_request` //! returns a typed [`AssistError::NotStarted`]; after `spawn`, it returns an //! *empty-but-typed* [`RufloResponse`] so the pipeline can legitimately fall //! through to its regex recognizer. It never pretends an absent LLM answered. //! //! ## Subprocess runner (data-gated) //! //! A real `node ruflo-agent.js` subprocess runner with Windows-safe teardown //! (ADR-133 §Q3) is genuinely gated on the `ruflo-agent.js` script existing on //! disk. When that script is absent, [`LocalRunner`] is the honest path — it //! resolves intents locally rather than fabricating a subprocess response. use std::sync::Arc; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::intent::Intent; use crate::recognizer::IntentRecognizer; /// Error type for the assist pipeline (runner + pipeline-level errors). #[derive(Error, Debug)] pub enum AssistError { #[error("runner not started")] NotStarted, #[error("runner IO error: {0}")] Io(String), #[error("runner response parse error: {0}")] ParseError(String), #[error("recognizer error: {0}")] Recognizer(#[from] crate::recognizer::RecognizerError), #[error("handler error: {0}")] Handler(#[from] crate::handler::HandlerError), #[error("no handler registered for intent: {0}")] NoHandler(String), } /// Configuration for launching the ruflo agent subprocess. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct RufloRunnerOpts { /// Path to the `ruflo-agent.js` entry point. pub script_path: String, /// Additional environment variables to pass to the subprocess. pub env: std::collections::HashMap, /// Request timeout in milliseconds (default 5000). pub timeout_ms: u64, } impl Default for RufloRunnerOpts { fn default() -> Self { Self { script_path: "ruflo-agent.js".into(), env: Default::default(), timeout_ms: 5000, } } } /// JSON response from the ruflo agent subprocess. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct RufloResponse { /// Recognised intent, if the LLM resolved one. pub intent: Option, /// Spoken text from the LLM, if any. pub speech: Option, } /// Trait for the ruflo agent runner. /// /// Implemented by [`LocalRunner`] (real recognizer-backed resolution) and /// [`NoopRunner`] (honest no-op). A live `node ruflo-agent.js` subprocess /// runner with Windows-safe teardown (ADR-133 §Q3) is the data-gated future /// implementation. #[async_trait] pub trait RufloRunner: Send + Sync + 'static { /// Spawn (or reconnect to) the ruflo agent subprocess. async fn spawn(&mut self, opts: RufloRunnerOpts) -> Result<(), AssistError>; /// Send an utterance payload to the agent and await a response. /// /// `payload` is an arbitrary JSON object; at minimum it should include /// `{ "utterance": "...", "language": "..." }`. async fn send_request( &self, payload: serde_json::Value, ) -> Result; /// Gracefully shut down the subprocess. /// /// Must be idempotent — calling `shutdown` on an already-stopped runner /// must return `Ok(())` rather than an error. async fn shutdown(&mut self) -> Result<(), AssistError>; } /// Honest no-op implementation. /// /// `NoopRunner` spawns no subprocess. It is *honest* about state: /// - Calling `send_request` **before** `spawn` returns /// [`AssistError::NotStarted`] — not a silent empty response. /// - After `spawn`, `send_request` returns an empty-but-typed /// [`RufloResponse`] (`intent: None`), which the pipeline reads as an /// explicit "no LLM opinion" signal and legitimately falls through to its /// regex recognizer. /// /// Use [`LocalRunner`] when you want a runner that actually resolves intents. #[derive(Default)] pub struct NoopRunner { started: bool, } impl NoopRunner { pub fn new() -> Self { Self { started: false } } } #[async_trait] impl RufloRunner for NoopRunner { async fn spawn(&mut self, _opts: RufloRunnerOpts) -> Result<(), AssistError> { self.started = true; tracing::debug!("NoopRunner: spawn called (no subprocess — explicit no-op)"); Ok(()) } async fn send_request( &self, _payload: serde_json::Value, ) -> Result { // Honest: refuse to answer if not started rather than fabricating a // response. After spawn, return an explicit "no opinion" so the // pipeline can fall through deliberately. if !self.started { return Err(AssistError::NotStarted); } Ok(RufloResponse { intent: None, speech: None, }) } async fn shutdown(&mut self) -> Result<(), AssistError> { // Idempotent: Ok whether or not spawn was called. self.started = false; tracing::debug!("NoopRunner: shutdown called (idempotent)"); Ok(()) } } /// Real, dependency-free runner that resolves intents locally. /// /// `LocalRunner` wraps any [`IntentRecognizer`]. On `send_request` it: /// 1. Extracts `utterance` + `language` from the JSON payload. /// 2. Runs the recognizer over the utterance. /// 3. On a match, returns a `RufloResponse` carrying the resolved [`Intent`] /// plus a real spoken acknowledgement. /// 4. On no match, returns an empty `RufloResponse` (intent `None`) so the /// caller can fall through — this is a genuine "nothing recognised", not a /// swallowed error. /// /// This is the honest production path when no Node.js `ruflo-agent.js` LLM /// process is installed: it answers with the actual recognizer pipeline. pub struct LocalRunner { recognizer: Arc, started: bool, } impl LocalRunner { /// Build a `LocalRunner` over the given recognizer. pub fn new(recognizer: R) -> Self { Self { recognizer: Arc::new(recognizer), started: false, } } /// Build a `LocalRunner` from a shared recognizer handle. pub fn from_arc(recognizer: Arc) -> Self { Self { recognizer, started: false, } } /// Compose the spoken acknowledgement for a resolved intent. /// /// Mirrors the speech the built-in handlers would synthesise, so the /// runner's `speech` field is consistent with the handler path. fn speech_for(intent: &Intent) -> String { match (intent.name.as_str(), intent.entity_id()) { ("HassTurnOn", Some(e)) => format!("Turned on {e}."), ("HassTurnOff", Some(e)) => format!("Turned off {e}."), ("HassLightSet", Some(e)) => format!("Done, adjusted {e}."), ("HassNevermind", _) => "Okay, never mind.".to_owned(), ("HassCancelAll", _) => "Cancelled all running automations.".to_owned(), (name, Some(e)) => format!("Resolved {name} for {e}."), (name, None) => format!("Resolved {name}."), } } } #[async_trait] impl RufloRunner for LocalRunner { async fn spawn(&mut self, _opts: RufloRunnerOpts) -> Result<(), AssistError> { self.started = true; tracing::debug!("LocalRunner: ready (local recognizer-backed resolution)"); Ok(()) } async fn send_request( &self, payload: serde_json::Value, ) -> Result { if !self.started { return Err(AssistError::NotStarted); } let utterance = payload .get("utterance") .and_then(|v| v.as_str()) .ok_or_else(|| AssistError::ParseError("payload missing `utterance`".into()))?; let language = payload .get("language") .and_then(|v| v.as_str()) .unwrap_or("en"); // Run the REAL recognizer pipeline. let intent = self.recognizer.recognize(utterance, language).await?; match intent { Some(intent) => { let speech = Self::speech_for(&intent); tracing::debug!( intent = %intent.name, "LocalRunner: resolved intent for utterance" ); Ok(RufloResponse { intent: Some(intent), speech: Some(speech), }) } None => { // Genuine no-match — fall through, not a silent failure. tracing::debug!("LocalRunner: no intent recognised — falling through"); Ok(RufloResponse { intent: None, speech: None, }) } } } async fn shutdown(&mut self) -> Result<(), AssistError> { self.started = false; tracing::debug!("LocalRunner: shutdown (idempotent)"); Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::recognizer::RegexIntentRecognizer; async fn turn_on_recognizer() -> RegexIntentRecognizer { let r = RegexIntentRecognizer::new(); r.register( "HassTurnOn", r"turn on (?:the )?(?P[a-z_][a-z0-9_ ]*(?:\.[a-z_][a-z0-9_]*)?)", "*", ) .await .unwrap(); r } #[tokio::test] async fn noop_runner_spawn_returns_ok() { let mut runner = NoopRunner::new(); let result = runner.spawn(RufloRunnerOpts::default()).await; assert!(result.is_ok()); } #[tokio::test] async fn noop_runner_send_before_spawn_is_not_started() { // Honest behaviour: un-spawned runner must NOT fabricate a response. let runner = NoopRunner::new(); let err = runner .send_request(serde_json::json!({"utterance": "turn on the light"})) .await .unwrap_err(); assert!(matches!(err, AssistError::NotStarted)); } #[tokio::test] async fn noop_runner_after_spawn_returns_explicit_no_opinion() { let mut runner = NoopRunner::new(); runner.spawn(RufloRunnerOpts::default()).await.unwrap(); let resp = runner .send_request(serde_json::json!({"utterance": "turn on the light", "language": "en"})) .await .unwrap(); // Explicit "no opinion" so the pipeline can fall through deliberately. assert!(resp.intent.is_none()); assert!(resp.speech.is_none()); } #[tokio::test] async fn noop_runner_shutdown_is_idempotent() { let mut runner = NoopRunner::new(); // First shutdown without spawn — must not error. assert!(runner.shutdown().await.is_ok()); // Spawn then shutdown — must not error. runner.spawn(RufloRunnerOpts::default()).await.unwrap(); assert!(runner.shutdown().await.is_ok()); // Second shutdown — must still not error. assert!(runner.shutdown().await.is_ok()); } // ── LocalRunner: real response path ─────────────────────────────────────── #[tokio::test] async fn local_runner_resolves_known_intent_with_real_response() { // This test FAILS against the old always-empty stub: it asserts a real // resolved intent + non-empty speech, which the stub never produced. let mut runner = LocalRunner::new(turn_on_recognizer().await); runner.spawn(RufloRunnerOpts::default()).await.unwrap(); let resp = runner .send_request(serde_json::json!({ "utterance": "turn on the kitchen light", "language": "en" })) .await .unwrap(); let intent = resp.intent.expect("known intent must resolve to Some"); assert_eq!(intent.name.as_str(), "HassTurnOn"); assert!(intent.slots.contains_key("entity_id")); let speech = resp.speech.expect("a real response must carry speech"); assert!( speech.to_lowercase().contains("turned on"), "speech should acknowledge the action, got {speech:?}" ); } #[tokio::test] async fn local_runner_dotted_entity_round_trips() { let mut runner = LocalRunner::new(turn_on_recognizer().await); runner.spawn(RufloRunnerOpts::default()).await.unwrap(); let resp = runner .send_request(serde_json::json!({"utterance": "turn on light.kitchen", "language": "en"})) .await .unwrap(); let intent = resp.intent.expect("must resolve"); assert_eq!(intent.entity_id(), Some("light.kitchen")); assert_eq!(resp.speech.as_deref(), Some("Turned on light.kitchen.")); } #[tokio::test] async fn local_runner_unknown_utterance_falls_through() { let mut runner = LocalRunner::new(turn_on_recognizer().await); runner.spawn(RufloRunnerOpts::default()).await.unwrap(); let resp = runner .send_request(serde_json::json!({"utterance": "play jazz music", "language": "en"})) .await .unwrap(); assert!(resp.intent.is_none(), "unknown utterance must not resolve"); assert!(resp.speech.is_none()); } #[tokio::test] async fn local_runner_missing_utterance_is_typed_error() { let mut runner = LocalRunner::new(turn_on_recognizer().await); runner.spawn(RufloRunnerOpts::default()).await.unwrap(); let err = runner .send_request(serde_json::json!({"language": "en"})) .await .unwrap_err(); assert!(matches!(err, AssistError::ParseError(_))); } #[tokio::test] async fn local_runner_send_before_spawn_is_not_started() { let runner = LocalRunner::new(turn_on_recognizer().await); let err = runner .send_request(serde_json::json!({"utterance": "turn on light.kitchen"})) .await .unwrap_err(); assert!(matches!(err, AssistError::NotStarted)); } }