feat(homecore-assist/p1): ADR-133 intent pipeline + ruflo runner stub (23 tests pass)

- Creates v2/crates/homecore-assist with intent, recognizer, handler,
  runner, and pipeline modules per ADR-133 §2 design
- RegexIntentRecognizer: HA-style named-capture-group pattern matching
- Built-in handlers: HassTurnOn, HassTurnOff, HassLightSet, HassNevermind,
  HassCancelAll — dispatch to homecore ServiceRegistry
- RufloRunner trait + NoopRunner P1 stub (Windows-safe subprocess teardown
  deferred to P2 per ADR-133 §Q3)
- AssistPipeline + default_pipeline() wires recognizer → handler → response
- SemanticIntentRecognizer P2 stub (ruvector HNSW deferred)
- 23 unit tests, 0 failures; cargo build -p homecore-assist clean

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-25 18:31:54 -04:00
parent 901adf1be6
commit 26ad45fbdb
8 changed files with 1352 additions and 0 deletions

View File

@ -0,0 +1,176 @@
# ADR-133: HOMECORE-ASSIST — Voice/Intent Pipeline + Ruflo Agent Bridge
| Field | Value |
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-05-25 |
| **Deciders** | ruv |
| **Codename** | **HOMECORE-ASSIST** |
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master), [ADR-127](ADR-127-homecore-state-machine-rust.md) (HOMECORE-CORE), [ADR-130](ADR-130-homecore-rest-websocket-api.md) (HOMECORE-API), [ADR-124](ADR-124-rvagent-mcp-ruvector-npm-integration.md) (SENSE-BRIDGE) |
| **Tracking issue** | TBD |
| **Crate** | `v2/crates/homecore-assist` |
---
## 1. Context
Home Assistant's Assist pipeline (`homeassistant/components/assist_pipeline/`) provides
voice-to-intent-to-response processing. It chains:
1. **STT** (speech-to-text) — Whisper, cloud, or satellite
2. **NLU** (natural language understanding) — intent recognition via regex/slots
3. **Intent handler** — maps intent to a HA service call
4. **TTS** (text-to-speech) — synthesises the response for the caller
HA's intent model (`homeassistant/helpers/intent.py`) is keyword/regex based. Every
intent is a named template with slot definitions and a handler that dispatches to HA
services. The built-in intents (`homeassistant/components/conversation/default_agent.py`)
cover `HassTurnOn`, `HassTurnOff`, `HassLightSet`, `HassNevermind`, `HassCancelAll`,
`HassGetState`, `HassGetWeather`, and many others.
HOMECORE needs a wire-compatible Assist pipeline so that:
- The HA iOS/Android companion app's "Assist" button works against HOMECORE.
- The HOMECORE-API WebSocket `assist` command (ADR-130 §2.2) has a handler.
- The ruflo agent toolchain (ADR-124) can provide LLM-grade intent disambiguation as a
drop-in upgrade path for the P1 regex recognizer.
### 1.1 Ruflo integration approach
Ruflo's agent runner exposes an MCP-over-stdio interface (`node ruflo-agent.js`).
HOMECORE-ASSIST manages a long-lived subprocess (Q3 Windows concern below), sends
utterance JSON, and receives intent JSON back. In P1 we ship only the trait surface
and a `NoopRunner` stub; the real subprocess management is P2.
### 1.2 Ruvector semantic intent matching (P2)
`ruvector-core` provides embedding + cosine-similarity primitives. P2 will add a
`SemanticIntentRecognizer` that embeds the utterance and compares it to a HNSW index
of intent exemplars, falling back to the P1 regex recognizer when similarity < 0.75.
This is the mechanism that allows "dim the lights" to match `HassLightSet` without an
explicit regex entry.
---
## 2. Design
### 2.1 Module layout (`v2/crates/homecore-assist/`)
| Module | Contents |
|--------|----------|
| `intent` | `IntentName` newtype, `Intent` (name + slots), `IntentResponse` (speech + optional card + optional data) |
| `recognizer` | `IntentRecognizer` trait; `RegexIntentRecognizer` (P1); `SemanticIntentRecognizer` stub (P2) |
| `handler` | `IntentHandler` trait; built-in handlers: `HassTurnOn`, `HassTurnOff`, `HassLightSet`, `HassNevermind`, `HassCancelAll` |
| `runner` | `RufloRunner` trait + `RufloRunnerOpts`; `NoopRunner` (P1 stub); real subprocess runner (P2) |
| `pipeline` | `AssistPipeline`: wires recognizer → handler → response; exposes `async fn process(utterance, language) -> IntentResponse` |
### 2.2 Built-in intent handlers (P1)
| Handler | HA service call | Slot |
|---------|-----------------|------|
| `HassTurnOn` | `homeassistant.turn_on` / `light.turn_on` / `switch.turn_on` | `entity_id` |
| `HassTurnOff` | `homeassistant.turn_off` / `light.turn_off` / `switch.turn_off` | `entity_id` |
| `HassLightSet` | `light.turn_on` | `entity_id`, `brightness` (0255), `color_name` |
| `HassNevermind` | — (no-op, returns acknowledgement) | — |
| `HassCancelAll` | — (fires `homeassistant_stop_all_scripts` domain event) | — |
### 2.3 IntentResponse
```rust
pub struct IntentResponse {
pub speech: String,
pub card: Option<Card>,
pub data: Option<serde_json::Value>,
}
pub struct Card {
pub title: String,
pub content: String,
}
```
### 2.4 RufloRunner trait
```rust
#[async_trait]
pub trait RufloRunner: Send + Sync + 'static {
async fn spawn(&mut self, opts: RufloRunnerOpts) -> Result<(), AssistError>;
async fn send_request(&self, payload: serde_json::Value) -> Result<RufloResponse, AssistError>;
async fn shutdown(&mut self) -> Result<(), AssistError>;
}
```
`RufloResponse` is `{ intent: Option<Intent>, speech: Option<String> }`.
### 2.5 Pipeline
```rust
pub struct AssistPipeline<R, H> {
recognizer: R,
handler: H,
runner: Option<Box<dyn RufloRunner>>,
}
impl<R: IntentRecognizer, H: IntentHandler> AssistPipeline<R, H> {
pub async fn process(&self, utterance: &str, language: &str, hc: &HomeCore)
-> Result<IntentResponse, AssistError>;
}
```
---
## 3. Questions & Answers
### Q1 — Why not reuse HA's existing `homeassistant.helpers.intent` via PyO3?
PyO3 bridges add a GIL lock on every cross-language call; the Assist pipeline processes
hundreds of short utterances per day from voice satellites. A native Rust recognizer is
simpler and faster. Python HA can still connect as an external integration via MQTT or
the HOMECORE WebSocket API.
### Q2 — How does `RegexIntentRecognizer` handle ambiguity?
Patterns are tried in registration order; the first match wins. Slot extraction uses
named capture groups. A future P2 upgrade can run all patterns, score them by slot
completeness, and return the highest-scoring match.
### Q3 — Windows subprocess teardown (ruflo runner subprocess on Windows)
`tokio::process::Child` on Windows does not automatically kill the child process when
the `Child` struct is dropped — `SIGTERM` is not a Windows concept, and `TerminateProcess`
is not called automatically. Options for P2:
1. Call `child.start_kill()` in a `Drop` impl (requires a `Runtime` handle — tricky in sync Drop).
2. Wrap `Child` in an `Arc<Mutex<Option<Child>>>` and call `kill()` in an `async fn shutdown()`.
3. Use a Windows job object to bind the subprocess lifetime to the parent process.
**P2 decision**: implement option 2 (explicit `async shutdown()`) + register a `tokio::signal`
handler for `Ctrl+C` / `SIGINT` that calls `shutdown()` before exit. Document the Windows caveat
in the crate README and in `runner.rs`. Job object approach (option 3) is deferred to P3 only
if option 2 proves insufficient in fleet testing.
### Q4 — Why is `SemanticIntentRecognizer` a P2 stub?
The ruvector HNSW index requires the vector store to be populated at startup with intent
exemplars. That startup path requires deciding on a serialization format (HNSW index files
vs. an in-memory array at compile time), which intersects with ADR-084 (RabitQ) and ADR-067
(ruvector v2.0.5). P2 will define the exemplar format and populate the index.
---
## 4. Consequences
- **Positive**: HOMECORE-API `assist` WebSocket command gets a functional backend.
- **Positive**: Ruflo LLM pipelines can upgrade intent matching by swapping the `RufloRunner` impl.
- **Positive**: P1 ships with zero new heavy dependencies (no subprocess spawning, no ML runtime).
- **Negative**: Regex matching has limited coverage; long-tail utterances will return "I'm not sure".
- **Deferral**: ruvector semantic recognizer and real subprocess runner both land in P2.
---
## 5. Implementation phases
| Phase | Scope |
|-------|-------|
| **P1** (this ADR) | `intent`, `recognizer` (regex), `handler` (5 built-ins), `runner` (trait + noop), `pipeline` (end-to-end wiring), 1015 tests |
| **P2** | Real `tokio::process::Child` runner with Windows-safe teardown; `SemanticIntentRecognizer` with ruvector HNSW |
| **P3** | STT/TTS bridge, satellite protocol, cloud fallback |

