use std::sync::Arc;
use async_trait::async_trait;
use tracing::debug;
use crate::control::config::ObservabilityConfig;
use crate::control::event::ActivityKind;
use crate::core::sm::providers::{ChatMessage, LlmProvider, LlmRequest};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClassifierGate {
pub config_enabled: bool,
pub api_key_present: bool,
pub sm_agent_connected: bool,
}
impl ClassifierGate {
pub fn from_config(
config: &ObservabilityConfig,
api_key_present: bool,
sm_agent_connected: bool,
) -> Self {
Self {
config_enabled: config.llm_classifier,
api_key_present,
sm_agent_connected,
}
}
pub fn should_classify(&self) -> bool {
self.config_enabled && self.api_key_present && !self.sm_agent_connected
}
}
const CLASSIFIER_TEMPERATURE: f32 = 0.0;
const CLASSIFIER_MAX_TOKENS: u32 = 16;
const CLASSIFIER_SYSTEM_PROMPT: &str = "You are a session-activity classifier. \
Given raw output from a coding-agent session, reply with EXACTLY ONE lowercase \
label from this set and nothing else: awaiting-input, tool-use, error, \
test-result, git-op, auth-prompt, session-complete, rate-limited, unknown.";
#[async_trait]
pub trait ActivityClassifier: Send + Sync {
async fn classify(&self, raw_output: &str) -> Result<Option<ActivityKind>, String>;
}
pub struct OpenRouterClassifier {
provider: Arc<dyn LlmProvider>,
model: String,
}
impl OpenRouterClassifier {
pub fn new(provider: Arc<dyn LlmProvider>, model: impl Into<String>) -> Self {
Self {
provider,
model: model.into(),
}
}
}
#[async_trait]
impl ActivityClassifier for OpenRouterClassifier {
async fn classify(&self, raw_output: &str) -> Result<Option<ActivityKind>, String> {
let req = LlmRequest {
model: self.model.clone(),
system: CLASSIFIER_SYSTEM_PROMPT.to_string(),
messages: vec![ChatMessage {
role: "user".to_string(),
content: raw_output.to_string(),
}],
temperature: CLASSIFIER_TEMPERATURE,
max_tokens: CLASSIFIER_MAX_TOKENS,
};
let resp = self
.provider
.complete(req)
.await
.map_err(|e| format!("openrouter classify failed: {e}"))?;
match label_to_activity_kind(&resp.text) {
Some(kind) => {
debug!(label = %resp.text.trim(), "§8.3 classifier resolved a label");
Ok(Some(kind))
}
None => {
debug!(
label = %resp.text.trim(),
"§8.3 classifier returned no-match label (ok-none, silent no-op)"
);
Ok(None)
}
}
}
}
pub fn label_to_activity_kind(label: &str) -> Option<ActivityKind> {
match label.trim().to_ascii_lowercase().as_str() {
"awaiting-input" | "awaiting_input" => Some(ActivityKind::AwaitingInput),
"tool-use" | "tool_use" => Some(ActivityKind::ToolUse {
name: String::new(),
}),
"error" => Some(ActivityKind::Error {
excerpt: String::new(),
}),
"test-result" | "test_result" => Some(ActivityKind::TestResult {
passed: 0,
failed: 0,
}),
"git-op" | "git_op" => Some(ActivityKind::GitOp {
verb: String::new(),
}),
"auth-prompt" | "auth_prompt" => Some(ActivityKind::AuthPrompt),
"session-complete" | "session_complete" => Some(ActivityKind::SessionComplete),
"rate-limited" | "rate_limited" => Some(ActivityKind::RateLimited),
_ => {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn gate(
config_enabled: bool,
api_key_present: bool,
sm_agent_connected: bool,
) -> ClassifierGate {
ClassifierGate {
config_enabled,
api_key_present,
sm_agent_connected,
}
}
#[test]
fn classifier_gate_all_off() {
assert!(!gate(false, false, false).should_classify());
}
#[test]
fn classifier_gate_requires_all_three() {
assert!(gate(true, true, false).should_classify());
assert!(
!gate(false, true, false).should_classify(),
"config off blocks"
);
assert!(!gate(true, false, false).should_classify(), "no key blocks");
}
#[test]
fn classifier_gate_sm_connected_blocks() {
assert!(!gate(true, true, true).should_classify());
}
#[test]
fn classifier_gate_from_config() {
let on = ObservabilityConfig {
llm_classifier: true,
};
let g = ClassifierGate::from_config(&on, true, false);
assert!(g.config_enabled);
assert!(g.api_key_present);
assert!(!g.sm_agent_connected);
assert!(g.should_classify());
let off = ObservabilityConfig::default();
let g2 = ClassifierGate::from_config(&off, true, false);
assert!(!g2.config_enabled);
assert!(!g2.should_classify());
}
use crate::core::sm::providers::{LlmProvider, LlmResponse, error::SmLlmError};
use std::sync::Arc;
struct MockProvider {
reply: Result<String, SmLlmError>,
}
#[async_trait]
impl LlmProvider for MockProvider {
fn name(&self) -> &str {
"mock"
}
async fn complete(
&self,
_req: crate::core::sm::providers::LlmRequest,
) -> Result<LlmResponse, SmLlmError> {
match &self.reply {
Ok(text) => Ok(LlmResponse {
text: text.clone(),
model: "mock-model".into(),
input_tokens: 0,
output_tokens: 0,
latency_ms: 0,
cost_usd: 0.0,
}),
Err(_) => Err(SmLlmError::RateLimited),
}
}
}
#[test]
fn label_to_activity_kind_maps_known() {
assert_eq!(
label_to_activity_kind("awaiting-input"),
Some(ActivityKind::AwaitingInput)
);
assert_eq!(
label_to_activity_kind(" AUTH-PROMPT \n"),
Some(ActivityKind::AuthPrompt)
);
assert_eq!(
label_to_activity_kind("rate_limited"),
Some(ActivityKind::RateLimited)
);
assert!(matches!(
label_to_activity_kind("tool-use"),
Some(ActivityKind::ToolUse { .. })
));
}
#[test]
fn label_to_activity_kind_unknown_none() {
assert_eq!(label_to_activity_kind("unknown"), None);
assert_eq!(label_to_activity_kind("definitely-not-a-label"), None);
assert_eq!(label_to_activity_kind(""), None);
}
#[tokio::test]
async fn openrouter_classifier_returns_label_on_gate_on() {
let provider = Arc::new(MockProvider {
reply: Ok("rate-limited".to_string()),
});
let classifier = OpenRouterClassifier::new(provider, "openai/gpt-4o-mini");
let result = classifier
.classify("HTTP 429 Too Many Requests from the API")
.await
.expect("mock provider must not error");
assert_eq!(result, Some(ActivityKind::RateLimited));
}
#[tokio::test]
async fn openrouter_classifier_unknown_is_ok_none() {
let provider = Arc::new(MockProvider {
reply: Ok("unknown".to_string()),
});
let classifier = OpenRouterClassifier::new(provider, "m");
let result = classifier.classify("ambiguous output").await.unwrap();
assert_eq!(result, None, "unknown must be Ok(None), not Err");
}
#[tokio::test]
async fn openrouter_classifier_unrecognised_label_is_ok_none() {
let provider = Arc::new(MockProvider {
reply: Ok("???".to_string()),
});
let classifier = OpenRouterClassifier::new(provider, "m");
let result = classifier.classify("weird output").await.unwrap();
assert_eq!(result, None, "unrecognised label must be Ok(None), not Err");
}
#[tokio::test]
async fn openrouter_classifier_provider_error_is_err() {
let provider = Arc::new(MockProvider {
reply: Err(SmLlmError::RateLimited),
});
let classifier = OpenRouterClassifier::new(provider, "m");
assert!(classifier.classify("anything").await.is_err());
}
}