use std::time::Instant;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum SteeringKind {
#[default]
Hint,
Redirect,
Stop,
}
impl std::fmt::Display for SteeringKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Hint => write!(f, "HINT"),
Self::Redirect => write!(f, "REDIRECT"),
Self::Stop => write!(f, "STOP"),
}
}
}
#[derive(Debug, Clone)]
pub struct SteeringEvent {
pub kind: SteeringKind,
pub message: String,
pub timestamp: Instant,
}
impl SteeringEvent {
pub fn new(kind: SteeringKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
timestamp: Instant::now(),
}
}
}
pub type SteeringSender = tokio::sync::mpsc::UnboundedSender<SteeringEvent>;
pub type SteeringReceiver = tokio::sync::mpsc::UnboundedReceiver<SteeringEvent>;
pub const MAX_STEER_MESSAGE_BYTES: usize = 2000;
pub fn steering_channel() -> (SteeringSender, SteeringReceiver) {
tokio::sync::mpsc::unbounded_channel()
}
pub fn drain_pending_steers(rx: &mut SteeringReceiver) -> Option<(String, SteeringKind)> {
let mut events: Vec<SteeringEvent> = Vec::new();
while let Ok(event) = rx.try_recv() {
events.push(event);
}
if events.is_empty() {
return None;
}
let strongest_kind = events
.iter()
.max_by_key(|e| match e.kind {
SteeringKind::Stop => 2u8,
SteeringKind::Redirect => 1,
SteeringKind::Hint => 0,
})
.map(|e| e.kind.clone())
.unwrap_or(SteeringKind::Hint);
let message = build_steer_message(&events);
Some((message, strongest_kind))
}
pub fn build_steer_message(events: &[SteeringEvent]) -> String {
if events.is_empty() {
return String::new();
}
let combined = if events.len() == 1 {
let text = truncate_steer_text(&events[0].message);
format!("[⛵ STEER] {text}")
} else {
events
.iter()
.enumerate()
.map(|(i, e)| {
let text = truncate_steer_text(&e.message);
format!("[⛵ STEER] ({}) {}", i + 1, text)
})
.collect::<Vec<_>>()
.join("\n")
};
scan_and_sanitize_steer(combined)
}
fn truncate_steer_text(text: &str) -> String {
let trimmed = text.trim();
if trimmed.len() <= MAX_STEER_MESSAGE_BYTES {
return trimmed.to_string();
}
let mut end = MAX_STEER_MESSAGE_BYTES;
while !trimmed.is_char_boundary(end) {
end -= 1;
}
format!(
"{}… (truncated at {} chars)",
&trimmed[..end],
MAX_STEER_MESSAGE_BYTES
)
}
fn scan_and_sanitize_steer(message: String) -> String {
let threats = crate::prompt_builder::scan_for_injection(&message);
if threats.is_empty() {
return message;
}
let has_high = threats
.iter()
.any(|t| t.severity == crate::prompt_builder::ThreatSeverity::High);
if has_high {
tracing::warn!(
threats = threats.len(),
"steer message blocked: high-severity injection pattern detected"
);
"[⛵ STEER] [blocked: content flagged by injection scanner]".to_string()
} else {
tracing::warn!(
threats = threats.len(),
"steer message: medium/low injection patterns detected, allowing through"
);
message
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_single_steer() {
let events = vec![SteeringEvent::new(SteeringKind::Hint, "focus on auth")];
let msg = build_steer_message(&events);
assert_eq!(msg, "[⛵ STEER] focus on auth");
}
#[test]
fn test_build_multiple_steers() {
let events = vec![
SteeringEvent::new(SteeringKind::Hint, "focus on auth"),
SteeringEvent::new(SteeringKind::Redirect, "skip the DB step"),
];
let msg = build_steer_message(&events);
assert!(msg.contains("(1) focus on auth"));
assert!(msg.contains("(2) skip the DB step"));
}
#[test]
fn test_truncation() {
let long_text = "x".repeat(3000);
let events = vec![SteeringEvent::new(SteeringKind::Hint, long_text)];
let msg = build_steer_message(&events);
assert!(msg.contains("truncated"));
assert!(msg.len() < 2100); }
#[test]
fn test_strongest_kind_stop_wins() {
let events = vec![
SteeringEvent::new(SteeringKind::Hint, "a"),
SteeringEvent::new(SteeringKind::Stop, "b"),
SteeringEvent::new(SteeringKind::Redirect, "c"),
];
let (_, kind) = drain_pending_steers(&mut {
let (tx, mut rx) = steering_channel();
for e in events {
let _ = tx.send(e);
}
rx
})
.expect("drain should return the strongest steering event");
assert_eq!(kind, SteeringKind::Stop);
}
#[tokio::test]
async fn test_drain_empty() {
let (_tx, mut rx) = steering_channel();
assert!(drain_pending_steers(&mut rx).is_none());
}
#[tokio::test]
async fn test_drain_collects_all() {
let (tx, mut rx) = steering_channel();
for i in 0..5 {
let _ = tx.send(SteeringEvent::new(SteeringKind::Hint, format!("hint {i}")));
}
let result = drain_pending_steers(&mut rx);
assert!(result.is_some());
let (msg, _) = result.expect("drain should return collected steering hints");
assert!(msg.contains("(1)") && msg.contains("(5)"));
}
}