View File

@ -0,0 +1,47 @@
# HOMECORE-ASSIST — Voice/intent pipeline + ruflo agent bridge.
# Implements ADR-133 (HOMECORE-ASSIST), P1 scaffold:
# - IntentName, Intent, IntentResponse types
# - IntentRecognizer trait + RegexIntentRecognizer (P1)
# - IntentHandler trait + 5 built-in HA-mirroring handlers
# - RufloRunner trait + NoopRunner (P1 stub; real subprocess in P2)
# - AssistPipeline: utterance → recognizer → handler → response
[package]
name = "homecore-assist"
version = "0.1.0-alpha.0"
edition = "2021"
license = "MIT"
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
description = "HOMECORE voice/intent pipeline + ruflo agent bridge (ADR-133 P1 scaffold)"
repository = "https://github.com/ruvnet/RuView"
[lib]
name = "homecore_assist"
path = "src/lib.rs"
[dependencies]
# HOMECORE state machine — local path (ADR-127).
homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
# Async runtime — same feature set as workspace.
# tokio::process is used by the P2 runner; included now so the trait compiles.
tokio = { version = "1", features = ["full"] }
# Async trait support for IntentRecognizer, IntentHandler, RufloRunner.
async-trait = "0.1"
# Error handling.
thiserror = "1"
# Serialisation (intents, slots, ruflo request/response payloads).
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Regex for P1 intent pattern matching.
regex = "1"
# Structured logging.
tracing = "0.1"
[dev-dependencies]
tokio = { version = "1", features = ["full", "test-util"] }

