1use crate::error::MultiError;
7use crate::mailbox::Mailbox;
8use crate::runner::AgentRunner;
9use crate::shared::SharedInfra;
10use crate::types::{AgentOutput, AgentSpec};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::sync::atomic::{AtomicU32, Ordering};
14use std::sync::Arc;
15use tracing::instrument;
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "snake_case")]
20pub enum AdvisorVerdict {
21 Continue { rationale: String },
22 Plan { guidance: String },
23 Correction { guidance: String },
24 Stop { reason: String },
25 Uncertain { reason: String },
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct AdvisorResult {
31 pub verdict: AdvisorVerdict,
32 pub advisor_output: AgentOutput,
33 pub used: u32,
34 pub max_uses: u32,
35}
36
37pub struct Advisor {
39 pub advisor: AgentSpec,
40 pub max_uses: u32,
41 used: AtomicU32,
42}
43
44impl Advisor {
45 pub fn new(advisor: AgentSpec, max_uses: u32) -> Self {
46 Self {
47 advisor,
48 max_uses,
49 used: AtomicU32::new(0),
50 }
51 }
52
53 pub fn used(&self) -> u32 {
54 self.used.load(Ordering::Relaxed)
55 }
56
57 pub fn remaining(&self) -> u32 {
58 self.max_uses.saturating_sub(self.used())
59 }
60
61 #[instrument(name = "multi.advisor", skip_all)]
62 pub async fn consult(
63 &self,
64 task: &str,
65 runner: &Arc<dyn AgentRunner>,
66 infra: &SharedInfra,
67 ) -> Result<AdvisorResult, MultiError> {
68 let prior = self.used.fetch_add(1, Ordering::Relaxed);
69 if prior >= self.max_uses {
70 return Err(MultiError::AdvisorExhausted {
71 used: prior,
72 max_uses: self.max_uses,
73 });
74 }
75
76 let advisory_task = format!(
77 r#"You are an advisor. The executor remains in control of the loop.
78
79Return exactly one verdict as JSON:
80```json
81{{
82 "verdict": "continue|plan|correction|stop|uncertain",
83 "rationale": "text when verdict is continue",
84 "guidance": "text when verdict is plan or correction",
85 "reason": "text when verdict is stop or uncertain"
86}}
87```
88
89Task or draft to review:
90{task}"#
91 );
92
93 let mailbox = Mailbox::default();
94 let rt = infra.make_runtime();
95 let output = runner
96 .run(&self.advisor, &advisory_task, &rt, &mailbox)
97 .await
98 .map_err(|e| {
99 MultiError::AgentFailed(
100 self.advisor.name.clone(),
101 format!("advisor consultation failed: {}", e),
102 )
103 })?;
104
105 let verdict = parse_verdict(&output.answer);
106 Ok(AdvisorResult {
107 verdict,
108 advisor_output: output,
109 used: prior + 1,
110 max_uses: self.max_uses,
111 })
112 }
113}
114
115#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
117#[serde(rename_all = "snake_case")]
118pub enum TaskRisk {
119 Low,
120 Medium,
121 High,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
126pub struct AdvisorTriggerContext {
127 pub repeated_failures: u32,
128 pub explicit_uncertainty: bool,
129 pub missing_guidance: bool,
130 pub prior_advisor_calls: u32,
131 #[serde(default = "default_task_risk")]
132 pub task_risk: TaskRisk,
133 #[serde(default)]
134 pub labels: Vec<String>,
135}
136
137const fn default_task_risk() -> TaskRisk {
138 TaskRisk::Low
139}
140
141impl Default for AdvisorTriggerContext {
142 fn default() -> Self {
143 Self {
144 repeated_failures: 0,
145 explicit_uncertainty: false,
146 missing_guidance: false,
147 prior_advisor_calls: 0,
148 task_risk: TaskRisk::Low,
149 labels: Vec::new(),
150 }
151 }
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
156#[serde(rename_all = "snake_case")]
157pub enum AdvisorTriggerDecision {
158 NoConsult { reason: String },
159 OptionalConsult { reason: String },
160 MustConsult { reason: String },
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
165pub struct AdvisorTriggerPolicy {
166 pub max_calls_per_run: u32,
167 pub repeated_failure_threshold: u32,
168 pub consult_on_missing_guidance: bool,
169 pub require_on_high_risk: bool,
170}
171
172impl Default for AdvisorTriggerPolicy {
173 fn default() -> Self {
174 Self {
175 max_calls_per_run: 3,
176 repeated_failure_threshold: 2,
177 consult_on_missing_guidance: true,
178 require_on_high_risk: true,
179 }
180 }
181}
182
183impl AdvisorTriggerPolicy {
184 pub fn evaluate(&self, ctx: &AdvisorTriggerContext) -> AdvisorTriggerDecision {
185 if ctx.prior_advisor_calls >= self.max_calls_per_run {
186 return AdvisorTriggerDecision::NoConsult {
187 reason: "advisor budget exhausted".to_string(),
188 };
189 }
190
191 if self.require_on_high_risk && matches!(ctx.task_risk, TaskRisk::High) {
192 return AdvisorTriggerDecision::MustConsult {
193 reason: "high-risk task".to_string(),
194 };
195 }
196
197 if ctx.repeated_failures >= self.repeated_failure_threshold {
198 return AdvisorTriggerDecision::MustConsult {
199 reason: format!(
200 "repeated failures reached threshold ({}/{})",
201 ctx.repeated_failures, self.repeated_failure_threshold
202 ),
203 };
204 }
205
206 if ctx.explicit_uncertainty {
207 return AdvisorTriggerDecision::OptionalConsult {
208 reason: "executor signaled uncertainty".to_string(),
209 };
210 }
211
212 if self.consult_on_missing_guidance && ctx.missing_guidance {
213 return AdvisorTriggerDecision::OptionalConsult {
214 reason: "no approved guidance matched".to_string(),
215 };
216 }
217
218 AdvisorTriggerDecision::NoConsult {
219 reason: "no trigger matched".to_string(),
220 }
221 }
222}
223
224fn parse_verdict(response: &str) -> AdvisorVerdict {
225 if let Some(json_str) = car_ir::json_extract::extract_json_object(response) {
226 if let Ok(parsed) = serde_json::from_str::<HashMap<String, serde_json::Value>>(&json_str) {
227 let verdict = parsed.get("verdict").and_then(|v| v.as_str()).unwrap_or("");
228 let rationale = parsed
229 .get("rationale")
230 .and_then(|v| v.as_str())
231 .unwrap_or("")
232 .trim();
233 let guidance = parsed
234 .get("guidance")
235 .and_then(|v| v.as_str())
236 .unwrap_or("")
237 .trim();
238 let reason = parsed
239 .get("reason")
240 .and_then(|v| v.as_str())
241 .unwrap_or("")
242 .trim();
243 return match verdict {
244 "continue" if !rationale.is_empty() => AdvisorVerdict::Continue {
245 rationale: rationale.to_string(),
246 },
247 "plan" if !guidance.is_empty() => AdvisorVerdict::Plan {
248 guidance: guidance.to_string(),
249 },
250 "correction" if !guidance.is_empty() => AdvisorVerdict::Correction {
251 guidance: guidance.to_string(),
252 },
253 "stop" if !reason.is_empty() => AdvisorVerdict::Stop {
254 reason: reason.to_string(),
255 },
256 "uncertain" if !reason.is_empty() => AdvisorVerdict::Uncertain {
257 reason: reason.to_string(),
258 },
259 _ => AdvisorVerdict::Uncertain {
260 reason: "advisor response could not be parsed cleanly".to_string(),
261 },
262 };
263 }
264 }
265
266 let lower = response.to_lowercase();
267 if lower.contains("verdict: continue") {
268 AdvisorVerdict::Continue {
269 rationale: response.trim().to_string(),
270 }
271 } else if lower.contains("verdict: stop") {
272 AdvisorVerdict::Stop {
273 reason: response.trim().to_string(),
274 }
275 } else if lower.contains("verdict: correction") {
276 AdvisorVerdict::Correction {
277 guidance: response.trim().to_string(),
278 }
279 } else if lower.contains("verdict: plan") {
280 AdvisorVerdict::Plan {
281 guidance: response.trim().to_string(),
282 }
283 } else {
284 AdvisorVerdict::Uncertain {
285 reason: response.trim().to_string(),
286 }
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use car_engine::Runtime;
294
295 struct StaticRunner {
296 response: String,
297 }
298
299 #[async_trait::async_trait]
300 impl crate::runner::AgentRunner for StaticRunner {
301 async fn run(
302 &self,
303 spec: &AgentSpec,
304 _task: &str,
305 _runtime: &Runtime,
306 _mailbox: &Mailbox,
307 ) -> Result<AgentOutput, MultiError> {
308 Ok(AgentOutput {
309 name: spec.name.clone(),
310 answer: self.response.clone(),
311 turns: 1,
312 tool_calls: 0,
313 duration_ms: 3.0,
314 error: None,
315 outcome: None,
316 tokens: None,
317 })
318 }
319 }
320
321 #[tokio::test]
322 async fn consult_returns_parsed_verdict() {
323 let advisor = Advisor::new(AgentSpec::new("advisor", "review"), 2);
324 let runner: Arc<dyn crate::runner::AgentRunner> = Arc::new(StaticRunner {
325 response: r#"{"verdict":"plan","guidance":"check state before editing"}"#.to_string(),
326 });
327 let infra = SharedInfra::new();
328
329 let result = advisor.consult("help", &runner, &infra).await.unwrap();
330 assert_eq!(
331 result.verdict,
332 AdvisorVerdict::Plan {
333 guidance: "check state before editing".to_string(),
334 }
335 );
336 assert_eq!(result.used, 1);
337 assert_eq!(advisor.remaining(), 1);
338 }
339
340 #[tokio::test]
341 async fn consult_enforces_budget() {
342 let advisor = Advisor::new(AgentSpec::new("advisor", "review"), 1);
343 let runner: Arc<dyn crate::runner::AgentRunner> = Arc::new(StaticRunner {
344 response: r#"{"verdict":"uncertain","reason":"need more evidence"}"#.to_string(),
345 });
346 let infra = SharedInfra::new();
347
348 advisor.consult("first", &runner, &infra).await.unwrap();
349 let err = advisor
350 .consult("second", &runner, &infra)
351 .await
352 .unwrap_err();
353 match err {
354 MultiError::AdvisorExhausted { used, max_uses } => {
355 assert_eq!(used, 1);
356 assert_eq!(max_uses, 1);
357 }
358 other => panic!("unexpected error: {other:?}"),
359 }
360 }
361
362 #[test]
363 fn trigger_policy_requires_high_risk() {
364 let policy = AdvisorTriggerPolicy::default();
365 let ctx = AdvisorTriggerContext {
366 task_risk: TaskRisk::High,
367 ..Default::default()
368 };
369 assert_eq!(
370 policy.evaluate(&ctx),
371 AdvisorTriggerDecision::MustConsult {
372 reason: "high-risk task".to_string(),
373 }
374 );
375 }
376
377 #[test]
378 fn trigger_policy_honors_budget() {
379 let policy = AdvisorTriggerPolicy::default();
380 let ctx = AdvisorTriggerContext {
381 prior_advisor_calls: 3,
382 explicit_uncertainty: true,
383 ..Default::default()
384 };
385 assert_eq!(
386 policy.evaluate(&ctx),
387 AdvisorTriggerDecision::NoConsult {
388 reason: "advisor budget exhausted".to_string(),
389 }
390 );
391 }
392
393 #[test]
394 fn parse_verdict_falls_back_to_uncertain() {
395 let verdict = parse_verdict("I am not sure.");
396 assert_eq!(
397 verdict,
398 AdvisorVerdict::Uncertain {
399 reason: "I am not sure.".to_string(),
400 }
401 );
402 }
403
404 #[test]
405 fn parse_verdict_reads_continue_json() {
406 let verdict = parse_verdict(
407 r#"{"verdict":"continue","rationale":"current completion claim is supported"}"#,
408 );
409 assert_eq!(
410 verdict,
411 AdvisorVerdict::Continue {
412 rationale: "current completion claim is supported".to_string(),
413 }
414 );
415 }
416}