use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use parking_lot::Mutex;
use serde_json::{Value, json};
use crate::advisor::types::{AdvisorNote, AdvisorSeverity, note_dedupe_key};
use crate::{AgentTool, AgentToolResult, ToolContext};
const ADVISE_DESCRIPTION: &str = include_str!("prompts/advise-tool.md");
pub type EnqueueAdviceFn = Arc<dyn Fn(AdvisorNote) + Send + Sync>;
pub struct AdviseTool {
enqueue: EnqueueAdviceFn,
delivered: Mutex<HashMap<String, u8>>,
}
impl AdviseTool {
#[must_use]
pub fn new(enqueue: EnqueueAdviceFn) -> Self {
Self {
enqueue,
delivered: Mutex::new(HashMap::new()),
}
}
pub fn reset_delivered(&self) {
self.delivered.lock().clear();
}
}
#[async_trait]
impl AgentTool for AdviseTool {
fn name(&self) -> &str {
"advise"
}
fn label(&self) -> &str {
"Advise"
}
fn description(&self) -> &str {
ADVISE_DESCRIPTION
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"note": {
"type": "string",
"description": "One concrete piece of advice for the agent you are watching. Terse, specific, actionable."
},
"severity": {
"type": "string",
"enum": ["nit", "concern", "blocker"],
"description": "How strongly to weigh this. Omit for a plain nit."
}
},
"required": ["note"]
})
}
fn essential(&self) -> bool {
false
}
async fn execute(
&self,
_tool_call_id: &str,
params: Value,
_signal: Option<tokio::sync::oneshot::Receiver<()>>,
_ctx: &ToolContext,
) -> Result<AgentToolResult, String> {
let note_text = params
.get("note")
.and_then(Value::as_str)
.ok_or_else(|| "advise: missing string field 'note'".to_string())?
.trim()
.to_string();
if note_text.is_empty() {
return Err("advise: 'note' must be non-empty".into());
}
let severity = params
.get("severity")
.and_then(Value::as_str)
.and_then(AdvisorSeverity::from_id);
let key = note_dedupe_key(¬e_text);
let rank = severity.unwrap_or_default().rank();
let prev = *self.delivered.lock().get(&key).unwrap_or(&0);
if rank <= prev {
return Ok(
AgentToolResult::success("Duplicate advice ignored.").with_metadata(json!({
"note": note_text,
"severity": severity.map(AdvisorSeverity::as_str),
"duplicate": true,
})),
);
}
self.delivered.lock().insert(key, rank);
(self.enqueue)(AdvisorNote {
note: note_text.clone(),
severity,
});
Ok(AgentToolResult::success("Recorded.").with_metadata(json!({
"note": note_text,
"severity": severity.map(AdvisorSeverity::as_str),
"duplicate": false,
})))
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use crate::ToolContext;
use std::path::PathBuf;
use std::sync::Mutex as StdMutex;
fn ctx() -> ToolContext {
ToolContext::new(PathBuf::from("."))
}
fn capture() -> (EnqueueAdviceFn, Arc<StdMutex<Vec<AdvisorNote>>>) {
let sink = Arc::new(StdMutex::new(Vec::<AdvisorNote>::new()));
let sink_clone = Arc::clone(&sink);
let f: EnqueueAdviceFn = Arc::new(move |n| sink_clone.lock().unwrap().push(n));
(f, sink)
}
#[tokio::test]
async fn records_first_note_and_enqueues() {
let (f, sink) = capture();
let tool = AdviseTool::new(f);
let res = tool
.execute(
"t1",
json!({"note": "Use saturating_add", "severity": "concern"}),
None,
&ctx(),
)
.await
.unwrap();
assert!(res.success);
assert_eq!(res.output, "Recorded.");
assert_eq!(sink.lock().unwrap().len(), 1);
assert_eq!(
sink.lock().unwrap()[0].severity,
Some(AdvisorSeverity::Concern)
);
}
#[tokio::test]
async fn dedupes_lower_or_equal_severity() {
let (f, sink) = capture();
let tool = AdviseTool::new(f);
tool.execute("t1", json!({"note": "rename x"}), None, &ctx())
.await
.unwrap();
let res = tool
.execute("t2", json!({"note": "rename x"}), None, &ctx())
.await
.unwrap();
assert_eq!(res.output, "Duplicate advice ignored.");
tool.execute(
"t3",
json!({"note": "rename x", "severity": "concern"}),
None,
&ctx(),
)
.await
.unwrap();
assert_eq!(sink.lock().unwrap().len(), 2);
}
#[tokio::test]
async fn whitespace_variants_dedupe() {
let (f, sink) = capture();
let tool = AdviseTool::new(f);
tool.execute("t1", json!({"note": "rename x"}), None, &ctx())
.await
.unwrap();
let res = tool
.execute("t2", json!({"note": " rename x "}), None, &ctx())
.await
.unwrap();
assert_eq!(res.output, "Duplicate advice ignored.");
assert_eq!(sink.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn empty_note_errors() {
let (f, _sink) = capture();
let tool = AdviseTool::new(f);
let res = tool
.execute("t1", json!({"note": " "}), None, &ctx())
.await;
assert!(res.is_err());
}
#[tokio::test]
async fn reset_delivered_readmits() {
let (f, sink) = capture();
let tool = AdviseTool::new(f);
tool.execute("t1", json!({"note": "tip"}), None, &ctx())
.await
.unwrap();
tool.reset_delivered();
tool.execute("t2", json!({"note": "tip"}), None, &ctx())
.await
.unwrap();
assert_eq!(sink.lock().unwrap().len(), 2);
}
#[tokio::test]
async fn omitted_severity_is_nit() {
let (f, sink) = capture();
let tool = AdviseTool::new(f);
tool.execute("t1", json!({"note": "tip"}), None, &ctx())
.await
.unwrap();
assert_eq!(sink.lock().unwrap()[0].severity, None);
assert_eq!(sink.lock().unwrap()[0].rank(), 1);
}
}