View File

@ -0,0 +1,288 @@
//! Intent handler trait + built-in HA-mirroring handlers.
//!
//! Mirrors `homeassistant.helpers.intent.IntentHandler`. Each handler
//! receives a recognised `Intent` and a `HomeCore` handle, dispatches the
//! appropriate service call, and returns an `IntentResponse`.
//!
//! ## Built-in handlers (P1)
//!
//! | Handler | HA service | Slots |
//! |---------|-----------|-------|
//! | `HassTurnOn` | `homeassistant.turn_on` | `entity_id` |
//! | `HassTurnOff` | `homeassistant.turn_off` | `entity_id` |
//! | `HassLightSet` | `light.turn_on` | `entity_id`, `brightness`, `color_name` |
//! | `HassNevermind` | — (no-op) | — |
//! | `HassCancelAll` | — (domain event) | — |
use async_trait::async_trait;
use thiserror::Error;
use homecore::{Context, HomeCore, ServiceCall, ServiceName};
use crate::intent::{Intent, IntentResponse};
#[derive(Error, Debug)]
pub enum HandlerError {
#[error("service call failed: {0}")]
ServiceFailed(String),
#[error("missing required slot: {0}")]
MissingSlot(String),
#[error("handler internal error: {0}")]
Internal(String),
}
/// Core trait every intent handler must implement.
#[async_trait]
pub trait IntentHandler: Send + Sync + 'static {
/// The intent name(s) this handler accepts.
fn intent_name(&self) -> &str;
/// Handle the intent and return a response.
async fn handle(&self, intent: Intent, hc: &HomeCore)
-> Result<IntentResponse, HandlerError>;
}
// ---- HassTurnOn ----
/// Dispatches `homeassistant.turn_on` (domain-agnostic) for the entity.
pub struct HassTurnOn;
#[async_trait]
impl IntentHandler for HassTurnOn {
fn intent_name(&self) -> &str {
"HassTurnOn"
}
async fn handle(
&self,
intent: Intent,
hc: &HomeCore,
) -> Result<IntentResponse, HandlerError> {
let entity_id = intent
.entity_id()
.ok_or_else(|| HandlerError::MissingSlot("entity_id".into()))?
.to_owned();
let call = ServiceCall {
name: ServiceName::new("homeassistant", "turn_on"),
data: serde_json::json!({ "entity_id": entity_id }),
context: Context::new(),
};
hc.services()
.call(call)
.await
.map_err(|e| HandlerError::ServiceFailed(e.to_string()))?;
Ok(IntentResponse::speech_only(format!("Turned on {entity_id}.")))
}
}
// ---- HassTurnOff ----
/// Dispatches `homeassistant.turn_off` for the entity.
pub struct HassTurnOff;
#[async_trait]
impl IntentHandler for HassTurnOff {
fn intent_name(&self) -> &str {
"HassTurnOff"
}
async fn handle(
&self,
intent: Intent,
hc: &HomeCore,
) -> Result<IntentResponse, HandlerError> {
let entity_id = intent
.entity_id()
.ok_or_else(|| HandlerError::MissingSlot("entity_id".into()))?
.to_owned();
let call = ServiceCall {
name: ServiceName::new("homeassistant", "turn_off"),
data: serde_json::json!({ "entity_id": entity_id }),
context: Context::new(),
};
hc.services()
.call(call)
.await
.map_err(|e| HandlerError::ServiceFailed(e.to_string()))?;
Ok(IntentResponse::speech_only(format!("Turned off {entity_id}.")))
}
}
// ---- HassLightSet ----
/// Dispatches `light.turn_on` with optional `brightness` and `color_name`.
pub struct HassLightSet;
#[async_trait]
impl IntentHandler for HassLightSet {
fn intent_name(&self) -> &str {
"HassLightSet"
}
async fn handle(
&self,
intent: Intent,
hc: &HomeCore,
) -> Result<IntentResponse, HandlerError> {
let entity_id = intent
.entity_id()
.ok_or_else(|| HandlerError::MissingSlot("entity_id".into()))?
.to_owned();
let mut data = serde_json::json!({ "entity_id": entity_id });
if let Some(b) = intent.slots.get("brightness") {
data["brightness"] = b.clone();
}
if let Some(c) = intent.slots.get("color_name") {
data["color_name"] = c.clone();
}
let call = ServiceCall {
name: ServiceName::new("light", "turn_on"),
data,
context: Context::new(),
};
hc.services()
.call(call)
.await
.map_err(|e| HandlerError::ServiceFailed(e.to_string()))?;
Ok(IntentResponse::speech_only(format!("Done, adjusted {entity_id}.")))
}
}
// ---- HassNevermind ----
/// No-op — acknowledges the cancellation without a service call.
pub struct HassNevermind;
#[async_trait]
impl IntentHandler for HassNevermind {
fn intent_name(&self) -> &str {
"HassNevermind"
}
async fn handle(
&self,
_intent: Intent,
_hc: &HomeCore,
) -> Result<IntentResponse, HandlerError> {
Ok(IntentResponse::speech_only("Okay, never mind."))
}
}
// ---- HassCancelAll ----
/// Fires a domain event to cancel all running scripts/automations.
pub struct HassCancelAll;
#[async_trait]
impl IntentHandler for HassCancelAll {
fn intent_name(&self) -> &str {
"HassCancelAll"
}
async fn handle(
&self,
_intent: Intent,
hc: &HomeCore,
) -> Result<IntentResponse, HandlerError> {
use homecore::{Context, DomainEvent};
let event = DomainEvent::new(
"homeassistant_stop_all_scripts",
serde_json::json!({}),
Context::new(),
);
// fire_domain is synchronous and infallible (returns receiver count).
let _receivers = hc.bus().fire_domain(event);
Ok(IntentResponse::speech_only("Cancelled all running automations."))
}
}
#[cfg(test)]
mod tests {
use homecore::service::FnHandler;
use homecore::ServiceName;
use super::*;
/// Build a `HomeCore` pre-registered with a spy handler for the given
/// service. Returns `(HomeCore, Arc<AtomicBool>)` so tests can assert
/// the handler was called.
async fn hc_with_spy(domain: &str, service: &str) -> (HomeCore, std::sync::Arc<std::sync::atomic::AtomicBool>) {
let hc = HomeCore::new();
let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let called2 = called.clone();
hc.services()
.register(
ServiceName::new(domain, service),
FnHandler(move |_call| {
let c = called2.clone();
async move {
c.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(serde_json::json!({}))
}
}),
)
.await;
(hc, called)
}
#[tokio::test]
async fn turn_on_dispatches_service() {
let (hc, called) = hc_with_spy("homeassistant", "turn_on").await;
let intent = Intent::with_entity("HassTurnOn", "light.kitchen", "en");
let resp = HassTurnOn.handle(intent, &hc).await.unwrap();
assert!(called.load(std::sync::atomic::Ordering::SeqCst));
assert!(resp.speech.contains("light.kitchen"));
}
#[tokio::test]
async fn turn_off_dispatches_service() {
let (hc, called) = hc_with_spy("homeassistant", "turn_off").await;
let intent = Intent::with_entity("HassTurnOff", "switch.fan", "en");
let resp = HassTurnOff.handle(intent, &hc).await.unwrap();
assert!(called.load(std::sync::atomic::Ordering::SeqCst));
assert!(resp.speech.contains("switch.fan"));
}
#[tokio::test]
async fn light_set_dispatches_light_turn_on() {
let (hc, called) = hc_with_spy("light", "turn_on").await;
let mut intent = Intent::with_entity("HassLightSet", "light.living", "en");
intent
.slots
.insert("brightness".into(), serde_json::json!(128));
let resp = HassLightSet.handle(intent, &hc).await.unwrap();
assert!(called.load(std::sync::atomic::Ordering::SeqCst));
assert!(resp.speech.contains("light.living"));
}
#[tokio::test]
async fn nevermind_returns_ok_response() {
let hc = HomeCore::new();
let intent = Intent {
name: crate::intent::IntentName::new("HassNevermind"),
slots: Default::default(),
language: "en".into(),
};
let resp = HassNevermind.handle(intent, &hc).await.unwrap();
assert!(resp.speech.to_lowercase().contains("never mind")
|| resp.speech.to_lowercase().contains("nevermind")
|| resp.speech.to_lowercase().contains("okay"));
}
#[tokio::test]
async fn cancel_all_fires_domain_event() {
let hc = HomeCore::new();
// Subscribe before firing so the sender has a live receiver.
let mut rx = hc.bus().subscribe_domain();
let intent = Intent {
name: crate::intent::IntentName::new("HassCancelAll"),
slots: Default::default(),
language: "en".into(),
};
let resp = HassCancelAll.handle(intent, &hc).await.unwrap();
assert!(resp.speech.to_lowercase().contains("cancel"));
// Domain event should have been broadcast.
let event = rx.recv().await.unwrap();
assert_eq!(event.event_type, "homeassistant_stop_all_scripts");
}
}

