返回 CodeWhale
advisor.rs
根目录 / crates / tui / src / tools / subagent / advisor.rs
1 //! Background advisor watcher (#3982).
2 //!
3 //! When enabled, the advisor wakes on turn boundaries, reads a bounded slice
4 //! of recent tool calls from the session transcript, makes a concise LLM
5 //! advisory call (reusing the same `DeepSeekClient` as the parent turn), and
6 //! emits an [`Event::AdvisoryNote`] fire-and-forget.
7 //!
8 //! Key design properties:
9 //! - **Off by default** — enabled via `[advisor] enabled = true` or `/advisor on`.
10 //! - **Bounded input** — at most `max_tool_calls` tool-call/result pairs are
11 //! included; the rest are dropped oldest-first.
12 //! - **Rate-limited** — at most one emission per `rate_limit_secs` seconds.
13 //! - **Deduplicated** — notes whose content hash matches the previous note
14 //! within `dedup_window_secs` are silently dropped.
15 //! - **Child-failure isolated** — advisor errors are logged but never surface
16 //! as parent turn failures.
17 //! - **Policy-bounded** — the advisor uses a read-only reviewer prompt and
18 //! no tool access; it cannot exceed the parent session policy.
19
20 use std::collections::hash_map::DefaultHasher;
21 use std::hash::{Hash, Hasher};
22 use std::time::{Duration, Instant};
23
24 use codewhale_config::AdvisorConfigToml;
25 use tokio::sync::mpsc;
26 use tracing::debug;
27
28 use crate::client::DeepSeekClient;
29 use crate::core::events::Event;
30 use crate::llm_client::LlmClient;
31 use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt};
32 use crate::utils::truncate_with_ellipsis;
33
34 /// Maximum tokens the advisor may generate. Kept short so the note stays
35 /// concise and does not compete with the parent turn's billing budget.
36 const ADVISOR_MAX_TOKENS: u32 = 256;
37
38 /// Maximum characters of tool input + result to include per tool-call pair.
39 const MAX_CHARS_PER_PAIR: usize = 800;
40
41 /// System prompt for the advisor LLM call. Read-only review posture — no
42 /// tool access, no code generation.
43 const ADVISOR_SYSTEM_PROMPT: &str = "You are a concise background advisor reviewing recent tool activity. \
44 Your role: identify one or two concrete concerns (correctness, risk, or \
45 missed alternatives) in the tool calls provided. \
46 If nothing notable stands out, respond with exactly the word \"ok\". \
47 Otherwise write one to three short sentences — no preamble, no markdown, \
48 no praise. Focus on signal; omit noise.";
49
50 /// A single tool-call/result pair extracted from the session transcript.
51 #[derive(Debug, Clone)]
52 pub struct ToolCallPair {
53 /// Tool name (e.g. `exec_shell`, `file_write`).
54 pub name: String,
55 /// Bounded serialization of the tool input.
56 pub input_preview: String,
57 /// Bounded serialization of the tool result.
58 pub result_preview: String,
59 }
60
61 /// Resolved advisor configuration derived from [`AdvisorConfigToml`].
62 #[derive(Debug, Clone)]
63 pub struct AdvisorConfig {
64 /// Whether the advisor is currently enabled (session-level toggle).
65 pub enabled: bool,
66 /// Max tool-call pairs to review per turn.
67 pub max_tool_calls: u32,
68 /// Min seconds between consecutive emissions.
69 pub rate_limit: Duration,
70 /// Window during which duplicate notes are suppressed.
71 pub dedup_window: Duration,
72 /// Optional model override (falls back to session model when `None`).
73 pub model: Option<String>,
74 }
75
76 impl AdvisorConfig {
77 /// Build a resolved config from the TOML schema.
78 #[must_use]
79 pub fn from_toml(toml: &AdvisorConfigToml) -> Self {
80 Self {
81 enabled: toml.enabled,
82 max_tool_calls: toml.max_tool_calls.clamp(1, 50),
83 rate_limit: Duration::from_secs(toml.rate_limit_secs.clamp(5, 3600)),
84 dedup_window: Duration::from_secs(toml.dedup_window_secs),
85 model: toml.model.clone(),
86 }
87 }
88
89 /// Default disabled config (matches `[advisor]` absent from config.toml).
90 #[must_use]
91 pub fn disabled() -> Self {
92 Self {
93 enabled: false,
94 max_tool_calls: 10,
95 rate_limit: Duration::from_secs(60),
96 dedup_window: Duration::from_secs(300),
97 model: None,
98 }
99 }
100 }
101
102 /// Runtime emission guard: tracks the last emission time and the hash of the
103 /// last advisory note to enforce rate limiting and deduplication.
104 #[derive(Debug)]
105 pub struct EmissionGuard {
106 last_emission: Option<Instant>,
107 last_note_hash: Option<u64>,
108 last_note_hash_at: Option<Instant>,
109 }
110
111 impl EmissionGuard {
112 /// Create a fresh guard with no emission history.
113 #[must_use]
114 pub fn new() -> Self {
115 Self {
116 last_emission: None,
117 last_note_hash: None,
118 last_note_hash_at: None,
119 }
120 }
121
122 /// Check whether emitting `note` is allowed under `config`'s rate-limit
123 /// and dedup policy. Returns `true` when the note may be emitted.
124 #[must_use]
125 pub fn may_emit(&self, note: &str, config: &AdvisorConfig) -> bool {
126 // Suppress trivial "ok" responses from the model.
127 if note.trim().eq_ignore_ascii_case("ok") {
128 return false;
129 }
130
131 let now = Instant::now();
132
133 // Rate limit: require at least `rate_limit` since last emission.
134 if let Some(last) = self.last_emission
135 && now.duration_since(last) < config.rate_limit
136 {
137 return false;
138 }
139
140 // Dedup: suppress if the note content hash matches the previous note
141 // within the dedup window.
142 let note_hash = hash_str(note);
143 if let (Some(prev_hash), Some(prev_at)) = (self.last_note_hash, self.last_note_hash_at)
144 && prev_hash == note_hash
145 && now.duration_since(prev_at) < config.dedup_window
146 {
147 return false;
148 }
149
150 true
151 }
152
153 /// Record that `note` was emitted now. Must be called immediately after
154 /// sending the `AdvisoryNote` event.
155 pub fn record_emission(&mut self, note: &str) {
156 let now = Instant::now();
157 self.last_emission = Some(now);
158 self.last_note_hash = Some(hash_str(note));
159 self.last_note_hash_at = Some(now);
160 }
161 }
162
163 impl Default for EmissionGuard {
164 fn default() -> Self {
165 Self::new()
166 }
167 }
168
169 /// Extract bounded tool-call/result pairs from a session message slice.
170 ///
171 /// Scans `messages` in reverse (newest first), collects up to `max_pairs`
172 /// `ToolUse`/`ToolResult` pairs, then returns them oldest-first.
173 #[must_use]
174 pub fn extract_tool_call_pairs(messages: &[Message], max_pairs: usize) -> Vec<ToolCallPair> {
175 // Collect ToolUse names+inputs (from assistant messages) and
176 // ToolResult texts (from user messages) into a pairing structure.
177 let mut uses: Vec<(String, String, String)> = Vec::new(); // (id, name, input)
178 let mut results: std::collections::HashMap<String, String> = std::collections::HashMap::new();
179
180 for msg in messages {
181 for block in &msg.content {
182 match block {
183 ContentBlock::ToolUse {
184 id, name, input, ..
185 } => {
186 let input_str = truncate_with_ellipsis(
187 &serde_json::to_string(input).unwrap_or_default(),
188 MAX_CHARS_PER_PAIR / 2,
189 "…",
190 );
191 uses.push((id.clone(), name.clone(), input_str));
192 }
193 ContentBlock::ToolResult {
194 tool_use_id,
195 content,
196 ..
197 } => {
198 results.insert(
199 tool_use_id.clone(),
200 truncate_with_ellipsis(content, MAX_CHARS_PER_PAIR / 2, "…"),
201 );
202 }
203 _ => {}
204 }
205 }
206 }
207
208 // Match uses with results and take the last `max_pairs`.
209 let start = uses.len().saturating_sub(max_pairs);
210 uses[start..]
211 .iter()
212 .map(|(id, name, input)| {
213 let result = results
214 .get(id.as_str())
215 .cloned()
216 .unwrap_or_else(|| "(pending)".to_string());
217 ToolCallPair {
218 name: name.clone(),
219 input_preview: input.clone(),
220 result_preview: result,
221 }
222 })
223 .collect()
224 }
225
226 /// Build the user prompt for the advisor from a slice of tool-call pairs.
227 #[must_use]
228 pub fn build_advisor_prompt(pairs: &[ToolCallPair]) -> String {
229 let mut out = String::from("Recent tool activity to review (oldest → newest):\n\n");
230 for (i, pair) in pairs.iter().enumerate() {
231 out.push_str(&format!(
232 "{}. tool={}\n input: {}\n result: {}\n\n",
233 i + 1,
234 pair.name,
235 pair.input_preview,
236 pair.result_preview
237 ));
238 }
239 out.push_str(
240 "Provide your advisory in one to three sentences, or respond with \"ok\" if nothing notable.",
241 );
242 out
243 }
244
245 /// Run one advisor review cycle for a completed turn.
246 ///
247 /// This is the async work dispatched by `spawn_supervised` in the engine. It:
248 /// 1. Checks whether emission is allowed by `guard`.
249 /// 2. Extracts bounded tool-call pairs from `messages`.
250 /// 3. Makes a non-streaming LLM call with a short read-only prompt.
251 /// 4. Checks emission again (the LLM call may have taken time).
252 /// 5. Sends `Event::AdvisoryNote` if the note passes the guard.
253 ///
254 /// All errors are logged and swallowed — the advisor must never fail the
255 /// parent turn.
256 pub async fn run_advisor_for_turn(
257 turn_id: String,
258 messages: Vec<Message>,
259 config: AdvisorConfig,
260 client: DeepSeekClient,
261 session_model: String,
262 guard: std::sync::Arc<tokio::sync::Mutex<EmissionGuard>>,
263 tx_event: mpsc::Sender<Event>,
264 ) {
265 // Pre-flight: skip if the guard already blocks (avoids the LLM call when
266 // rate-limited, which is the common case for rapid turn sequences).
267 {
268 let g = guard.lock().await;
269 // We don't have the note content yet, so we only check the rate limit
270 // here by testing with a placeholder. The dedup check runs after the
271 // LLM call, when we have the actual content.
272 if let Some(last) = g.last_emission
273 && std::time::Instant::now().duration_since(last) < config.rate_limit
274 {
275 debug!(target: "advisor", "rate-limited, skipping advisor run for turn {turn_id}");
276 return;
277 }
278 }
279
280 // Extract a bounded slice of tool-call pairs.
281 let pairs = extract_tool_call_pairs(&messages, config.max_tool_calls as usize);
282 if pairs.is_empty() {
283 debug!(target: "advisor", "no tool calls found; skipping advisor for turn {turn_id}");
284 return;
285 }
286
287 let tool_call_count = pairs.len() as u32;
288 let prompt = build_advisor_prompt(&pairs);
289 let model = config
290 .model
291 .clone()
292 .unwrap_or_else(|| session_model.clone());
293
294 let request = MessageRequest {
295 model: model.clone(),
296 messages: vec![Message {
297 role: "user".to_string(),
298 content: vec![ContentBlock::Text {
299 text: prompt,
300 cache_control: None,
301 }],
302 }],
303 max_tokens: ADVISOR_MAX_TOKENS,
304 system: Some(SystemPrompt::Text(ADVISOR_SYSTEM_PROMPT.to_string())),
305 tools: None,
306 tool_choice: None,
307 metadata: None,
308 thinking: None,
309 reasoning_effort: None,
310 stream: Some(false),
311 temperature: Some(0.3),
312 top_p: Some(0.9),
313 };
314
315 let response = match client.create_message(request).await {
316 Ok(r) => r,
317 Err(e) => {
318 tracing::warn!(target: "advisor", "advisor LLM call failed for turn {turn_id}: {e}");
319 return;
320 }
321 };
322
323 // Extract the text from the response.
324 let note: String = response
325 .content
326 .iter()
327 .filter_map(|block| {
328 if let ContentBlock::Text { text, .. } = block {
329 Some(text.as_str())
330 } else {
331 None
332 }
333 })
334 .collect::<Vec<_>>()
335 .join("\n")
336 .trim()
337 .to_string();
338
339 if note.is_empty() {
340 debug!(target: "advisor", "empty advisor response for turn {turn_id}; skipping");
341 return;
342 }
343
344 // Post-flight emission check (rate limit + dedup).
345 let mut guard_lock = guard.lock().await;
346 if !guard_lock.may_emit(&note, &config) {
347 debug!(target: "advisor", "emission suppressed by guard for turn {turn_id}");
348 return;
349 }
350
351 guard_lock.record_emission(&note);
352 drop(guard_lock);
353
354 let _ = tx_event
355 .send(Event::AdvisoryNote {
356 turn_id: turn_id.clone(),
357 note: note.clone(),
358 tool_call_count,
359 })
360 .await;
361
362 debug!(target: "advisor", "advisory note emitted for turn {turn_id} ({tool_call_count} tool calls reviewed)");
363 }
364
365 fn hash_str(s: &str) -> u64 {
366 let mut h = DefaultHasher::new();
367 s.hash(&mut h);
368 h.finish()
369 }
370
371 // ── Tests ──────────────────────────────────────────────────────────────────
372
373 #[cfg(test)]
374 mod tests {
375 use super::*;
376 use std::time::Duration;
377
378 fn test_config() -> AdvisorConfig {
379 AdvisorConfig {
380 enabled: true,
381 max_tool_calls: 5,
382 rate_limit: Duration::from_secs(1),
383 dedup_window: Duration::from_secs(10),
384 model: None,
385 }
386 }
387
388 // ── enable/disable ────────────────────────────────────────────────────
389
390 #[test]
391 fn disabled_config_has_enabled_false() {
392 let cfg = AdvisorConfig::disabled();
393 assert!(!cfg.enabled);
394 }
395
396 #[test]
397 fn from_toml_clamps_max_tool_calls() {
398 let toml = AdvisorConfigToml {
399 enabled: true,
400 max_tool_calls: 999,
401 rate_limit_secs: 60,
402 dedup_window_secs: 300,
403 model: None,
404 };
405 let cfg = AdvisorConfig::from_toml(&toml);
406 assert_eq!(
407 cfg.max_tool_calls, 50,
408 "max_tool_calls must be clamped to 50"
409 );
410 }
411
412 #[test]
413 fn from_toml_clamps_rate_limit() {
414 let toml = AdvisorConfigToml {
415 enabled: true,
416 max_tool_calls: 10,
417 rate_limit_secs: 0, // below minimum of 5
418 dedup_window_secs: 300,
419 model: None,
420 };
421 let cfg = AdvisorConfig::from_toml(&toml);
422 assert!(
423 cfg.rate_limit >= Duration::from_secs(5),
424 "rate_limit must be at least 5s"
425 );
426 }
427
428 // ── bounded input ─────────────────────────────────────────────────────
429
430 fn make_messages_with_n_tool_calls(n: usize) -> Vec<Message> {
431 let mut messages = Vec::new();
432 for i in 0..n {
433 let id = format!("tool_{i}");
434 // assistant message with ToolUse
435 messages.push(Message {
436 role: "assistant".to_string(),
437 content: vec![ContentBlock::ToolUse {
438 id: id.clone(),
439 name: "exec_shell".to_string(),
440 input: serde_json::json!({"command": format!("echo {i}")}),
441 caller: None,
442 }],
443 });
444 // user message with ToolResult
445 messages.push(Message {
446 role: "user".to_string(),
447 content: vec![ContentBlock::ToolResult {
448 tool_use_id: id,
449 content: format!("{i}"),
450 is_error: None,
451 content_blocks: None,
452 }],
453 });
454 }
455 messages
456 }
457
458 #[test]
459 fn extract_tool_call_pairs_bounded_by_max() {
460 let messages = make_messages_with_n_tool_calls(20);
461 let pairs = extract_tool_call_pairs(&messages, 5);
462 assert_eq!(pairs.len(), 5, "must return at most max_pairs");
463 // Should be the last 5 (newest).
464 assert_eq!(pairs[0].name, "exec_shell");
465 }
466
467 #[test]
468 fn extract_tool_call_pairs_empty_when_no_tool_calls() {
469 let messages = vec![Message {
470 role: "user".to_string(),
471 content: vec![ContentBlock::Text {
472 text: "hello".to_string(),
473 cache_control: None,
474 }],
475 }];
476 let pairs = extract_tool_call_pairs(&messages, 5);
477 assert!(pairs.is_empty());
478 }
479
480 #[test]
481 fn extract_tool_call_pairs_fewer_than_max_returns_all() {
482 let messages = make_messages_with_n_tool_calls(3);
483 let pairs = extract_tool_call_pairs(&messages, 10);
484 assert_eq!(pairs.len(), 3);
485 }
486
487 // ── rate limiting ─────────────────────────────────────────────────────
488
489 #[test]
490 fn emission_guard_allows_first_emission() {
491 let guard = EmissionGuard::new();
492 let config = test_config();
493 assert!(
494 guard.may_emit("something concerning here", &config),
495 "first emission must be allowed"
496 );
497 }
498
499 #[test]
500 fn emission_guard_blocks_immediately_after_emission() {
501 let mut guard = EmissionGuard::new();
502 let config = test_config();
503 let note = "something concerning";
504 guard.record_emission(note);
505 assert!(
506 !guard.may_emit("a completely different note", &config),
507 "emission must be blocked immediately after a prior emission (rate limit)"
508 );
509 }
510
511 #[test]
512 fn emission_guard_allows_after_rate_limit_expires() {
513 let mut guard = EmissionGuard::new();
514 // Rate limit of 0ms — always expired.
515 let config = AdvisorConfig {
516 rate_limit: Duration::ZERO,
517 dedup_window: Duration::from_secs(300),
518 ..AdvisorConfig::disabled()
519 };
520 let note = "first note";
521 guard.record_emission(note);
522 assert!(
523 guard.may_emit("second different note", &config),
524 "emission must be allowed when rate limit duration is zero"
525 );
526 }
527
528 // ── deduplication ─────────────────────────────────────────────────────
529
530 #[test]
531 fn emission_guard_suppresses_ok_response() {
532 let guard = EmissionGuard::new();
533 let config = test_config();
534 assert!(!guard.may_emit("ok", &config), "\"ok\" must be suppressed");
535 assert!(!guard.may_emit("OK", &config), "\"OK\" must be suppressed");
536 assert!(
537 !guard.may_emit(" ok ", &config),
538 "\" ok \" must be suppressed"
539 );
540 }
541
542 #[test]
543 fn emission_guard_dedup_blocks_identical_note_within_window() {
544 let mut guard = EmissionGuard::new();
545 // Use a zero rate limit so only dedup is tested.
546 let config = AdvisorConfig {
547 rate_limit: Duration::ZERO,
548 dedup_window: Duration::from_secs(300),
549 ..AdvisorConfig::disabled()
550 };
551 let note = "risky shell command with no error checking";
552 guard.record_emission(note);
553 assert!(
554 !guard.may_emit(note, &config),
555 "identical note must be suppressed within the dedup window"
556 );
557 }
558
559 #[test]
560 fn emission_guard_allows_different_note_within_dedup_window() {
561 let mut guard = EmissionGuard::new();
562 let config = AdvisorConfig {
563 rate_limit: Duration::ZERO,
564 dedup_window: Duration::from_secs(300),
565 ..AdvisorConfig::disabled()
566 };
567 guard.record_emission("first note");
568 assert!(
569 guard.may_emit("entirely different note", &config),
570 "a different note must be allowed even within the dedup window"
571 );
572 }
573
574 // ── child failure isolation ───────────────────────────────────────────
575
576 #[test]
577 fn advisor_prompt_is_non_empty_for_non_empty_pairs() {
578 let pairs = vec![ToolCallPair {
579 name: "exec_shell".to_string(),
580 input_preview: r#"{"command":"ls -la"}"#.to_string(),
581 result_preview: "total 4\ndrwxr-xr-x 2 user user 4096".to_string(),
582 }];
583 let prompt = build_advisor_prompt(&pairs);
584 assert!(
585 prompt.contains("exec_shell"),
586 "prompt must include the tool name"
587 );
588 assert!(
589 prompt.contains("ls -la"),
590 "prompt must include the tool input"
591 );
592 }
593 }
594
594 lines RUST