返回 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 on an exactly resolved provider/model client, 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::CodewhaleClient;
29 use crate::config::Config;
30 use crate::core::events::Event;
31 use crate::llm_client::LlmClient;
32 use crate::utils::truncate_with_ellipsis;
33 use codewhale_models::Role;
34 use codewhale_models::{ContentBlock, Message, MessageRequest, SystemPrompt};
35
36 /// Maximum tokens the advisor may generate. Kept short so the note stays
37 /// concise and does not compete with the parent turn's billing budget.
38 const ADVISOR_MAX_TOKENS: u32 = 256;
39
40 /// Maximum characters of tool input + result to include per tool-call pair.
41 const MAX_CHARS_PER_PAIR: usize = 800;
42
43 /// System prompt for the advisor LLM call. Read-only review posture — no
44 /// tool access, no code generation.
45 const ADVISOR_SYSTEM_PROMPT: &str = "You are a concise background advisor reviewing recent tool activity. \
46 Your role: identify one or two concrete concerns (correctness, risk, or \
47 missed alternatives) in the tool calls provided. \
48 If nothing notable stands out, respond with exactly the word \"ok\". \
49 Otherwise write one to three short sentences — no preamble, no markdown, \
50 no praise. Focus on signal; omit noise.";
51
52 /// A single tool-call/result pair extracted from the session transcript.
53 #[derive(Debug, Clone)]
54 pub struct ToolCallPair {
55 /// Tool name (e.g. `exec_shell`, `file_write`).
56 pub name: String,
57 /// Bounded serialization of the tool input.
58 pub input_preview: String,
59 /// Bounded serialization of the tool result.
60 pub result_preview: String,
61 }
62
63 /// Resolved advisor configuration derived from [`AdvisorConfigToml`].
64 #[derive(Debug, Clone)]
65 pub struct AdvisorConfig {
66 /// Whether the advisor is currently enabled (session-level toggle).
67 pub enabled: bool,
68 /// Max tool-call pairs to review per turn.
69 pub max_tool_calls: u32,
70 /// Min seconds between consecutive emissions.
71 pub rate_limit: Duration,
72 /// Window during which duplicate notes are suppressed.
73 pub dedup_window: Duration,
74 /// Optional model override (falls back to session model when `None`).
75 pub model: Option<String>,
76 }
77
78 impl AdvisorConfig {
79 /// Build a resolved config from the TOML schema.
80 #[must_use]
81 pub fn from_toml(toml: &AdvisorConfigToml) -> Self {
82 Self {
83 enabled: toml.enabled,
84 max_tool_calls: toml.max_tool_calls.clamp(1, 50),
85 rate_limit: Duration::from_secs(toml.rate_limit_secs.clamp(5, 3600)),
86 dedup_window: Duration::from_secs(toml.dedup_window_secs),
87 model: toml.model.clone(),
88 }
89 }
90
91 /// Default disabled config (matches `[advisor]` absent from config.toml).
92 #[must_use]
93 pub fn disabled() -> Self {
94 Self {
95 enabled: false,
96 max_tool_calls: 10,
97 rate_limit: Duration::from_secs(60),
98 dedup_window: Duration::from_secs(300),
99 model: None,
100 }
101 }
102 }
103
104 /// Runtime emission guard: tracks the last emission time and the hash of the
105 /// last advisory note to enforce rate limiting and deduplication.
106 #[derive(Debug)]
107 pub struct EmissionGuard {
108 last_emission: Option<Instant>,
109 last_note_hash: Option<u64>,
110 last_note_hash_at: Option<Instant>,
111 }
112
113 /// Accounting ownership captured while the originating turn is still live.
114 ///
115 /// Runtime turns retain their synchronous durable sink through the lease;
116 /// ordinary interactive turns fall back to the exact session cost generation
117 /// captured here. Neither path can spill into a later session.
118 #[derive(Debug)]
119 pub(crate) struct AdvisorUsageContext {
120 cost_scope: crate::cost_status::CostScopeToken,
121 runtime_usage_lease: Option<crate::cost_status::RuntimeUsageLease>,
122 }
123
124 impl AdvisorUsageContext {
125 #[must_use]
126 pub(crate) fn capture(runtime_owner: Option<&str>) -> Self {
127 Self {
128 cost_scope: crate::cost_status::scope_token(),
129 runtime_usage_lease: runtime_owner
130 .and_then(crate::cost_status::acquire_runtime_usage_lease),
131 }
132 }
133
134 fn report(
135 &self,
136 source_id: &str,
137 route: &crate::cost_status::EffectiveRouteEnvelope,
138 usage: &codewhale_models::Usage,
139 ) {
140 crate::cost_status::report_effective_route_for_runtime(
141 self.cost_scope,
142 self.runtime_usage_lease
143 .as_ref()
144 .map(crate::cost_status::RuntimeUsageLease::owner),
145 source_id,
146 route,
147 usage,
148 );
149 }
150
151 fn report_unreceipted(
152 &self,
153 source_id: &str,
154 route: &crate::cost_status::EffectiveRouteEnvelope,
155 ) {
156 crate::cost_status::report_unreceipted_provider_success(
157 self.cost_scope,
158 self.runtime_usage_lease
159 .as_ref()
160 .map(crate::cost_status::RuntimeUsageLease::owner),
161 source_id,
162 route,
163 );
164 }
165 }
166
167 impl EmissionGuard {
168 /// Create a fresh guard with no emission history.
169 #[must_use]
170 pub fn new() -> Self {
171 Self {
172 last_emission: None,
173 last_note_hash: None,
174 last_note_hash_at: None,
175 }
176 }
177
178 /// Check whether emitting `note` is allowed under `config`'s rate-limit
179 /// and dedup policy. Returns `true` when the note may be emitted.
180 #[must_use]
181 pub fn may_emit(&self, note: &str, config: &AdvisorConfig) -> bool {
182 // Suppress trivial "ok" responses from the model.
183 if note.trim().eq_ignore_ascii_case("ok") {
184 return false;
185 }
186
187 let now = Instant::now();
188
189 // Rate limit: require at least `rate_limit` since last emission.
190 if let Some(last) = self.last_emission
191 && now.duration_since(last) < config.rate_limit
192 {
193 return false;
194 }
195
196 // Dedup: suppress if the note content hash matches the previous note
197 // within the dedup window.
198 let note_hash = hash_str(note);
199 if let (Some(prev_hash), Some(prev_at)) = (self.last_note_hash, self.last_note_hash_at)
200 && prev_hash == note_hash
201 && now.duration_since(prev_at) < config.dedup_window
202 {
203 return false;
204 }
205
206 true
207 }
208
209 /// Record that `note` was emitted now. Must be called immediately after
210 /// sending the `AdvisoryNote` event.
211 pub fn record_emission(&mut self, note: &str) {
212 let now = Instant::now();
213 self.last_emission = Some(now);
214 self.last_note_hash = Some(hash_str(note));
215 self.last_note_hash_at = Some(now);
216 }
217 }
218
219 impl Default for EmissionGuard {
220 fn default() -> Self {
221 Self::new()
222 }
223 }
224
225 /// Extract bounded tool-call/result pairs from a session message slice.
226 ///
227 /// Scans `messages` in reverse (newest first), collects up to `max_pairs`
228 /// `ToolUse`/`ToolResult` pairs, then returns them oldest-first.
229 #[must_use]
230 pub fn extract_tool_call_pairs(messages: &[Message], max_pairs: usize) -> Vec<ToolCallPair> {
231 // Collect ToolUse names+inputs (from assistant messages) and
232 // ToolResult texts (from user messages) into a pairing structure.
233 let mut uses: Vec<(String, String, String)> = Vec::new(); // (id, name, input)
234 let mut results: std::collections::HashMap<String, String> = std::collections::HashMap::new();
235
236 for msg in messages {
237 for block in &msg.content {
238 match block {
239 ContentBlock::ToolUse {
240 id, name, input, ..
241 } => {
242 let input_str = truncate_with_ellipsis(
243 &serde_json::to_string(input).unwrap_or_default(),
244 MAX_CHARS_PER_PAIR / 2,
245 "…",
246 );
247 uses.push((id.clone(), name.clone(), input_str));
248 }
249 ContentBlock::ToolResult {
250 tool_use_id,
251 content,
252 ..
253 } => {
254 results.insert(
255 tool_use_id.clone(),
256 truncate_with_ellipsis(content, MAX_CHARS_PER_PAIR / 2, "…"),
257 );
258 }
259 _ => {}
260 }
261 }
262 }
263
264 // Match uses with results and take the last `max_pairs`.
265 let start = uses.len().saturating_sub(max_pairs);
266 uses[start..]
267 .iter()
268 .map(|(id, name, input)| {
269 let result = results
270 .get(id.as_str())
271 .cloned()
272 .unwrap_or_else(|| "(pending)".to_string());
273 ToolCallPair {
274 name: name.clone(),
275 input_preview: input.clone(),
276 result_preview: result,
277 }
278 })
279 .collect()
280 }
281
282 /// Build the user prompt for the advisor from a slice of tool-call pairs.
283 #[must_use]
284 pub fn build_advisor_prompt(pairs: &[ToolCallPair]) -> String {
285 let mut out = String::from("Recent tool activity to review (oldest → newest):\n\n");
286 for (i, pair) in pairs.iter().enumerate() {
287 out.push_str(&format!(
288 "{}. tool={}\n input: {}\n result: {}\n\n",
289 i + 1,
290 pair.name,
291 pair.input_preview,
292 pair.result_preview
293 ));
294 }
295 out.push_str(
296 "Provide your advisory in one to three sentences, or respond with \"ok\" if nothing notable.",
297 );
298 out
299 }
300
301 /// Run one advisor review cycle for a completed turn.
302 ///
303 /// This is the async work dispatched by `spawn_supervised` in the engine. It:
304 /// 1. Checks whether emission is allowed by `guard`.
305 /// 2. Extracts bounded tool-call pairs from `messages`.
306 /// 3. Makes a non-streaming LLM call with a short read-only prompt.
307 /// 4. Checks emission again (the LLM call may have taken time).
308 /// 5. Sends `Event::AdvisoryNote` if the note passes the guard.
309 ///
310 /// All errors are logged and swallowed — the advisor must never fail the
311 /// parent turn.
312 pub async fn run_advisor_for_turn(
313 turn_id: String,
314 messages: Vec<Message>,
315 config: AdvisorConfig,
316 client: CodewhaleClient,
317 route_config: Config,
318 session_model: String,
319 usage_context: AdvisorUsageContext,
320 guard: std::sync::Arc<tokio::sync::Mutex<EmissionGuard>>,
321 tx_event: mpsc::Sender<Event>,
322 ) {
323 // Pre-flight: skip if the guard already blocks (avoids the LLM call when
324 // rate-limited, which is the common case for rapid turn sequences).
325 {
326 let g = guard.lock().await;
327 // We don't have the note content yet, so we only check the rate limit
328 // here by testing with a placeholder. The dedup check runs after the
329 // LLM call, when we have the actual content.
330 if let Some(last) = g.last_emission
331 && std::time::Instant::now().duration_since(last) < config.rate_limit
332 {
333 debug!(target: "advisor", "rate-limited, skipping advisor run for turn {turn_id}");
334 return;
335 }
336 }
337
338 // Extract a bounded slice of tool-call pairs.
339 let pairs = extract_tool_call_pairs(&messages, config.max_tool_calls as usize);
340 if pairs.is_empty() {
341 debug!(target: "advisor", "no tool calls found; skipping advisor for turn {turn_id}");
342 return;
343 }
344
345 let tool_call_count = pairs.len() as u32;
346 let prompt = build_advisor_prompt(&pairs);
347 let model = config
348 .model
349 .clone()
350 .unwrap_or_else(|| session_model.clone());
351
352 let (client, model) = match exact_advisor_client(&route_config, client, &session_model, &model)
353 {
354 Ok(route) => route,
355 Err(error) => {
356 tracing::warn!(target: "advisor", "advisor route resolution failed for turn {turn_id}: {error}");
357 return;
358 }
359 };
360 let route = client.effective_route_envelope(&model, chrono::Utc::now());
361 let request = MessageRequest {
362 model: model.clone(),
363 messages: vec![Message {
364 role: Role::User,
365 content: vec![ContentBlock::Text {
366 text: prompt,
367 cache_control: None,
368 }],
369 }],
370 max_tokens: ADVISOR_MAX_TOKENS,
371 system: Some(SystemPrompt::Text(ADVISOR_SYSTEM_PROMPT.to_string())),
372 tools: None,
373 tool_choice: None,
374 metadata: None,
375 thinking: None,
376 // The advisor has a deliberately tiny answer contract. Hidden
377 // reasoning would spend that allowance before the note is emitted.
378 reasoning_effort: Some("off".to_string()),
379 stream: Some(false),
380 temperature: None,
381 top_p: None,
382 };
383
384 let response = match client.create_message(request).await {
385 Ok(r) => r,
386 Err(e) => {
387 tracing::warn!(target: "advisor", "advisor LLM call failed for turn {turn_id}: {e}");
388 return;
389 }
390 };
391
392 // A decoded provider response is billable even when its partial/empty
393 // content is rejected below or the emission guard suppresses a duplicate.
394 let usage_source_id = format!("advisor:{turn_id}:provider-response:0");
395 if response.usage == codewhale_models::Usage::default() {
396 usage_context.report_unreceipted(&usage_source_id, &route);
397 tracing::warn!(
398 target: "advisor",
399 "advisor provider response omitted usage for turn {turn_id}; cost coverage is unknown"
400 );
401 } else {
402 usage_context.report(&usage_source_id, &route, &response.usage);
403 }
404
405 if codewhale_models::is_incomplete_stop_reason(response.stop_reason.as_deref()) {
406 tracing::warn!(
407 target: "advisor",
408 "advisor response incomplete for turn {turn_id} (stop reason `{}`); dropping partial note",
409 codewhale_models::stop_reason_detail(response.stop_reason.as_deref())
410 );
411 return;
412 }
413
414 // Extract the text from the response.
415 let note: String = response
416 .content
417 .iter()
418 .filter_map(|block| {
419 if let ContentBlock::Text { text, .. } = block {
420 Some(text.as_str())
421 } else {
422 None
423 }
424 })
425 .collect::<Vec<_>>()
426 .join("\n")
427 .trim()
428 .to_string();
429
430 if note.is_empty() {
431 debug!(target: "advisor", "empty advisor response for turn {turn_id}; skipping");
432 return;
433 }
434
435 // Post-flight emission check (rate limit + dedup).
436 let mut guard_lock = guard.lock().await;
437 if !guard_lock.may_emit(&note, &config) {
438 debug!(target: "advisor", "emission suppressed by guard for turn {turn_id}");
439 return;
440 }
441
442 guard_lock.record_emission(&note);
443 drop(guard_lock);
444
445 let _ = tx_event
446 .send(Event::AdvisoryNote {
447 turn_id: turn_id.clone(),
448 note: note.clone(),
449 tool_call_count,
450 })
451 .await;
452
453 debug!(target: "advisor", "advisory note emitted for turn {turn_id} ({tool_call_count} tool calls reviewed)");
454 }
455
456 fn exact_advisor_client(
457 config: &Config,
458 parent_client: CodewhaleClient,
459 session_model: &str,
460 requested_model: &str,
461 ) -> anyhow::Result<(CodewhaleClient, String)> {
462 if requested_model
463 .trim()
464 .eq_ignore_ascii_case(session_model.trim())
465 {
466 return Ok((parent_client, session_model.trim().to_string()));
467 }
468
469 if config.providers.as_ref().is_some_and(|providers| {
470 providers.custom.values().any(|provider| {
471 provider
472 .model
473 .as_deref()
474 .is_some_and(|model| model.trim().eq_ignore_ascii_case(requested_model.trim()))
475 })
476 }) {
477 anyhow::bail!(
478 "advisor model `{}` belongs to a custom provider but no exact provider identity is carried",
479 requested_model.trim()
480 );
481 }
482
483 let selection =
484 crate::model_routing::resolve_explicit_route_with_inventory(config, requested_model);
485 let (provider, model) = if let Some(selection) = selection {
486 if selection.provider == crate::config::ApiProvider::Custom {
487 anyhow::bail!(
488 "advisor model `{}` resolved only to a custom provider kind without an exact provider identity",
489 requested_model.trim()
490 );
491 }
492 (selection.provider, selection.model)
493 } else {
494 let candidates =
495 crate::model_routing::explicit_route_candidate_providers(config, requested_model);
496 if !candidates.is_empty() && !candidates.contains(&config.api_provider()) {
497 anyhow::bail!(
498 "advisor model `{}` is not owned by the originating provider and has no unique exact route",
499 requested_model.trim()
500 );
501 }
502 (config.api_provider(), requested_model.trim().to_string())
503 };
504 let client = crate::route_runtime::resolve_runtime_route(config, provider, Some(&model))
505 .map_err(anyhow::Error::msg)?
506 .validate()
507 .map(|route| route.client)
508 .map_err(anyhow::Error::msg)?;
509 Ok((client, model))
510 }
511
512 fn hash_str(s: &str) -> u64 {
513 let mut h = DefaultHasher::new();
514 s.hash(&mut h);
515 h.finish()
516 }
517
518 // ── Tests ──────────────────────────────────────────────────────────────────
519
520 #[cfg(test)]
521 mod tests {
522 use super::*;
523 use crate::config::{ProviderConfig, ProvidersConfig};
524 use std::time::Duration;
525 use wiremock::matchers::{method, path};
526 use wiremock::{Mock, MockServer, ResponseTemplate};
527
528 fn test_config() -> AdvisorConfig {
529 AdvisorConfig {
530 enabled: true,
531 max_tool_calls: 5,
532 rate_limit: Duration::from_secs(1),
533 dedup_window: Duration::from_secs(10),
534 model: None,
535 }
536 }
537
538 // ── enable/disable ────────────────────────────────────────────────────
539
540 #[test]
541 fn disabled_config_has_enabled_false() {
542 let cfg = AdvisorConfig::disabled();
543 assert!(!cfg.enabled);
544 }
545
546 #[test]
547 fn from_toml_clamps_max_tool_calls() {
548 let toml = AdvisorConfigToml {
549 enabled: true,
550 max_tool_calls: 999,
551 rate_limit_secs: 60,
552 dedup_window_secs: 300,
553 model: None,
554 };
555 let cfg = AdvisorConfig::from_toml(&toml);
556 assert_eq!(
557 cfg.max_tool_calls, 50,
558 "max_tool_calls must be clamped to 50"
559 );
560 }
561
562 #[test]
563 fn from_toml_clamps_rate_limit() {
564 let toml = AdvisorConfigToml {
565 enabled: true,
566 max_tool_calls: 10,
567 rate_limit_secs: 0, // below minimum of 5
568 dedup_window_secs: 300,
569 model: None,
570 };
571 let cfg = AdvisorConfig::from_toml(&toml);
572 assert!(
573 cfg.rate_limit >= Duration::from_secs(5),
574 "rate_limit must be at least 5s"
575 );
576 }
577
578 // ── bounded input ─────────────────────────────────────────────────────
579
580 fn make_messages_with_n_tool_calls(n: usize) -> Vec<Message> {
581 let mut messages = Vec::new();
582 for i in 0..n {
583 let id = format!("tool_{i}");
584 // assistant message with ToolUse
585 messages.push(Message {
586 role: Role::Assistant,
587 content: vec![ContentBlock::ToolUse {
588 id: id.clone(),
589 name: "exec_shell".to_string(),
590 input: serde_json::json!({"command": format!("echo {i}")}),
591 caller: None,
592 thought_signature: None,
593 }],
594 });
595 // user message with ToolResult
596 messages.push(Message {
597 role: Role::User,
598 content: vec![ContentBlock::ToolResult {
599 tool_use_id: id,
600 content: format!("{i}"),
601 is_error: None,
602 content_blocks: None,
603 }],
604 });
605 }
606 messages
607 }
608
609 #[test]
610 fn extract_tool_call_pairs_bounded_by_max() {
611 let messages = make_messages_with_n_tool_calls(20);
612 let pairs = extract_tool_call_pairs(&messages, 5);
613 assert_eq!(pairs.len(), 5, "must return at most max_pairs");
614 // Should be the last 5 (newest).
615 assert_eq!(pairs[0].name, "exec_shell");
616 }
617
618 #[test]
619 fn extract_tool_call_pairs_empty_when_no_tool_calls() {
620 let messages = vec![Message {
621 role: Role::User,
622 content: vec![ContentBlock::Text {
623 text: "hello".to_string(),
624 cache_control: None,
625 }],
626 }];
627 let pairs = extract_tool_call_pairs(&messages, 5);
628 assert!(pairs.is_empty());
629 }
630
631 #[test]
632 fn extract_tool_call_pairs_fewer_than_max_returns_all() {
633 let messages = make_messages_with_n_tool_calls(3);
634 let pairs = extract_tool_call_pairs(&messages, 10);
635 assert_eq!(pairs.len(), 3);
636 }
637
638 // ── rate limiting ─────────────────────────────────────────────────────
639
640 #[test]
641 fn emission_guard_allows_first_emission() {
642 let guard = EmissionGuard::new();
643 let config = test_config();
644 assert!(
645 guard.may_emit("something concerning here", &config),
646 "first emission must be allowed"
647 );
648 }
649
650 #[test]
651 fn emission_guard_blocks_immediately_after_emission() {
652 let mut guard = EmissionGuard::new();
653 let config = test_config();
654 let note = "something concerning";
655 guard.record_emission(note);
656 assert!(
657 !guard.may_emit("a completely different note", &config),
658 "emission must be blocked immediately after a prior emission (rate limit)"
659 );
660 }
661
662 #[test]
663 fn emission_guard_allows_after_rate_limit_expires() {
664 let mut guard = EmissionGuard::new();
665 // Rate limit of 0ms — always expired.
666 let config = AdvisorConfig {
667 rate_limit: Duration::ZERO,
668 dedup_window: Duration::from_secs(300),
669 ..AdvisorConfig::disabled()
670 };
671 let note = "first note";
672 guard.record_emission(note);
673 assert!(
674 guard.may_emit("second different note", &config),
675 "emission must be allowed when rate limit duration is zero"
676 );
677 }
678
679 // ── deduplication ─────────────────────────────────────────────────────
680
681 #[test]
682 fn emission_guard_suppresses_ok_response() {
683 let guard = EmissionGuard::new();
684 let config = test_config();
685 assert!(!guard.may_emit("ok", &config), "\"ok\" must be suppressed");
686 assert!(!guard.may_emit("OK", &config), "\"OK\" must be suppressed");
687 assert!(
688 !guard.may_emit(" ok ", &config),
689 "\" ok \" must be suppressed"
690 );
691 }
692
693 #[test]
694 fn emission_guard_dedup_blocks_identical_note_within_window() {
695 let mut guard = EmissionGuard::new();
696 // Use a zero rate limit so only dedup is tested.
697 let config = AdvisorConfig {
698 rate_limit: Duration::ZERO,
699 dedup_window: Duration::from_secs(300),
700 ..AdvisorConfig::disabled()
701 };
702 let note = "risky shell command with no error checking";
703 guard.record_emission(note);
704 assert!(
705 !guard.may_emit(note, &config),
706 "identical note must be suppressed within the dedup window"
707 );
708 }
709
710 #[test]
711 fn emission_guard_allows_different_note_within_dedup_window() {
712 let mut guard = EmissionGuard::new();
713 let config = AdvisorConfig {
714 rate_limit: Duration::ZERO,
715 dedup_window: Duration::from_secs(300),
716 ..AdvisorConfig::disabled()
717 };
718 guard.record_emission("first note");
719 assert!(
720 guard.may_emit("entirely different note", &config),
721 "a different note must be allowed even within the dedup window"
722 );
723 }
724
725 // ── child failure isolation ───────────────────────────────────────────
726
727 #[test]
728 fn advisor_prompt_is_non_empty_for_non_empty_pairs() {
729 let pairs = vec![ToolCallPair {
730 name: "exec_shell".to_string(),
731 input_preview: r#"{"command":"ls -la"}"#.to_string(),
732 result_preview: "total 4\ndrwxr-xr-x 2 user user 4096".to_string(),
733 }];
734 let prompt = build_advisor_prompt(&pairs);
735 assert!(
736 prompt.contains("exec_shell"),
737 "prompt must include the tool name"
738 );
739 assert!(
740 prompt.contains("ls -la"),
741 "prompt must include the tool input"
742 );
743 }
744
745 #[test]
746 fn advisor_model_override_builds_the_owning_provider_client() {
747 let config = Config {
748 provider: Some("deepseek".to_string()),
749 providers: Some(ProvidersConfig {
750 deepseek: ProviderConfig {
751 api_key: Some("sk-deepseek-advisor-test".to_string()),
752 model: Some("deepseek-chat".to_string()),
753 ..ProviderConfig::default()
754 },
755 zai: ProviderConfig {
756 api_key: Some("zai-advisor-test-key".to_string()),
757 model: Some(crate::config::DEFAULT_ZAI_MODEL.to_string()),
758 ..ProviderConfig::default()
759 },
760 ..ProvidersConfig::default()
761 }),
762 ..Config::default()
763 };
764 let parent = CodewhaleClient::new(&config).expect("parent client");
765 let (advisor, resolved_model) = exact_advisor_client(
766 &config,
767 parent,
768 "deepseek-chat",
769 crate::config::DEFAULT_ZAI_MODEL,
770 )
771 .expect("cross-provider advisor route");
772 let route = advisor.effective_route_envelope(&resolved_model, chrono::Utc::now());
773
774 assert_eq!(route.provider, crate::config::ApiProvider::Zai);
775 assert_eq!(route.provider_identity, "zai");
776 assert_eq!(route.model, crate::config::DEFAULT_ZAI_MODEL);
777 }
778
779 #[test]
780 fn advisor_foreign_custom_override_fails_closed_without_exact_identity() {
781 let config = Config {
782 provider: Some("deepseek".to_string()),
783 providers: Some(ProvidersConfig {
784 deepseek: ProviderConfig {
785 api_key: Some("sk-deepseek-advisor-test".to_string()),
786 model: Some("deepseek-chat".to_string()),
787 ..ProviderConfig::default()
788 },
789 custom: [(
790 "private-route".to_string(),
791 ProviderConfig {
792 api_key: Some("custom-advisor-test-key".to_string()),
793 base_url: Some("https://custom.invalid/v1".to_string()),
794 model: Some("private-advisor-model".to_string()),
795 ..ProviderConfig::default()
796 },
797 )]
798 .into_iter()
799 .collect(),
800 ..ProvidersConfig::default()
801 }),
802 ..Config::default()
803 };
804 let parent = CodewhaleClient::new(&config).expect("parent client");
805 let error =
806 match exact_advisor_client(&config, parent, "deepseek-chat", "private-advisor-model") {
807 Ok(_) => panic!("generic custom kind cannot identify the exact foreign route"),
808 Err(error) => error,
809 };
810 assert!(
811 error.to_string().contains("exact provider identity"),
812 "{error}"
813 );
814 }
815
816 #[test]
817 fn advisor_named_custom_a_cannot_route_model_owned_by_custom_b() {
818 let config = Config {
819 provider: Some("custom-a".to_string()),
820 providers: Some(ProvidersConfig {
821 custom: [
822 (
823 "custom-a".to_string(),
824 ProviderConfig {
825 api_key: Some("custom-a-advisor-test-key".to_string()),
826 base_url: Some("https://custom-a.invalid/v1".to_string()),
827 model: Some("custom-a-model".to_string()),
828 kind: Some("openai-compatible".to_string()),
829 ..ProviderConfig::default()
830 },
831 ),
832 (
833 "custom-b".to_string(),
834 ProviderConfig {
835 api_key: Some("custom-b-advisor-test-key".to_string()),
836 base_url: Some("https://custom-b.invalid/v1".to_string()),
837 model: Some("custom-b-model".to_string()),
838 kind: Some("openai-compatible".to_string()),
839 ..ProviderConfig::default()
840 },
841 ),
842 ]
843 .into_iter()
844 .collect(),
845 ..ProvidersConfig::default()
846 }),
847 ..Config::default()
848 };
849 let parent = CodewhaleClient::new(&config).expect("active custom-a client");
850 let error = match exact_advisor_client(&config, parent, "custom-a-model", "custom-b-model")
851 {
852 Ok(_) => panic!("custom-b must not reuse custom-a's endpoint or credential"),
853 Err(error) => error,
854 };
855 assert!(
856 error.to_string().contains("exact provider identity"),
857 "{error}"
858 );
859 }
860
861 async fn run_billed_advisor_fixture(
862 note: &str,
863 stop_reason: &str,
864 suppress_as_duplicate: bool,
865 include_usage: bool,
866 ) -> (crate::cost_status::PendingBackgroundCost, Option<Event>) {
867 let _scope = crate::cost_status::test_scope();
868 let server = MockServer::start().await;
869 let mut provider_response = serde_json::json!({
870 "id": "advisor-provider-response",
871 "model": "deepseek-chat",
872 "choices": [{
873 "index": 0,
874 "message": {"role": "assistant", "content": note},
875 "finish_reason": stop_reason
876 }]
877 });
878 if include_usage {
879 provider_response["usage"] = serde_json::json!({
880 "prompt_tokens": 9,
881 "completion_tokens": 3,
882 "total_tokens": 12
883 });
884 }
885 Mock::given(method("POST"))
886 .and(path("/v1/chat/completions"))
887 .respond_with(ResponseTemplate::new(200).set_body_json(provider_response))
888 .expect(1)
889 .mount(&server)
890 .await;
891 let route_config = Config {
892 provider: Some("deepseek".to_string()),
893 providers: Some(ProvidersConfig {
894 deepseek: ProviderConfig {
895 api_key: Some("sk-deepseek-advisor-test".to_string()),
896 model: Some("deepseek-chat".to_string()),
897 ..ProviderConfig::default()
898 },
899 ..ProvidersConfig::default()
900 }),
901 ..Config::default()
902 };
903 let mut client = CodewhaleClient::new(&route_config).expect("advisor client");
904 client.set_test_chat_transport_base_url(server.uri());
905 let mut emission_guard = EmissionGuard::new();
906 if suppress_as_duplicate {
907 emission_guard.record_emission(note);
908 }
909 let guard = std::sync::Arc::new(tokio::sync::Mutex::new(emission_guard));
910 let (tx, mut rx) = mpsc::channel(1);
911 run_advisor_for_turn(
912 "advisor-turn".to_string(),
913 make_messages_with_n_tool_calls(1),
914 AdvisorConfig {
915 enabled: true,
916 max_tool_calls: 5,
917 rate_limit: Duration::ZERO,
918 dedup_window: Duration::from_secs(60),
919 model: None,
920 },
921 client,
922 route_config,
923 "deepseek-chat".to_string(),
924 AdvisorUsageContext::capture(None),
925 guard,
926 tx,
927 )
928 .await;
929 (crate::cost_status::drain(), rx.try_recv().ok())
930 }
931
932 #[tokio::test]
933 async fn advisor_incomplete_and_dedup_suppressed_responses_are_each_billed_once() {
934 let (incomplete, incomplete_event) =
935 run_billed_advisor_fixture("partial note", "max_tokens", false, true).await;
936 assert!(incomplete_event.is_none());
937 assert_eq!(
938 incomplete
939 .priced_turns
940 .saturating_add(incomplete.unpriced_turns),
941 1
942 );
943
944 let (dedup, dedup_event) =
945 run_billed_advisor_fixture("same advisory", "stop", true, true).await;
946 assert!(dedup_event.is_none());
947 assert_eq!(dedup.priced_turns.saturating_add(dedup.unpriced_turns), 1);
948 }
949
950 #[tokio::test]
951 async fn advisor_provider_success_without_usage_marks_unknown_once() {
952 let (pending, event) =
953 run_billed_advisor_fixture("use a smaller focused slice", "stop", false, false).await;
954
955 assert!(
956 event.is_some(),
957 "the semantic advisor response remains usable"
958 );
959 assert_eq!(pending.priced_turns, 0);
960 assert_eq!(pending.unpriced_turns, 1);
961 assert_eq!(pending.cny_unpriced_turns, 1);
962 assert!(
963 pending
964 .unpriced_reasons
965 .contains("provider_success_missing_usage")
966 );
967 }
968 }
969
969 lines RUST