Skip to main content

oxi_agent/advisor/
advise_tool.rs

1//! The `advise` agent tool — ported from omp `AdviseTool` (advise-tool.ts).
2//!
3//! The advisor LLM calls this to surface one piece of advice to the primary
4//! agent. The tool applies a severity-rank dedupe (a call passes only when its
5//! rank strictly exceeds the previously-delivered rank for that note — a real
6//! escalation `nit → concern → blocker`), then hands accepted notes to a
7//! host-provided [`EnqueueAdviceFn`] callback. The host owns the
8//! [`crate::advisor::emission_guard::AdvisorEmissionGuard`] (the final
9//! per-update/dedupe/content-free gate) and routes the note to the primary.
10//!
11//! # Attribution
12//!
13//! Translated to Rust from omp (oh-my-pi), MIT licensed.
14
15use 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
25/// The tool description, embedded from the omp prompt so the engine is
26/// self-contained (SDK consumers get a ready tool without supplying the text).
27const ADVISE_DESCRIPTION: &str = include_str!("prompts/advise-tool.md");
28
29/// Host callback invoked for each *dedupe-passing* note. The host's
30/// [`crate::advisor::emission_guard::AdvisorEmissionGuard`] (not this tool) is
31/// the final accept/reject authority; this callback only fires when this tool's
32/// own severity-rank gate admits the note.
33pub type EnqueueAdviceFn = Arc<dyn Fn(AdvisorNote) + Send + Sync>;
34
35/// The `advise` agent tool.
36pub struct AdviseTool {
37    enqueue: EnqueueAdviceFn,
38    /// Highest delivered severity rank per dedupe key. A new call passes
39    /// through only when its rank strictly exceeds the recorded one, so an
40    /// advisor cannot bypass dedupe by retagging the same text at a lower or
41    /// equal severity. omp `#deliveredNoteSeverities`.
42    delivered: Mutex<HashMap<String, u8>>,
43}
44
45impl AdviseTool {
46    /// Construct with the host-provided advice-enqueue callback.
47    #[must_use]
48    pub fn new(enqueue: EnqueueAdviceFn) -> Self {
49        Self {
50            enqueue,
51            delivered: Mutex::new(HashMap::new()),
52        }
53    }
54
55    /// Clear delivered-note memory when the advisor starts a fresh conversation
56    /// (omp `resetDeliveredNotes`). Called from the host's advisor-reset path.
57    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(&note_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            // Duplicate — the same note at a lower or equal severity. Still
124            // return success so the advisor model isn't prompted to retry;
125            // omp returns `Duplicate advice ignored.`
126            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    /// Capture enqueued notes in a vec behind a std Mutex (test-only; the
160    /// closure itself must be Send+Sync so parking_lot is not required here).
161    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        // nit first
195        tool.execute("t1", json!({"note": "rename x"}), None, &ctx())
196            .await
197            .unwrap();
198        // same note, nit again -> ignored
199        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        // same note, concern -> escalation passes
205        tool.execute(
206            "t3",
207            json!({"note": "rename x", "severity": "concern"}),
208            None,
209            &ctx(),
210        )
211        .await
212        .unwrap();
213        // 2 enqueued (first nit + escalation concern); the duplicate was not.
214        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}