1use std::collections::HashMap;
16use std::sync::Arc;
17
18use async_trait::async_trait;
19use parking_lot::Mutex;
20use serde_json::{Value, json};
21
22use crate::advisor::types::{AdvisorNote, AdvisorSeverity, note_dedupe_key};
23use crate::{AgentTool, AgentToolResult, ToolContext};
24
25const ADVISE_DESCRIPTION: &str = include_str!("prompts/advise-tool.md");
28
29pub type EnqueueAdviceFn = Arc<dyn Fn(AdvisorNote) + Send + Sync>;
34
35pub struct AdviseTool {
37 enqueue: EnqueueAdviceFn,
38 delivered: Mutex<HashMap<String, u8>>,
43}
44
45impl AdviseTool {
46 #[must_use]
48 pub fn new(enqueue: EnqueueAdviceFn) -> Self {
49 Self {
50 enqueue,
51 delivered: Mutex::new(HashMap::new()),
52 }
53 }
54
55 pub fn reset_delivered(&self) {
58 self.delivered.lock().clear();
59 }
60}
61
62#[async_trait]
63impl AgentTool for AdviseTool {
64 fn name(&self) -> &str {
65 "advise"
66 }
67
68 fn label(&self) -> &str {
69 "Advise"
70 }
71
72 fn description(&self) -> &str {
73 ADVISE_DESCRIPTION
74 }
75
76 fn parameters_schema(&self) -> Value {
77 json!({
78 "type": "object",
79 "properties": {
80 "note": {
81 "type": "string",
82 "description": "One concrete piece of advice for the agent you are watching. Terse, specific, actionable."
83 },
84 "severity": {
85 "type": "string",
86 "enum": ["nit", "concern", "blocker"],
87 "description": "How strongly to weigh this. Omit for a plain nit."
88 }
89 },
90 "required": ["note"]
91 })
92 }
93
94 fn essential(&self) -> bool {
95 false
96 }
97
98 async fn execute(
99 &self,
100 _tool_call_id: &str,
101 params: Value,
102 _signal: Option<tokio::sync::oneshot::Receiver<()>>,
103 _ctx: &ToolContext,
104 ) -> Result<AgentToolResult, String> {
105 let note_text = params
106 .get("note")
107 .and_then(Value::as_str)
108 .ok_or_else(|| "advise: missing string field 'note'".to_string())?
109 .trim()
110 .to_string();
111 if note_text.is_empty() {
112 return Err("advise: 'note' must be non-empty".into());
113 }
114 let severity = params
115 .get("severity")
116 .and_then(Value::as_str)
117 .and_then(AdvisorSeverity::from_id);
118
119 let key = note_dedupe_key(¬e_text);
120 let rank = severity.unwrap_or_default().rank();
121 let prev = *self.delivered.lock().get(&key).unwrap_or(&0);
122 if rank <= prev {
123 return Ok(
127 AgentToolResult::success("Duplicate advice ignored.").with_metadata(json!({
128 "note": note_text,
129 "severity": severity.map(AdvisorSeverity::as_str),
130 "duplicate": true,
131 })),
132 );
133 }
134 self.delivered.lock().insert(key, rank);
135 (self.enqueue)(AdvisorNote {
136 note: note_text.clone(),
137 severity,
138 });
139 Ok(AgentToolResult::success("Recorded.").with_metadata(json!({
140 "note": note_text,
141 "severity": severity.map(AdvisorSeverity::as_str),
142 "duplicate": false,
143 })))
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 #![allow(clippy::unwrap_used)]
150 use super::*;
151 use crate::ToolContext;
152 use std::path::PathBuf;
153 use std::sync::Mutex as StdMutex;
154
155 fn ctx() -> ToolContext {
156 ToolContext::new(PathBuf::from("."))
157 }
158
159 fn capture() -> (EnqueueAdviceFn, Arc<StdMutex<Vec<AdvisorNote>>>) {
162 let sink = Arc::new(StdMutex::new(Vec::<AdvisorNote>::new()));
163 let sink_clone = Arc::clone(&sink);
164 let f: EnqueueAdviceFn = Arc::new(move |n| sink_clone.lock().unwrap().push(n));
165 (f, sink)
166 }
167
168 #[tokio::test]
169 async fn records_first_note_and_enqueues() {
170 let (f, sink) = capture();
171 let tool = AdviseTool::new(f);
172 let res = tool
173 .execute(
174 "t1",
175 json!({"note": "Use saturating_add", "severity": "concern"}),
176 None,
177 &ctx(),
178 )
179 .await
180 .unwrap();
181 assert!(res.success);
182 assert_eq!(res.output, "Recorded.");
183 assert_eq!(sink.lock().unwrap().len(), 1);
184 assert_eq!(
185 sink.lock().unwrap()[0].severity,
186 Some(AdvisorSeverity::Concern)
187 );
188 }
189
190 #[tokio::test]
191 async fn dedupes_lower_or_equal_severity() {
192 let (f, sink) = capture();
193 let tool = AdviseTool::new(f);
194 tool.execute("t1", json!({"note": "rename x"}), None, &ctx())
196 .await
197 .unwrap();
198 let res = tool
200 .execute("t2", json!({"note": "rename x"}), None, &ctx())
201 .await
202 .unwrap();
203 assert_eq!(res.output, "Duplicate advice ignored.");
204 tool.execute(
206 "t3",
207 json!({"note": "rename x", "severity": "concern"}),
208 None,
209 &ctx(),
210 )
211 .await
212 .unwrap();
213 assert_eq!(sink.lock().unwrap().len(), 2);
215 }
216
217 #[tokio::test]
218 async fn whitespace_variants_dedupe() {
219 let (f, sink) = capture();
220 let tool = AdviseTool::new(f);
221 tool.execute("t1", json!({"note": "rename x"}), None, &ctx())
222 .await
223 .unwrap();
224 let res = tool
225 .execute("t2", json!({"note": " rename x "}), None, &ctx())
226 .await
227 .unwrap();
228 assert_eq!(res.output, "Duplicate advice ignored.");
229 assert_eq!(sink.lock().unwrap().len(), 1);
230 }
231
232 #[tokio::test]
233 async fn empty_note_errors() {
234 let (f, _sink) = capture();
235 let tool = AdviseTool::new(f);
236 let res = tool
237 .execute("t1", json!({"note": " "}), None, &ctx())
238 .await;
239 assert!(res.is_err());
240 }
241
242 #[tokio::test]
243 async fn reset_delivered_readmits() {
244 let (f, sink) = capture();
245 let tool = AdviseTool::new(f);
246 tool.execute("t1", json!({"note": "tip"}), None, &ctx())
247 .await
248 .unwrap();
249 tool.reset_delivered();
250 tool.execute("t2", json!({"note": "tip"}), None, &ctx())
251 .await
252 .unwrap();
253 assert_eq!(sink.lock().unwrap().len(), 2);
254 }
255
256 #[tokio::test]
257 async fn omitted_severity_is_nit() {
258 let (f, sink) = capture();
259 let tool = AdviseTool::new(f);
260 tool.execute("t1", json!({"note": "tip"}), None, &ctx())
261 .await
262 .unwrap();
263 assert_eq!(sink.lock().unwrap()[0].severity, None);
264 assert_eq!(sink.lock().unwrap()[0].rank(), 1);
265 }
266}