use std::sync::Arc;
use async_trait::async_trait;
use clawft_types::skill::SkillDefinition;
use tracing::{debug, info, warn};
use super::{ContextDecision, ContextRequest, ContextRouter, clamp_complexity};
use crate::agent::skills_v2::SkillRegistry;
use crate::embeddings::Embedder;
mod index;
#[cfg(test)]
mod tests;
#[cfg(not(feature = "embedding-router"))]
use index::BruteForceIndex;
#[cfg(feature = "embedding-router")]
use index::DiskAnnEmbeddingIndex;
use index::Index;
pub const DEFAULT_TOP_K: usize = 5;
pub const DEFAULT_CONFIDENCE_THRESHOLD: f32 = 0.6;
pub const FALLBACK_TRACING_TARGET: &str = "context_router.fallback";
#[cfg(feature = "embedding-router")]
pub const INDEX_BACKEND: &str = "diskann";
#[cfg(not(feature = "embedding-router"))]
pub const INDEX_BACKEND: &str = "brute-force";
#[derive(Debug)]
pub enum EmbeddingRouterError {
EmptyRegistry,
EmbedError(String),
IndexError(String),
}
impl std::fmt::Display for EmbeddingRouterError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmptyRegistry => write!(f, "skill registry was empty at construction"),
Self::EmbedError(m) => write!(f, "skill descriptor embed failed: {m}"),
Self::IndexError(m) => write!(f, "embedding index build failed: {m}"),
}
}
}
impl std::error::Error for EmbeddingRouterError {}
pub struct EmbeddingRouter {
pub(super) embedder: Arc<dyn Embedder>,
index: Arc<dyn Index>,
archetypes: std::collections::HashMap<String, String>,
top_k: usize,
confidence_threshold: f32,
dim: usize,
}
impl EmbeddingRouter {
pub async fn new(
embedder: Arc<dyn Embedder>,
skills: &SkillRegistry,
) -> Result<Self, EmbeddingRouterError> {
if skills.is_empty() {
return Err(EmbeddingRouterError::EmptyRegistry);
}
let dim = embedder.dimension();
let mut entries: Vec<(String, Vec<f32>)> = Vec::with_capacity(skills.len());
let mut archetypes: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for skill in skills.list() {
let descriptor = describe_skill(skill);
let vector = embedder
.embed(&descriptor)
.await
.map_err(|e| EmbeddingRouterError::EmbedError(e.to_string()))?;
if vector.len() != dim {
return Err(EmbeddingRouterError::EmbedError(format!(
"embedder returned {} dims for skill {}; expected {}",
vector.len(),
skill.name,
dim
)));
}
entries.push((skill.name.clone(), vector));
if let Some(cat) = extract_category(skill) {
archetypes.insert(skill.name.clone(), cat);
}
}
let index: Arc<dyn Index> = build_index(dim, entries)?;
let n = index.len();
info!(
backend = INDEX_BACKEND,
skills = n,
dim,
"EmbeddingRouter: index built"
);
Ok(Self {
embedder,
index,
archetypes,
top_k: DEFAULT_TOP_K,
confidence_threshold: DEFAULT_CONFIDENCE_THRESHOLD,
dim,
})
}
#[must_use]
pub fn index_backend(&self) -> &'static str {
INDEX_BACKEND
}
#[must_use]
pub fn with_top_k(mut self, k: usize) -> Self {
self.top_k = k.max(1);
self
}
#[must_use]
pub fn with_confidence_threshold(mut self, t: f32) -> Self {
self.confidence_threshold = t.clamp(-1.0, 1.0);
self
}
}
#[async_trait]
impl ContextRouter for EmbeddingRouter {
async fn route(&self, request: &ContextRequest) -> ContextDecision {
if self.index.len() == 0 {
info!(
target: FALLBACK_TRACING_TARGET,
reason = "empty_index",
"EmbeddingRouter: index has no skills; falling back"
);
return ContextDecision::default();
}
let query = match self.embedder.embed(&request.content).await {
Ok(v) if v.len() == self.dim => v,
Ok(v) => {
warn!(
expected = self.dim,
got = v.len(),
"EmbeddingRouter: query dimension mismatch; falling back"
);
info!(
target: FALLBACK_TRACING_TARGET,
reason = "dim_mismatch",
"EmbeddingRouter: dim mismatch; falling back"
);
return ContextDecision::default();
}
Err(e) => {
warn!(
error = %e,
"EmbeddingRouter: query embed failed; falling back to empty decision"
);
info!(
target: FALLBACK_TRACING_TARGET,
reason = "embed_error",
"EmbeddingRouter: embed error; falling back"
);
return ContextDecision::default();
}
};
let hits = self.index.search(&query, self.top_k);
if hits.is_empty() {
info!(
target: FALLBACK_TRACING_TARGET,
reason = "no_hits",
"EmbeddingRouter: index returned no hits; falling back"
);
return ContextDecision::default();
}
let top1_cos = (1.0 - hits[0].distance / 2.0).clamp(-1.0, 1.0);
if top1_cos < self.confidence_threshold {
info!(
target: FALLBACK_TRACING_TARGET,
reason = "low_confidence",
top1_score = top1_cos,
threshold = self.confidence_threshold,
"EmbeddingRouter: top-1 below threshold; falling back"
);
return ContextDecision::default();
}
let skills: Vec<String> = hits.iter().map(|h| h.key.clone()).collect();
let archetype = self.archetypes.get(&hits[0].key).cloned();
let denom = (1.0 - self.confidence_threshold).max(f32::EPSILON);
let confidence_band = (top1_cos - self.confidence_threshold) / denom;
let hint = if confidence_band < 0.25 { 0.1 } else { 0.0 };
debug!(
top1 = %hits[0].key,
top1_cos = top1_cos,
archetype = ?archetype,
skills = ?skills,
"EmbeddingRouter: emitting decision"
);
let mut decision = ContextDecision::new(skills, None, clamp_complexity(hint));
decision.archetype = archetype;
decision
}
}
fn describe_skill(skill: &SkillDefinition) -> String {
let mut s = format!("{}: {}", skill.name, skill.description);
if let Some(cat) = extract_category(skill) {
s.push_str(&format!(" [category: {cat}]"));
}
s
}
fn extract_category(skill: &SkillDefinition) -> Option<String> {
skill
.metadata
.get("openclaw-category")
.or_else(|| skill.metadata.get("openclaw_category"))
.or_else(|| skill.metadata.get("category"))
.and_then(|v| v.as_str())
.map(str::to_owned)
}
pub(crate) fn normalise(mut v: Vec<f32>) -> Vec<f32> {
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > f32::EPSILON {
for x in &mut v {
*x /= norm;
}
}
v
}
#[cfg(feature = "embedding-router")]
fn build_index(
dim: usize,
entries: Vec<(String, Vec<f32>)>,
) -> Result<Arc<dyn Index>, EmbeddingRouterError> {
let idx = DiskAnnEmbeddingIndex::build(dim, entries)?;
Ok(Arc::new(idx))
}
#[cfg(not(feature = "embedding-router"))]
fn build_index(
_dim: usize,
entries: Vec<(String, Vec<f32>)>,
) -> Result<Arc<dyn Index>, EmbeddingRouterError> {
let mut idx = BruteForceIndex::new();
for (k, v) in entries {
idx.insert(k, v);
}
Ok(Arc::new(idx))
}