use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::time::{Duration, Instant};
use codewhale_config::AdvisorConfigToml;
use tokio::sync::mpsc;
use tracing::debug;
use crate::client::DeepSeekClient;
use crate::core::events::Event;
use crate::llm_client::LlmClient;
use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt};
use crate::utils::truncate_with_ellipsis;
const ADVISOR_MAX_TOKENS: u32 = 256;
const MAX_CHARS_PER_PAIR: usize = 800;
const ADVISOR_SYSTEM_PROMPT: &str = "You are a concise background advisor reviewing recent tool activity. \
Your role: identify one or two concrete concerns (correctness, risk, or \
missed alternatives) in the tool calls provided. \
If nothing notable stands out, respond with exactly the word \"ok\". \
Otherwise write one to three short sentences — no preamble, no markdown, \
no praise. Focus on signal; omit noise.";
#[derive(Debug, Clone)]
pub struct ToolCallPair {
pub name: String,
pub input_preview: String,
pub result_preview: String,
}
#[derive(Debug, Clone)]
pub struct AdvisorConfig {
pub enabled: bool,
pub max_tool_calls: u32,
pub rate_limit: Duration,
pub dedup_window: Duration,
pub model: Option<String>,
}
impl AdvisorConfig {
#[must_use]
pub fn from_toml(toml: &AdvisorConfigToml) -> Self {
Self {
enabled: toml.enabled,
max_tool_calls: toml.max_tool_calls.clamp(1, 50),
rate_limit: Duration::from_secs(toml.rate_limit_secs.clamp(5, 3600)),
dedup_window: Duration::from_secs(toml.dedup_window_secs),
model: toml.model.clone(),
}
}
#[must_use]
pub fn disabled() -> Self {
Self {
enabled: false,
max_tool_calls: 10,
rate_limit: Duration::from_secs(60),
dedup_window: Duration::from_secs(300),
model: None,
}
}
}
#[derive(Debug)]
pub struct EmissionGuard {
last_emission: Option<Instant>,
last_note_hash: Option<u64>,
last_note_hash_at: Option<Instant>,
}
impl EmissionGuard {
#[must_use]
pub fn new() -> Self {
Self {
last_emission: None,
last_note_hash: None,
last_note_hash_at: None,
}
}
#[must_use]
pub fn may_emit(&self, note: &str, config: &AdvisorConfig) -> bool {
if note.trim().eq_ignore_ascii_case("ok") {
return false;
}
let now = Instant::now();
if let Some(last) = self.last_emission
&& now.duration_since(last) < config.rate_limit
{
return false;
}
let note_hash = hash_str(note);
if let (Some(prev_hash), Some(prev_at)) = (self.last_note_hash, self.last_note_hash_at)
&& prev_hash == note_hash
&& now.duration_since(prev_at) < config.dedup_window
{
return false;
}
true
}
pub fn record_emission(&mut self, note: &str) {
let now = Instant::now();
self.last_emission = Some(now);
self.last_note_hash = Some(hash_str(note));
self.last_note_hash_at = Some(now);
}
}
impl Default for EmissionGuard {
fn default() -> Self {
Self::new()
}
}
#[must_use]
pub fn extract_tool_call_pairs(messages: &[Message], max_pairs: usize) -> Vec<ToolCallPair> {
let mut uses: Vec<(String, String, String)> = Vec::new(); let mut results: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for msg in messages {
for block in &msg.content {
match block {
ContentBlock::ToolUse {
id, name, input, ..
} => {
let input_str = truncate_with_ellipsis(
&serde_json::to_string(input).unwrap_or_default(),
MAX_CHARS_PER_PAIR / 2,
"…",
);
uses.push((id.clone(), name.clone(), input_str));
}
ContentBlock::ToolResult {
tool_use_id,
content,
..
} => {
results.insert(
tool_use_id.clone(),
truncate_with_ellipsis(content, MAX_CHARS_PER_PAIR / 2, "…"),
);
}
_ => {}
}
}
}
let start = uses.len().saturating_sub(max_pairs);
uses[start..]
.iter()
.map(|(id, name, input)| {
let result = results
.get(id.as_str())
.cloned()
.unwrap_or_else(|| "(pending)".to_string());
ToolCallPair {
name: name.clone(),
input_preview: input.clone(),
result_preview: result,
}
})
.collect()
}
#[must_use]
pub fn build_advisor_prompt(pairs: &[ToolCallPair]) -> String {
let mut out = String::from("Recent tool activity to review (oldest → newest):\n\n");
for (i, pair) in pairs.iter().enumerate() {
out.push_str(&format!(
"{}. tool={}\n input: {}\n result: {}\n\n",
i + 1,
pair.name,
pair.input_preview,
pair.result_preview
));
}
out.push_str(
"Provide your advisory in one to three sentences, or respond with \"ok\" if nothing notable.",
);
out
}
pub async fn run_advisor_for_turn(
turn_id: String,
messages: Vec<Message>,
config: AdvisorConfig,
client: DeepSeekClient,
session_model: String,
guard: std::sync::Arc<tokio::sync::Mutex<EmissionGuard>>,
tx_event: mpsc::Sender<Event>,
) {
{
let g = guard.lock().await;
if let Some(last) = g.last_emission
&& std::time::Instant::now().duration_since(last) < config.rate_limit
{
debug!(target: "advisor", "rate-limited, skipping advisor run for turn {turn_id}");
return;
}
}
let pairs = extract_tool_call_pairs(&messages, config.max_tool_calls as usize);
if pairs.is_empty() {
debug!(target: "advisor", "no tool calls found; skipping advisor for turn {turn_id}");
return;
}
let tool_call_count = pairs.len() as u32;
let prompt = build_advisor_prompt(&pairs);
let model = config
.model
.clone()
.unwrap_or_else(|| session_model.clone());
let request = MessageRequest {
model: model.clone(),
messages: vec![Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: prompt,
cache_control: None,
}],
}],
max_tokens: ADVISOR_MAX_TOKENS,
system: Some(SystemPrompt::Text(ADVISOR_SYSTEM_PROMPT.to_string())),
tools: None,
tool_choice: None,
metadata: None,
thinking: None,
reasoning_effort: Some("off".to_string()),
stream: Some(false),
temperature: None,
top_p: None,
};
let response = match client.create_message(request).await {
Ok(r) => r,
Err(e) => {
tracing::warn!(target: "advisor", "advisor LLM call failed for turn {turn_id}: {e}");
return;
}
};
if crate::models::is_incomplete_stop_reason(response.stop_reason.as_deref()) {
tracing::warn!(
target: "advisor",
"advisor response incomplete for turn {turn_id} (stop reason `{}`); dropping partial note",
crate::models::stop_reason_detail(response.stop_reason.as_deref())
);
return;
}
let note: String = response
.content
.iter()
.filter_map(|block| {
if let ContentBlock::Text { text, .. } = block {
Some(text.as_str())
} else {
None
}
})
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string();
if note.is_empty() {
debug!(target: "advisor", "empty advisor response for turn {turn_id}; skipping");
return;
}
let mut guard_lock = guard.lock().await;
if !guard_lock.may_emit(¬e, &config) {
debug!(target: "advisor", "emission suppressed by guard for turn {turn_id}");
return;
}
guard_lock.record_emission(¬e);
drop(guard_lock);
let _ = tx_event
.send(Event::AdvisoryNote {
turn_id: turn_id.clone(),
note: note.clone(),
tool_call_count,
})
.await;
debug!(target: "advisor", "advisory note emitted for turn {turn_id} ({tool_call_count} tool calls reviewed)");
}
fn hash_str(s: &str) -> u64 {
let mut h = DefaultHasher::new();
s.hash(&mut h);
h.finish()
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn test_config() -> AdvisorConfig {
AdvisorConfig {
enabled: true,
max_tool_calls: 5,
rate_limit: Duration::from_secs(1),
dedup_window: Duration::from_secs(10),
model: None,
}
}
#[test]
fn disabled_config_has_enabled_false() {
let cfg = AdvisorConfig::disabled();
assert!(!cfg.enabled);
}
#[test]
fn from_toml_clamps_max_tool_calls() {
let toml = AdvisorConfigToml {
enabled: true,
max_tool_calls: 999,
rate_limit_secs: 60,
dedup_window_secs: 300,
model: None,
};
let cfg = AdvisorConfig::from_toml(&toml);
assert_eq!(
cfg.max_tool_calls, 50,
"max_tool_calls must be clamped to 50"
);
}
#[test]
fn from_toml_clamps_rate_limit() {
let toml = AdvisorConfigToml {
enabled: true,
max_tool_calls: 10,
rate_limit_secs: 0, dedup_window_secs: 300,
model: None,
};
let cfg = AdvisorConfig::from_toml(&toml);
assert!(
cfg.rate_limit >= Duration::from_secs(5),
"rate_limit must be at least 5s"
);
}
fn make_messages_with_n_tool_calls(n: usize) -> Vec<Message> {
let mut messages = Vec::new();
for i in 0..n {
let id = format!("tool_{i}");
messages.push(Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: id.clone(),
name: "exec_shell".to_string(),
input: serde_json::json!({"command": format!("echo {i}")}),
caller: None,
thought_signature: None,
}],
});
messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: id,
content: format!("{i}"),
is_error: None,
content_blocks: None,
}],
});
}
messages
}
#[test]
fn extract_tool_call_pairs_bounded_by_max() {
let messages = make_messages_with_n_tool_calls(20);
let pairs = extract_tool_call_pairs(&messages, 5);
assert_eq!(pairs.len(), 5, "must return at most max_pairs");
assert_eq!(pairs[0].name, "exec_shell");
}
#[test]
fn extract_tool_call_pairs_empty_when_no_tool_calls() {
let messages = vec![Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "hello".to_string(),
cache_control: None,
}],
}];
let pairs = extract_tool_call_pairs(&messages, 5);
assert!(pairs.is_empty());
}
#[test]
fn extract_tool_call_pairs_fewer_than_max_returns_all() {
let messages = make_messages_with_n_tool_calls(3);
let pairs = extract_tool_call_pairs(&messages, 10);
assert_eq!(pairs.len(), 3);
}
#[test]
fn emission_guard_allows_first_emission() {
let guard = EmissionGuard::new();
let config = test_config();
assert!(
guard.may_emit("something concerning here", &config),
"first emission must be allowed"
);
}
#[test]
fn emission_guard_blocks_immediately_after_emission() {
let mut guard = EmissionGuard::new();
let config = test_config();
let note = "something concerning";
guard.record_emission(note);
assert!(
!guard.may_emit("a completely different note", &config),
"emission must be blocked immediately after a prior emission (rate limit)"
);
}
#[test]
fn emission_guard_allows_after_rate_limit_expires() {
let mut guard = EmissionGuard::new();
let config = AdvisorConfig {
rate_limit: Duration::ZERO,
dedup_window: Duration::from_secs(300),
..AdvisorConfig::disabled()
};
let note = "first note";
guard.record_emission(note);
assert!(
guard.may_emit("second different note", &config),
"emission must be allowed when rate limit duration is zero"
);
}
#[test]
fn emission_guard_suppresses_ok_response() {
let guard = EmissionGuard::new();
let config = test_config();
assert!(!guard.may_emit("ok", &config), "\"ok\" must be suppressed");
assert!(!guard.may_emit("OK", &config), "\"OK\" must be suppressed");
assert!(
!guard.may_emit(" ok ", &config),
"\" ok \" must be suppressed"
);
}
#[test]
fn emission_guard_dedup_blocks_identical_note_within_window() {
let mut guard = EmissionGuard::new();
let config = AdvisorConfig {
rate_limit: Duration::ZERO,
dedup_window: Duration::from_secs(300),
..AdvisorConfig::disabled()
};
let note = "risky shell command with no error checking";
guard.record_emission(note);
assert!(
!guard.may_emit(note, &config),
"identical note must be suppressed within the dedup window"
);
}
#[test]
fn emission_guard_allows_different_note_within_dedup_window() {
let mut guard = EmissionGuard::new();
let config = AdvisorConfig {
rate_limit: Duration::ZERO,
dedup_window: Duration::from_secs(300),
..AdvisorConfig::disabled()
};
guard.record_emission("first note");
assert!(
guard.may_emit("entirely different note", &config),
"a different note must be allowed even within the dedup window"
);
}
#[test]
fn advisor_prompt_is_non_empty_for_non_empty_pairs() {
let pairs = vec![ToolCallPair {
name: "exec_shell".to_string(),
input_preview: r#"{"command":"ls -la"}"#.to_string(),
result_preview: "total 4\ndrwxr-xr-x 2 user user 4096".to_string(),
}];
let prompt = build_advisor_prompt(&pairs);
assert!(
prompt.contains("exec_shell"),
"prompt must include the tool name"
);
assert!(
prompt.contains("ls -la"),
"prompt must include the tool input"
);
}
}