View File

@ -0,0 +1,131 @@
//! Intent types for the HOMECORE-ASSIST pipeline.
//!
//! Mirrors `homeassistant.helpers.intent.Intent` and
//! `homeassistant.helpers.intent.IntentResponse`.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
/// Newtype wrapping the intent name string (e.g. `"HassTurnOn"`).
///
/// Kept as a newtype rather than a raw `String` so that call sites can
/// pattern-match on well-known constant values without stringly-typed bugs.
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub struct IntentName(pub String);
impl IntentName {
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for IntentName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
/// A recognised user intent with extracted slot values.
///
/// Mirrors `homeassistant.helpers.intent.Intent`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Intent {
/// The intent name, e.g. `HassTurnOn`.
pub name: IntentName,
/// Extracted slots as a JSON-value map. Keys are slot names
/// (e.g. `"entity_id"`, `"brightness"`); values are typed by the
/// recognizer.
pub slots: HashMap<String, serde_json::Value>,
/// BCP-47 language tag of the utterance (e.g. `"en"`, `"en-US"`).
pub language: String,
}
impl Intent {
/// Convenience constructor for single-slot intents.
pub fn with_entity(name: impl Into<String>, entity_id: impl Into<String>, lang: &str) -> Self {
let mut slots = HashMap::new();
slots.insert(
"entity_id".into(),
serde_json::Value::String(entity_id.into()),
);
Self {
name: IntentName::new(name),
slots,
language: lang.to_owned(),
}
}
/// Return the `entity_id` slot as a `&str`, if present.
pub fn entity_id(&self) -> Option<&str> {
self.slots.get("entity_id").and_then(|v| v.as_str())
}
}
/// Optional card displayed in the HA frontend alongside the speech response.
///
/// Mirrors `homeassistant.helpers.intent.IntentResponseType.ACTION_DONE`
/// card payload.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Card {
pub title: String,
pub content: String,
}
/// The full response produced by an intent handler.
///
/// Mirrors `homeassistant.helpers.intent.IntentResponse`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct IntentResponse {
/// Spoken text to synthesise (TTS) or display.
pub speech: String,
/// Optional rich card for dashboard display.
pub card: Option<Card>,
/// Optional structured data for programmatic callers.
pub data: Option<serde_json::Value>,
}
impl IntentResponse {
/// Quick constructor for a plain speech-only response.
pub fn speech_only(text: impl Into<String>) -> Self {
Self {
speech: text.into(),
card: None,
data: None,
}
}
/// Default "not understood" response, mirroring HA's fallback text.
pub fn not_understood() -> Self {
Self::speech_only("I'm not sure how to help with that.")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn intent_name_display() {
let n = IntentName::new("HassTurnOn");
assert_eq!(format!("{n}"), "HassTurnOn");
}
#[test]
fn intent_with_entity_sets_slot() {
let intent = Intent::with_entity("HassTurnOn", "light.kitchen", "en");
assert_eq!(intent.entity_id(), Some("light.kitchen"));
assert_eq!(intent.name.as_str(), "HassTurnOn");
}
#[test]
fn not_understood_response_text() {
let r = IntentResponse::not_understood();
assert!(r.speech.contains("not sure"));
assert!(r.card.is_none());
}
}

View File

@ -0,0 +1,42 @@
//! HOMECORE-ASSIST — Voice/intent pipeline + ruflo agent bridge.
//!
//! Implements [ADR-133](../../../docs/adr/ADR-133-homecore-assist-ruflo.md):
//! the Assist pipeline that takes a voice utterance through intent
//! recognition, intent handling, and response synthesis.
//!
//! ## Module layout (P1 scaffold)
//!
//! - [`intent`] — `IntentName`, `Intent`, `IntentResponse`, `Card`
//! - [`recognizer`] — `IntentRecognizer` trait + `RegexIntentRecognizer` (P1)
//! - [`handler`] — `IntentHandler` trait + 5 built-in HA-mirroring handlers
//! - [`runner`] — `RufloRunner` trait + `NoopRunner` (P1 stub)
//! - [`pipeline`] — `AssistPipeline`: wires recognizer → handler → response
//!
//! ## P1 scope
//!
//! - Regex-based intent recognition (HA classic intent matching).
//! - Built-in handlers: `HassTurnOn`, `HassTurnOff`, `HassLightSet`,
//! `HassNevermind`, `HassCancelAll`.
//! - `RufloRunner` trait surface only; `NoopRunner` stub for P1.
//!
//! ## What's NOT here yet (deferred to P2+)
//!
//! - Real `tokio::process::Child` subprocess runner for `node ruflo-agent.js`
//! (Windows-safe teardown per ADR-133 §Q3 lands in P2).
//! - `SemanticIntentRecognizer` using ruvector HNSW embeddings (P2).
//! - STT/TTS bridge and satellite protocol (P3).
pub mod intent;
pub mod recognizer;
pub mod handler;
pub mod runner;
pub mod pipeline;
pub use intent::{Card, Intent, IntentName, IntentResponse};
pub use recognizer::{IntentRecognizer, RecognizerError, RegexIntentRecognizer};
pub use handler::{
HandlerError, HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn,
IntentHandler,
};
pub use runner::{AssistError, NoopRunner, RufloResponse, RufloRunner, RufloRunnerOpts};
pub use pipeline::AssistPipeline;

View File

@ -0,0 +1,262 @@
//! AssistPipeline — wires recognizer → handler → response.
//!
//! The pipeline is the public entry point for the HOMECORE-ASSIST subsystem.
//! The HOMECORE-API WebSocket `assist` command will call
//! `pipeline.process(utterance, language, &hc).await`.
//!
//! ## Processing flow
//!
//! 1. Call `recognizer.recognize(utterance, language)`.
//! 2. If no intent matched → return `IntentResponse::not_understood()`.
//! 3. Look up the handler by intent name.
//! 4. Call `handler.handle(intent, hc)`.
//! 5. Return the `IntentResponse`.
//!
//! The `RufloRunner` is reserved for a P2 LLM disambiguation pass that
//! fires between steps 1 and 2 when the regex recognizer returns `None`.
use std::collections::HashMap;
use std::sync::Arc;
use homecore::HomeCore;
use tracing::debug;
use crate::handler::IntentHandler;
use crate::intent::IntentResponse;
use crate::recognizer::IntentRecognizer;
use crate::runner::AssistError;
/// Boxed type alias so the pipeline can hold heterogeneous handlers.
type BoxedHandler = Arc<dyn IntentHandler>;
/// The main Assist pipeline.
///
/// Construct with `AssistPipeline::new(recognizer)`, register handlers
/// with `register_handler`, then call `process`.
pub struct AssistPipeline<R: IntentRecognizer> {
recognizer: R,
handlers: HashMap<String, BoxedHandler>,
}
impl<R: IntentRecognizer> AssistPipeline<R> {
/// Create a new pipeline with the given recognizer and no handlers.
pub fn new(recognizer: R) -> Self {
Self {
recognizer,
handlers: HashMap::new(),
}
}
/// Register an intent handler. If a handler for the same intent name
/// was already registered, it is replaced.
pub fn register_handler<H: IntentHandler>(&mut self, handler: H) {
self.handlers
.insert(handler.intent_name().to_owned(), Arc::new(handler));
}
/// Process an utterance through the full pipeline.
///
/// # Errors
///
/// Returns `AssistError` only for unexpected internal failures.
/// Unknown intents and unrecognised utterances are returned as
/// `IntentResponse::not_understood()` — not as errors — so the caller
/// (WebSocket handler) can always synthesise a speech reply.
pub async fn process(
&self,
utterance: &str,
language: &str,
hc: &HomeCore,
) -> Result<IntentResponse, AssistError> {
debug!(%utterance, %language, "AssistPipeline: processing utterance");
let intent = match self.recognizer.recognize(utterance, language).await {
Ok(Some(i)) => i,
Ok(None) => {
debug!("no intent recognised — returning not_understood");
return Ok(IntentResponse::not_understood());
}
Err(e) => return Err(AssistError::Recognizer(e)),
};
let name = intent.name.as_str().to_owned();
let handler = self.handlers.get(&name).cloned();
match handler {
Some(h) => h
.handle(intent, hc)
.await
.map_err(AssistError::Handler),
None => {
debug!(%name, "no handler registered for intent");
Ok(IntentResponse::not_understood())
}
}
}
/// Convenience: count of registered handlers.
pub fn handler_count(&self) -> usize {
self.handlers.len()
}
}
/// Builder that pre-wires the standard set of built-in HA intent handlers.
///
/// Use this when you want all 5 P1 built-ins registered without listing
/// them individually.
pub fn default_pipeline(
recognizer: impl IntentRecognizer,
) -> AssistPipeline<impl IntentRecognizer> {
use crate::handler::{HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn};
let mut pipeline = AssistPipeline::new(recognizer);
pipeline.register_handler(HassTurnOn);
pipeline.register_handler(HassTurnOff);
pipeline.register_handler(HassLightSet);
pipeline.register_handler(HassNevermind);
pipeline.register_handler(HassCancelAll);
pipeline
}
#[cfg(test)]
mod tests {
use homecore::service::FnHandler;
use homecore::{HomeCore, ServiceName};
use crate::handler::{HassTurnOff, HassTurnOn};
use crate::recognizer::RegexIntentRecognizer;
use super::*;
async fn build_test_pipeline() -> (AssistPipeline<RegexIntentRecognizer>, HomeCore) {
let r = RegexIntentRecognizer::new();
r.register(
"HassTurnOn",
r"turn on (?:the )?(?P<entity_id>[a-z_][a-z0-9_ ]*(?:\.[a-z0-9_]+)?)",
"*",
)
.await
.unwrap();
r.register(
"HassTurnOff",
r"turn off (?:the )?(?P<entity_id>[a-z_][a-z0-9_ ]*(?:\.[a-z0-9_]+)?)",
"*",
)
.await
.unwrap();
r.register("HassNevermind", r"never ?mind|cancel that", "*")
.await
.unwrap();
let mut pipeline = AssistPipeline::new(r);
pipeline.register_handler(HassTurnOn);
pipeline.register_handler(HassTurnOff);
pipeline.register_handler(crate::handler::HassNevermind);
let hc = HomeCore::new();
// Register spy handlers so service calls don't return NotRegistered.
hc.services()
.register(
ServiceName::new("homeassistant", "turn_on"),
FnHandler(|_| async { Ok(serde_json::json!({})) }),
)
.await;
hc.services()
.register(
ServiceName::new("homeassistant", "turn_off"),
FnHandler(|_| async { Ok(serde_json::json!({})) }),
)
.await;
(pipeline, hc)
}
#[tokio::test]
async fn pipeline_turn_on_end_to_end() {
let (pipeline, hc) = build_test_pipeline().await;
let resp = pipeline
.process("turn on light.kitchen", "en", &hc)
.await
.unwrap();
assert!(resp.speech.contains("light.kitchen"));
}
#[tokio::test]
async fn pipeline_turn_off_end_to_end() {
let (pipeline, hc) = build_test_pipeline().await;
let resp = pipeline
.process("turn off switch.fan", "en", &hc)
.await
.unwrap();
assert!(resp.speech.to_lowercase().contains("off") || resp.speech.contains("switch.fan"));
}
#[tokio::test]
async fn pipeline_unknown_utterance_returns_not_understood() {
let (pipeline, hc) = build_test_pipeline().await;
let resp = pipeline
.process("what is the weather like", "en", &hc)
.await
.unwrap();
assert!(resp.speech.contains("not sure") || resp.speech.contains("I'm not"));
}
#[tokio::test]
async fn pipeline_recognized_but_no_handler_returns_not_understood() {
// Register a pattern but NOT its handler.
let r = RegexIntentRecognizer::new();
r.register("HassGetState", r"what is (?P<entity_id>\S+)", "*")
.await
.unwrap();
let pipeline = AssistPipeline::new(r);
let hc = HomeCore::new();
let resp = pipeline
.process("what is light.kitchen", "en", &hc)
.await
.unwrap();
assert!(resp.speech.contains("not sure") || resp.speech.contains("I'm not"));
}
#[tokio::test]
async fn default_pipeline_registers_five_handlers() {
let r = RegexIntentRecognizer::new();
let pipeline = default_pipeline(r);
assert_eq!(pipeline.handler_count(), 5);
}
#[tokio::test]
async fn pipeline_nevermind_response() {
let (pipeline, hc) = build_test_pipeline().await;
let resp = pipeline
.process("never mind", "en", &hc)
.await
.unwrap();
assert!(
resp.speech.to_lowercase().contains("okay")
|| resp.speech.to_lowercase().contains("never")
|| resp.speech.to_lowercase().contains("cancel")
);
}
#[tokio::test]
async fn pipeline_use_homecore_service_fn_handler() {
use homecore::service::FnHandler;
let hc = HomeCore::new();
hc.services()
.register(
ServiceName::new("homeassistant", "turn_on"),
FnHandler(|_| async { Ok(serde_json::json!({"ok": true})) }),
)
.await;
let r = RegexIntentRecognizer::new();
r.register(
"HassTurnOn",
r"on (?P<entity_id>\S+)",
"*",
)
.await
.unwrap();
let mut pipeline = AssistPipeline::new(r);
pipeline.register_handler(HassTurnOn);
let resp = pipeline.process("on light.bed", "en", &hc).await.unwrap();
assert!(resp.speech.contains("light.bed"));
}
}

View File

@ -0,0 +1,232 @@
//! Intent recognizer trait + P1 regex-based implementation.
//!
//! Mirrors `homeassistant.helpers.intent.IntentRecognizer` and the
//! `homeassistant/components/conversation/default_agent.py` regex pattern
//! approach used in HA's classic intent matching.
//!
//! ## P1: `RegexIntentRecognizer`
//!
//! Tries each registered pattern in order; the first match wins.
//! Slot values are extracted from named capture groups.
//!
//! ## P2 (stub only): `SemanticIntentRecognizer`
//!
//! Will embed the utterance with ruvector-core and compare it to a
//! HNSW index of intent exemplars. Falls back to regex when similarity
//! is below a configurable threshold (default 0.75).
use std::collections::HashMap;
use async_trait::async_trait;
use regex::Regex;
// serde imports used by SemanticIntentRecognizer and future P2 code
use thiserror::Error;
use crate::intent::{Intent, IntentName};
#[derive(Error, Debug)]
pub enum RecognizerError {
#[error("regex compile error: {0}")]
BadPattern(String),
#[error("recognizer internal error: {0}")]
Internal(String),
}
/// Core trait every recognizer must implement.
///
/// Returns `Ok(None)` when no intent matches (pipeline falls through to
/// the "not understood" path).
#[async_trait]
pub trait IntentRecognizer: Send + Sync + 'static {
async fn recognize(
&self,
utterance: &str,
language: &str,
) -> Result<Option<Intent>, RecognizerError>;
}
/// A single registered intent pattern.
#[derive(Clone)]
struct IntentPattern {
name: IntentName,
/// Pre-compiled regex. Named capture groups become slot keys.
regex: Regex,
/// Language tag this pattern applies to. `"*"` means any language.
language: String,
}
/// P1 recognizer that matches utterances against pre-registered regex patterns.
///
/// Thread-safe: patterns are stored in a `Vec` behind an `Arc<RwLock<_>>` so
/// that `register` can be called from multiple tasks.
#[derive(Clone, Default)]
pub struct RegexIntentRecognizer {
patterns: std::sync::Arc<tokio::sync::RwLock<Vec<IntentPattern>>>,
}
impl RegexIntentRecognizer {
pub fn new() -> Self {
Self::default()
}
/// Register a regex pattern for the given intent name and language.
///
/// Named capture groups (e.g. `(?P<entity_id>\w+\.\w+)`) become slot keys.
/// `language` may be a BCP-47 tag (`"en"`) or `"*"` to match any language.
///
/// # Errors
///
/// Returns `RecognizerError::BadPattern` if the regex fails to compile.
pub async fn register(
&self,
name: impl Into<String>,
pattern: &str,
language: impl Into<String>,
) -> Result<(), RecognizerError> {
let regex = Regex::new(pattern).map_err(|e| RecognizerError::BadPattern(e.to_string()))?;
self.patterns.write().await.push(IntentPattern {
name: IntentName::new(name),
regex,
language: language.into(),
});
Ok(())
}
}
#[async_trait]
impl IntentRecognizer for RegexIntentRecognizer {
async fn recognize(
&self,
utterance: &str,
language: &str,
) -> Result<Option<Intent>, RecognizerError> {
let normalised = utterance.trim().to_lowercase();
let patterns = self.patterns.read().await;
for pattern in patterns.iter() {
if pattern.language != "*" && pattern.language != language {
continue;
}
if let Some(caps) = pattern.regex.captures(&normalised) {
let mut slots: HashMap<String, serde_json::Value> = HashMap::new();
for name in pattern.regex.capture_names().flatten() {
if let Some(m) = caps.name(name) {
slots.insert(name.to_owned(), serde_json::Value::String(m.as_str().to_owned()));
}
}
return Ok(Some(Intent {
name: pattern.name.clone(),
slots,
language: language.to_owned(),
}));
}
}
Ok(None)
}
}
/// P2 stub: semantic recognizer backed by ruvector HNSW.
///
/// Currently always delegates to the inner `RegexIntentRecognizer`.
/// P2 will populate a HNSW index at startup and compare embedded
/// utterances before falling back to regex.
pub struct SemanticIntentRecognizer {
fallback: RegexIntentRecognizer,
}
impl SemanticIntentRecognizer {
pub fn new(fallback: RegexIntentRecognizer) -> Self {
Self { fallback }
}
}
#[async_trait]
impl IntentRecognizer for SemanticIntentRecognizer {
async fn recognize(
&self,
utterance: &str,
language: &str,
) -> Result<Option<Intent>, RecognizerError> {
// TODO P2: embed utterance + HNSW search before falling through.
self.fallback.recognize(utterance, language).await
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn turn_on_recognizer() -> RegexIntentRecognizer {
let r = RegexIntentRecognizer::new();
r.register(
"HassTurnOn",
r"turn on (?:the )?(?P<entity_id>[a-z_][a-z0-9_ ]*(?:\.[a-z_][a-z0-9_]*)?)",
"*",
)
.await
.unwrap();
r.register(
"HassTurnOff",
r"turn off (?:the )?(?P<entity_id>[a-z_][a-z0-9_ ]*(?:\.[a-z_][a-z0-9_]*)?)",
"*",
)
.await
.unwrap();
r
}
#[tokio::test]
async fn recognizes_turn_on_entity() {
let r = turn_on_recognizer().await;
let intent = r
.recognize("turn on the kitchen light", "en")
.await
.unwrap()
.unwrap();
assert_eq!(intent.name.as_str(), "HassTurnOn");
assert!(intent.slots.contains_key("entity_id"));
}
#[tokio::test]
async fn recognizes_dotted_entity_id() {
let r = turn_on_recognizer().await;
let intent = r
.recognize("turn on light.kitchen", "en")
.await
.unwrap()
.unwrap();
assert_eq!(intent.name.as_str(), "HassTurnOn");
assert_eq!(intent.entity_id(), Some("light.kitchen"));
}
#[tokio::test]
async fn unrecognized_utterance_returns_none() {
let r = turn_on_recognizer().await;
let result = r.recognize("play jazz music", "en").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn language_filter_skips_non_matching() {
let r = RegexIntentRecognizer::new();
r.register("HassTurnOn", r"turn on (?P<entity_id>\S+)", "de")
.await
.unwrap();
// German-only pattern must not match an English utterance.
let result = r.recognize("turn on light.kitchen", "en").await.unwrap();
assert!(result.is_none());
// But it must match a German-tagged utterance.
let result = r.recognize("turn on licht.kueche", "de").await.unwrap();
assert!(result.is_some());
}
#[tokio::test]
async fn semantic_recognizer_delegates_to_fallback() {
let regex = turn_on_recognizer().await;
let semantic = SemanticIntentRecognizer::new(regex);
let result = semantic
.recognize("turn on light.kitchen", "en")
.await
.unwrap();
assert!(result.is_some());
}
}

View File

@ -0,0 +1,174 @@
//! RufloRunner trait + NoopRunner (P1 stub).
//!
//! 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`.
//!
//! ## P1 scope
//!
//! Only the trait + `NoopRunner` stub ship in P1. No subprocess is spawned.
//!
//! ## P2 scope
//!
//! Real subprocess management with Windows-safe teardown per ADR-133 §Q3:
//! - `Child` wrapped in `Arc<Mutex<Option<Child>>>`.
//! - Explicit `async shutdown()` calls `child.kill().await` before drop.
//! - `tokio::signal` handler registered for `Ctrl+C`/`SIGINT` that calls
//! `shutdown()` before exit.
//! - Windows job object approach (option 3 per Q3) deferred to P3.
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::intent::Intent;
/// 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<String, String>,
/// 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<Intent>,
/// Spoken text from the LLM, if any.
pub speech: Option<String>,
}
/// Trait for the ruflo agent subprocess runner.
///
/// P1 ships only this trait + `NoopRunner`. The real subprocess runner
/// lands in P2 with Windows-safe teardown (ADR-133 §Q3).
#[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<RufloResponse, AssistError>;
/// 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>;
}
/// P1 no-op implementation. Spawn/send/shutdown are all immediate Ok.
///
/// `send_request` returns an empty `RufloResponse` (no intent, no speech),
/// which causes the pipeline to fall through to the regex recognizer path.
#[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 (P1 stub — no subprocess started)");
Ok(())
}
async fn send_request(
&self,
_payload: serde_json::Value,
) -> Result<RufloResponse, AssistError> {
// P1 stub: always returns empty response so the pipeline falls through
// to the regex recognizer.
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 no-op in P1)");
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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_request_returns_empty_response() {
let runner = NoopRunner::new();
let resp = runner
.send_request(serde_json::json!({"utterance": "turn on the light", "language": "en"}))
.await
.unwrap();
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());
}
}