Skip to main content

bamboo_engine/runtime/
gold_evaluation.rs

1use std::sync::{
2    atomic::{AtomicBool, Ordering},
3    Arc,
4};
5
6use bamboo_agent_core::tools::{FunctionSchema, ToolCall, ToolSchema};
7use bamboo_agent_core::{AgentError, AgentEvent, GoldCheckpoint, GoldConfidence, GoldDecision};
8use bamboo_agent_core::{Message, Role, Session};
9use bamboo_compression::{TiktokenTokenCounter, TokenCounter};
10use bamboo_domain::ReasoningEffort;
11use bamboo_llm::{LLMProvider, LLMRequestOptions};
12use chrono::Utc;
13use serde_json::json;
14use tokio::sync::{mpsc, Notify};
15use tokio_util::sync::CancellationToken;
16
17use crate::runtime::config::GoldConfig;
18use crate::runtime::stream::handler::{
19    await_stream_bootstrap, consume_llm_stream_silent_with_context, StreamTimeoutContext,
20};
21use crate::runtime::task_context::TaskLoopContext;
22use bamboo_metrics::TokenUsage as MetricsTokenUsage;
23
24/// Evaluation-scoped frame bundling parameters that identify and configure a
25/// single gold evaluation pass.  Passed into [`evaluate_gold`] to keep its
26/// parameter count below the clippy threshold.
27pub struct GoldEvalFrame<'a> {
28    pub event_tx: &'a mpsc::Sender<AgentEvent>,
29    pub session_id: &'a str,
30    pub model: &'a str,
31    pub timeout_context: StreamTimeoutContext,
32    pub reasoning_effort: Option<ReasoningEffort>,
33    pub checkpoint: GoldCheckpoint,
34    pub iteration: u32,
35}
36
37#[derive(Debug, Clone)]
38pub struct GoldEvaluationResult {
39    pub checkpoint: GoldCheckpoint,
40    pub iteration: u32,
41    pub decision: GoldDecision,
42    pub confidence: GoldConfidence,
43    pub reasoning: String,
44    /// Concrete information still missing to achieve the goal, if any.
45    pub missing_information: Vec<String>,
46    /// The single most useful next action for the agent to take, if the
47    /// evaluator can name one. Used to give the auto-continue nudge direction.
48    pub next_action: Option<String>,
49    pub prompt_tokens: u64,
50    pub completion_tokens: u64,
51}
52
53#[derive(Debug, Clone)]
54pub(crate) struct AsyncGoldEvaluationRequest {
55    pub(crate) session_id: String,
56    pub(crate) round_number: usize,
57    pub(crate) model_name: String,
58    pub(crate) timeout_context: StreamTimeoutContext,
59    pub(crate) reasoning_effort: Option<ReasoningEffort>,
60    pub(crate) checkpoint: GoldCheckpoint,
61    pub(crate) session_snapshot: Session,
62    pub(crate) task_context_snapshot: Option<TaskLoopContext>,
63    pub(crate) gold_config: GoldConfig,
64}
65
66#[derive(Debug, Clone)]
67pub(crate) struct AsyncGoldEvaluationResult {
68    pub(crate) round_number: usize,
69    pub(crate) model_name: String,
70    pub(crate) evaluation_result: GoldEvaluationResult,
71}
72
73// A known local routing refusal is not an evaluator verdict. Other evaluator
74// errors retain the existing low-confidence fallback for async Gold.
75#[derive(Debug)]
76pub(crate) enum GoldEvaluationError {
77    Routing(AgentError),
78    Evaluation(AgentError),
79}
80
81impl From<AgentError> for GoldEvaluationError {
82    fn from(error: AgentError) -> Self {
83        Self::Evaluation(error)
84    }
85}
86
87impl std::fmt::Display for GoldEvaluationError {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        match self {
90            Self::Routing(error) | Self::Evaluation(error) => write!(f, "{error}"),
91        }
92    }
93}
94
95impl GoldEvaluationError {
96    pub(crate) fn into_agent_error(self) -> AgentError {
97        match self {
98            Self::Routing(error) | Self::Evaluation(error) => error,
99        }
100    }
101}
102
103fn estimate_prompt_tokens(messages: &[Message]) -> u64 {
104    let counter = TiktokenTokenCounter::default();
105    u64::from(counter.count_messages(messages))
106}
107
108fn estimate_completion_tokens(content: &str, tool_calls: &[ToolCall]) -> u64 {
109    let counter = TiktokenTokenCounter::default();
110    let mut completion_surface = content.to_string();
111
112    for call in tool_calls {
113        if !completion_surface.is_empty() {
114            completion_surface.push('\n');
115        }
116        completion_surface.push_str(&call.function.name);
117        completion_surface.push('\n');
118        completion_surface.push_str(&call.function.arguments);
119    }
120
121    u64::from(counter.count_text(&completion_surface))
122}
123
124#[allow(clippy::too_many_arguments)]
125pub(crate) fn build_async_gold_evaluation_request(
126    task_context: &Option<TaskLoopContext>,
127    session: &Session,
128    session_id: &str,
129    round_number: usize,
130    model_name: Option<&str>,
131    reasoning_effort: Option<ReasoningEffort>,
132    checkpoint: GoldCheckpoint,
133    gold_config: &GoldConfig,
134    timeout_context: StreamTimeoutContext,
135) -> Result<Option<AsyncGoldEvaluationRequest>, AgentError> {
136    if !gold_config.enabled {
137        return Ok(None);
138    }
139
140    let model_name = gold_config
141        .model_name
142        .as_deref()
143        .or(model_name)
144        .ok_or_else(|| AgentError::LLM("gold evaluation model_name is required".to_string()))?;
145
146    Ok(Some(AsyncGoldEvaluationRequest {
147        session_id: session_id.to_string(),
148        round_number,
149        model_name: model_name.to_string(),
150        timeout_context,
151        reasoning_effort,
152        checkpoint,
153        session_snapshot: session.clone(),
154        task_context_snapshot: task_context.clone(),
155        gold_config: gold_config.clone(),
156    }))
157}
158
159// The standalone test helper owns its own claim. The real runner shares it
160// with its InFlight state so run cleanup cannot emit a second terminal event.
161#[cfg(test)]
162pub(crate) async fn execute_async_gold_evaluation(
163    request: AsyncGoldEvaluationRequest,
164    llm: Arc<dyn LLMProvider>,
165    event_tx: mpsc::Sender<AgentEvent>,
166    configured_limit: usize,
167) -> Result<AsyncGoldEvaluationResult, AgentError> {
168    execute_async_gold_evaluation_with_terminal(
169        request,
170        llm,
171        event_tx,
172        configured_limit,
173        Arc::new(GoldEvaluationTerminal::default()),
174    )
175    .await
176}
177
178pub(crate) async fn execute_async_gold_evaluation_with_terminal(
179    request: AsyncGoldEvaluationRequest,
180    llm: Arc<dyn LLMProvider>,
181    event_tx: mpsc::Sender<AgentEvent>,
182    configured_limit: usize,
183    terminal_claimed: Arc<GoldEvaluationTerminal>,
184) -> Result<AsyncGoldEvaluationResult, AgentError> {
185    let budget_provider = llm.clone();
186    let budget_model = request.model_name.clone();
187    let acquire_dispatch_guard = async move {
188        crate::runtime::runner::auxiliary_budget::acquire(
189            &budget_provider,
190            &budget_model,
191            configured_limit,
192        )
193        .await
194    };
195    let evaluation_result = match evaluate_gold_with_dispatch_and_terminal(
196        &request.session_snapshot,
197        request.task_context_snapshot.as_ref(),
198        &request.gold_config,
199        llm,
200        &GoldEvalFrame {
201            event_tx: &event_tx,
202            session_id: &request.session_id,
203            model: &request.model_name,
204            timeout_context: request.timeout_context.clone(),
205            reasoning_effort: request.reasoning_effort,
206            checkpoint: request.checkpoint,
207            iteration: request.round_number as u32,
208        },
209        acquire_dispatch_guard,
210        Some(terminal_claimed.as_ref()),
211    )
212    .await
213    {
214        Ok(result) => result,
215        Err(GoldEvaluationError::Routing(error)) => return Err(error),
216        Err(GoldEvaluationError::Evaluation(error)) => GoldEvaluationResult {
217            checkpoint: request.checkpoint,
218            iteration: request.round_number as u32,
219            decision: GoldDecision::Continue,
220            confidence: GoldConfidence::Low,
221            reasoning: format!("Gold evaluation failed: {error}"),
222            missing_information: Vec::new(),
223            next_action: None,
224            prompt_tokens: 0,
225            completion_tokens: 0,
226        },
227    };
228
229    Ok(AsyncGoldEvaluationResult {
230        round_number: request.round_number,
231        model_name: request.model_name,
232        evaluation_result,
233    })
234}
235
236pub async fn evaluate_gold(
237    session: &Session,
238    task_context: Option<&TaskLoopContext>,
239    config: &GoldConfig,
240    llm: Arc<dyn LLMProvider>,
241    frame: &GoldEvalFrame<'_>,
242) -> Result<GoldEvaluationResult, AgentError> {
243    evaluate_gold_with_dispatch(
244        session,
245        task_context,
246        config,
247        llm,
248        frame,
249        std::future::ready(()),
250    )
251    .await
252    .map_err(GoldEvaluationError::into_agent_error)
253}
254
255pub(crate) async fn evaluate_gold_with_dispatch<G, Fut>(
256    session: &Session,
257    task_context: Option<&TaskLoopContext>,
258    config: &GoldConfig,
259    llm: Arc<dyn LLMProvider>,
260    frame: &GoldEvalFrame<'_>,
261    acquire_dispatch_guard: Fut,
262) -> Result<GoldEvaluationResult, GoldEvaluationError>
263where
264    Fut: std::future::Future<Output = G>,
265{
266    evaluate_gold_with_dispatch_and_terminal(
267        session,
268        task_context,
269        config,
270        llm,
271        frame,
272        acquire_dispatch_guard,
273        None,
274    )
275    .await
276}
277
278// One claim is shared by an async Gold evaluator and its cleanup. The
279// notification lets a losing sender stop waiting for channel capacity.
280#[derive(Default)]
281pub(crate) struct GoldEvaluationTerminal {
282    claimed: AtomicBool,
283    published: Notify,
284}
285
286pub(crate) async fn emit_gold_cancelled_once(
287    event_tx: &mpsc::Sender<AgentEvent>,
288    terminal: &GoldEvaluationTerminal,
289    session_id: &str,
290    reason: &str,
291) {
292    // Register before checking the flag so a concurrent publication cannot be
293    // missed between the check and polling the notification.
294    let published = terminal.published.notified();
295    tokio::pin!(published);
296    published.as_mut().enable();
297    if terminal.claimed.load(Ordering::Acquire) {
298        return;
299    }
300    let permit = tokio::select! {
301        biased;
302        _ = &mut published => return,
303        result = event_tx.reserve() => match result {
304            Ok(permit) => permit,
305            Err(_) => return,
306        },
307    };
308    let event = AgentEvent::GoldEvaluationCancelled {
309        session_id: session_id.to_string(),
310        reason: reason.to_string(),
311    };
312    if terminal
313        .claimed
314        .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
315        .is_ok()
316    {
317        // Enqueue and notify are synchronous after the claim: an aborted task
318        // cannot yield in between them. This is not a consumer acknowledgement.
319        permit.send(event);
320        terminal.published.notify_waiters();
321    }
322}
323
324async fn evaluate_gold_with_dispatch_and_terminal<G, Fut>(
325    session: &Session,
326    task_context: Option<&TaskLoopContext>,
327    config: &GoldConfig,
328    llm: Arc<dyn LLMProvider>,
329    frame: &GoldEvalFrame<'_>,
330    acquire_dispatch_guard: Fut,
331    terminal_claimed: Option<&GoldEvaluationTerminal>,
332) -> Result<GoldEvaluationResult, GoldEvaluationError>
333where
334    Fut: std::future::Future<Output = G>,
335{
336    if let Some(error) = llm.known_pre_dispatch_error() {
337        return Err(GoldEvaluationError::Routing(AgentError::LLM(
338            error.to_string(),
339        )));
340    }
341
342    // Bind frame fields as locals so the rest of the function body stays unchanged.
343    let event_tx = frame.event_tx;
344    let session_id = frame.session_id;
345    let model = frame.model;
346    let reasoning_effort = frame.reasoning_effort;
347    let checkpoint = frame.checkpoint;
348    let iteration = frame.iteration;
349
350    let _ = event_tx
351        .send(AgentEvent::GoldEvaluationStarted {
352            session_id: session_id.to_string(),
353            checkpoint,
354            iteration,
355        })
356        .await;
357
358    let messages = build_gold_messages(session, task_context, config, checkpoint);
359    let prompt_tokens = estimate_prompt_tokens(&messages);
360    let tools = get_gold_evaluation_tools();
361
362    let request_options = LLMRequestOptions {
363        session_id: Some(session_id.to_string()),
364        reasoning_effort,
365        parallel_tool_calls: None,
366        required_tool: None,
367        responses: None,
368        request_purpose: Some("gold_evaluation".to_string()),
369        cache: None,
370    };
371
372    let cancel_token = CancellationToken::new();
373    let _dispatch_guard = acquire_dispatch_guard.await;
374    let timeout_context = frame.timeout_context.clone().begin_request();
375    let stream = await_stream_bootstrap(
376        llm.chat_stream_with_options(
377            &messages,
378            &tools,
379            Some(config.max_output_tokens),
380            model,
381            Some(&request_options),
382        ),
383        &cancel_token,
384        session_id,
385        &timeout_context,
386    )
387    .await?;
388    let stream = match stream {
389        Ok(stream) => stream,
390        Err(error) if llm.local_dispatch_refused() => {
391            // No provider request was dispatched. Free the scarce slot before
392            // waiting for a potentially backpressured event consumer.
393            drop(_dispatch_guard);
394            if let Some(terminal_claimed) = terminal_claimed {
395                emit_gold_cancelled_once(event_tx, terminal_claimed, session_id, "route_rejected")
396                    .await;
397            } else {
398                let _ = event_tx
399                    .send(AgentEvent::GoldEvaluationCancelled {
400                        session_id: session_id.to_string(),
401                        reason: "route_rejected".to_string(),
402                    })
403                    .await;
404            }
405            return Err(GoldEvaluationError::Routing(AgentError::LLM(
406                error.to_string(),
407            )));
408        }
409        Err(error) => {
410            return Err(GoldEvaluationError::Evaluation(AgentError::LLM(
411                error.to_string(),
412            )))
413        }
414    };
415    let stream_output =
416        consume_llm_stream_silent_with_context(stream, &cancel_token, session_id, &timeout_context)
417            .await?;
418
419    let result = parse_gold_evaluation(
420        &stream_output.content,
421        &stream_output.tool_calls,
422        checkpoint,
423        iteration,
424        prompt_tokens,
425    );
426
427    let _ = event_tx
428        .send(AgentEvent::GoldEvaluationCompleted {
429            session_id: session_id.to_string(),
430            checkpoint: result.checkpoint,
431            iteration: result.iteration,
432            decision: result.decision,
433            confidence: result.confidence,
434            reasoning: result.reasoning.clone(),
435        })
436        .await;
437
438    Ok(result)
439}
440
441pub fn build_gold_messages(
442    session: &Session,
443    task_context: Option<&TaskLoopContext>,
444    config: &GoldConfig,
445    checkpoint: GoldCheckpoint,
446) -> Vec<Message> {
447    let mut messages = Vec::new();
448
449    let mut system_prompt = String::from(
450        "You are a gold progress evaluator. Judge whether the agent has already achieved the user's goal, should continue execution, needs user input, is blocked, or is exhausted.\n\nRules:\n1. This phase is observe-only: do not mutate state or invent actions.\n2. You must call report_gold_evaluation exactly once.\n3. Use achieved only when the user's actual goal is satisfied.\n4. Use continue when more agent work is still appropriate.\n5. Use need_input only when missing user input is the true next blocker.\n6. Use blocked only for a concrete blocking condition.\n7. Use exhausted for loops, budget exhaustion, or clear inability to make progress.\n8. Keep reasoning short, concrete, and evidence-based."
451    );
452
453    if let Some(extra) = config
454        .evaluation_prompt
455        .as_deref()
456        .map(str::trim)
457        .filter(|value| !value.is_empty())
458    {
459        system_prompt.push_str("\n\nAdditional instructions:\n");
460        system_prompt.push_str(extra);
461    }
462
463    messages.push(Message::system(system_prompt));
464
465    let task_summary = task_context
466        .map(TaskLoopContext::format_for_prompt)
467        .filter(|value| !value.trim().is_empty())
468        .unwrap_or_else(|| "## Current Task List\nNo task list available.".to_string());
469
470    let pending_question_summary = session
471        .pending_question
472        .as_ref()
473        .map(|question| {
474            let options = if question.options.is_empty() {
475                "none".to_string()
476            } else {
477                question.options.join(" | ")
478            };
479            let tool_name = if question.tool_name.trim().is_empty() {
480                "unknown".to_string()
481            } else {
482                question.tool_name.clone()
483            };
484            format!(
485                "question={} | options={} | tool={} | source={:?}",
486                question.question, options, tool_name, question.source
487            )
488        })
489        .unwrap_or_else(|| "none".to_string());
490
491    let runtime_summary = session
492        .agent_runtime_state
493        .as_ref()
494        .map(|state| {
495            format!(
496                "status={:?} | current_round={} | round_cap={} | suspend_reason={} | waiting_for_children={}",
497                state.status,
498                state.round.current_round,
499                state
500                    .round
501                    .max_rounds
502                    .map(|value| value.to_string())
503                    .unwrap_or_else(|| "unlimited".to_string()),
504                state
505                    .suspension
506                    .as_ref()
507                    .map(|s| s.reason.clone())
508                    .unwrap_or_else(|| "none".to_string()),
509                state.waiting_for_children.is_some()
510            )
511        })
512        .unwrap_or_else(|| "runtime_state=none".to_string());
513
514    let recent_messages = format_recent_messages(session, 6);
515
516    let goal_section = config
517        .effective_goal()
518        .map(|goal| format!("## Goal\n{goal}"))
519        .unwrap_or_else(|| {
520            "## Goal\nNo explicit goal set. Judge against the user's request inferred from the conversation.".to_string()
521        });
522
523    let user_prompt = format!(
524        "## Gold Checkpoint\ncheckpoint={}\n\n{}\n\n## Runtime\n{}\n\n## Pending Question\n{}\n\n{}\n\n## Recent Conversation\n{}\n\n## Instruction\nReport the best current Gold judgment for this checkpoint by measuring progress against the goal above. Remember: Phase 1 is observe-only, so only report decision/confidence/reasoning.",
525        checkpoint.as_str(),
526        goal_section,
527        runtime_summary,
528        pending_question_summary,
529        task_summary,
530        recent_messages,
531    );
532
533    messages.push(Message::user(user_prompt));
534    messages
535}
536
537fn format_recent_messages(session: &Session, limit: usize) -> String {
538    let start = session.messages.len().saturating_sub(limit);
539    let mut lines = Vec::new();
540
541    for message in session.messages.iter().skip(start) {
542        let role = match message.role {
543            Role::System => "system",
544            Role::User => "user",
545            Role::Assistant => "assistant",
546            Role::Tool => "tool",
547        };
548
549        let mut content = message.content.trim().replace('\n', " ");
550        if content.chars().count() > 240 {
551            content = format!("{}…", content.chars().take(240).collect::<String>());
552        }
553        if content.is_empty() {
554            content = "<empty>".to_string();
555        }
556
557        lines.push(format!("- [{}] {}", role, content));
558    }
559
560    if lines.is_empty() {
561        "- <no messages>".to_string()
562    } else {
563        lines.join("\n")
564    }
565}
566
567pub fn get_gold_evaluation_tools() -> Vec<ToolSchema> {
568    vec![ToolSchema {
569        schema_type: "function".to_string(),
570        function: FunctionSchema {
571            name: "report_gold_evaluation".to_string(),
572            description: "Report the current Gold evaluation decision for the session".to_string(),
573            parameters: json!({
574                "type": "object",
575                "properties": {
576                    "decision": {
577                        "type": "string",
578                        "enum": ["continue", "achieved", "blocked", "need_input", "exhausted"]
579                    },
580                    "confidence": {
581                        "type": "string",
582                        "enum": ["low", "medium", "high"]
583                    },
584                    "reasoning": {
585                        "type": "string",
586                        "description": "Short concrete reasoning for the decision"
587                    },
588                    "missing_information": {
589                        "type": "array",
590                        "items": { "type": "string" },
591                        "description": "Concrete pieces of information still missing to achieve the goal. Empty when nothing is missing."
592                    },
593                    "next_action": {
594                        "type": "string",
595                        "description": "The single most useful next action the agent should take. Provide when decision is continue."
596                    }
597                },
598                "required": ["decision", "confidence", "reasoning"],
599                "additionalProperties": false
600            }),
601        },
602    }]
603}
604
605pub fn parse_gold_evaluation(
606    content: &str,
607    tool_calls: &[ToolCall],
608    checkpoint: GoldCheckpoint,
609    iteration: u32,
610    prompt_tokens: u64,
611) -> GoldEvaluationResult {
612    let completion_tokens = estimate_completion_tokens(content, tool_calls);
613    let parsed = parse_gold_result_from_tool_calls(tool_calls);
614
615    let parsed = parsed.unwrap_or_else(|| {
616        let fallback_reasoning = content.trim().to_string();
617        ParsedGoldResult {
618            decision: GoldDecision::Continue,
619            confidence: GoldConfidence::Low,
620            reasoning: if fallback_reasoning.is_empty() {
621                "Gold evaluation returned no structured result; defaulting to continue.".to_string()
622            } else {
623                fallback_reasoning
624            },
625            missing_information: Vec::new(),
626            next_action: None,
627        }
628    });
629
630    GoldEvaluationResult {
631        checkpoint,
632        iteration,
633        decision: parsed.decision,
634        confidence: parsed.confidence,
635        reasoning: parsed.reasoning,
636        missing_information: parsed.missing_information,
637        next_action: parsed.next_action,
638        prompt_tokens,
639        completion_tokens,
640    }
641}
642
643struct ParsedGoldResult {
644    decision: GoldDecision,
645    confidence: GoldConfidence,
646    reasoning: String,
647    missing_information: Vec<String>,
648    next_action: Option<String>,
649}
650
651fn parse_gold_result_from_tool_calls(tool_calls: &[ToolCall]) -> Option<ParsedGoldResult> {
652    for tool_call in tool_calls {
653        if tool_call.function.name != "report_gold_evaluation" {
654            continue;
655        }
656
657        let Ok(args) = serde_json::from_str::<serde_json::Value>(&tool_call.function.arguments)
658        else {
659            continue;
660        };
661
662        let decision = match args.get("decision").and_then(|value| value.as_str()) {
663            Some("continue") => GoldDecision::Continue,
664            Some("achieved") => GoldDecision::Achieved,
665            Some("blocked") => GoldDecision::Blocked,
666            Some("need_input") => GoldDecision::NeedInput,
667            Some("exhausted") => GoldDecision::Exhausted,
668            _ => continue,
669        };
670
671        let confidence = match args.get("confidence").and_then(|value| value.as_str()) {
672            Some("low") => GoldConfidence::Low,
673            Some("medium") => GoldConfidence::Medium,
674            Some("high") => GoldConfidence::High,
675            _ => GoldConfidence::Low,
676        };
677
678        let reasoning = args
679            .get("reasoning")
680            .and_then(|value| value.as_str())
681            .map(str::trim)
682            .filter(|value| !value.is_empty())
683            .unwrap_or("Gold evaluation produced no reasoning")
684            .to_string();
685
686        let missing_information = args
687            .get("missing_information")
688            .and_then(|value| value.as_array())
689            .map(|items| {
690                items
691                    .iter()
692                    .filter_map(|item| item.as_str())
693                    .map(str::trim)
694                    .filter(|value| !value.is_empty())
695                    .map(str::to_string)
696                    .collect::<Vec<_>>()
697            })
698            .unwrap_or_default();
699
700        let next_action = args
701            .get("next_action")
702            .and_then(|value| value.as_str())
703            .map(str::trim)
704            .filter(|value| !value.is_empty())
705            .map(str::to_string);
706
707        return Some(ParsedGoldResult {
708            decision,
709            confidence,
710            reasoning,
711            missing_information,
712            next_action,
713        });
714    }
715
716    None
717}
718
719pub(crate) fn apply_gold_evaluation_result(
720    session: &mut Session,
721    result: &GoldEvaluationResult,
722) -> MetricsTokenUsage {
723    let evaluation_count = session
724        .metadata
725        .get("gold.evaluation_count")
726        .and_then(|value| value.parse::<u64>().ok())
727        .unwrap_or(0)
728        .saturating_add(1);
729
730    let summary = json!({
731        "checkpoint": result.checkpoint.as_str(),
732        "iteration": result.iteration,
733        "decision": result.decision.as_str(),
734        "confidence": result.confidence.as_str(),
735        "reasoning": result.reasoning,
736        "recorded_at": Utc::now().to_rfc3339(),
737    });
738
739    session
740        .metadata
741        .insert("gold.last_evaluation".to_string(), summary.to_string());
742    session.metadata.insert(
743        "gold.last_decision".to_string(),
744        result.decision.as_str().to_string(),
745    );
746    session.metadata.insert(
747        "gold.last_confidence".to_string(),
748        result.confidence.as_str().to_string(),
749    );
750    session
751        .metadata
752        .insert("gold.last_reasoning".to_string(), result.reasoning.clone());
753    session.metadata.insert(
754        "gold.last_checkpoint".to_string(),
755        result.checkpoint.as_str().to_string(),
756    );
757    session.metadata.insert(
758        "gold.last_iteration".to_string(),
759        result.iteration.to_string(),
760    );
761    session.metadata.insert(
762        "gold.evaluation_count".to_string(),
763        evaluation_count.to_string(),
764    );
765    session.updated_at = Utc::now();
766
767    let mut usage = MetricsTokenUsage {
768        prompt_tokens: result.prompt_tokens,
769        completion_tokens: result.completion_tokens,
770        ..Default::default()
771    };
772    usage.recompute_total();
773    usage
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use bamboo_agent_core::tools::FunctionCall;
780
781    // This witness supports both the old return shape and the corrected one,
782    // allowing the same assertions to run as a RED test on the baseline.
783    trait GoldOutcomeWitness {
784        fn is_routing_refusal(&self) -> bool;
785        fn ordinary_failure_reasoning(&self) -> Option<&str>;
786    }
787
788    impl GoldOutcomeWitness for AsyncGoldEvaluationResult {
789        fn is_routing_refusal(&self) -> bool {
790            false
791        }
792        fn ordinary_failure_reasoning(&self) -> Option<&str> {
793            Some(&self.evaluation_result.reasoning)
794        }
795    }
796
797    impl GoldOutcomeWitness for Result<AsyncGoldEvaluationResult, AgentError> {
798        fn is_routing_refusal(&self) -> bool {
799            self.is_err()
800        }
801        fn ordinary_failure_reasoning(&self) -> Option<&str> {
802            self.as_ref()
803                .ok()
804                .map(|result| result.evaluation_result.reasoning.as_str())
805        }
806    }
807
808    fn synthetic_request(model: &str) -> AsyncGoldEvaluationRequest {
809        AsyncGoldEvaluationRequest {
810            session_id: "gold-preflight-fixture".into(),
811            round_number: 1,
812            model_name: model.into(),
813            timeout_context: StreamTimeoutContext::default(),
814            reasoning_effort: None,
815            checkpoint: GoldCheckpoint::PostRound,
816            session_snapshot: Session::new("gold-preflight-fixture", model),
817            task_context_snapshot: None,
818            gold_config: GoldConfig {
819                enabled: true,
820                ..Default::default()
821            },
822        }
823    }
824
825    #[tokio::test]
826    async fn gold_terminal_wakes_losing_sender_without_another_channel_slot() {
827        let (event_tx, mut event_rx) = mpsc::channel(1);
828        event_tx
829            .send(AgentEvent::Token {
830                content: "occupy".into(),
831            })
832            .await
833            .unwrap();
834        let terminal = GoldEvaluationTerminal::default();
835        let mut route = Box::pin(emit_gold_cancelled_once(
836            &event_tx,
837            &terminal,
838            "synthetic-session",
839            "route_rejected",
840        ));
841        let mut cleanup = Box::pin(emit_gold_cancelled_once(
842            &event_tx,
843            &terminal,
844            "synthetic-session",
845            "run_cancelled",
846        ));
847        // Poll both to Pending while the same one-slot channel is full.
848        assert!(futures::poll!(route.as_mut()).is_pending());
849        assert!(futures::poll!(cleanup.as_mut()).is_pending());
850        assert!(!terminal.claimed.load(Ordering::Acquire));
851        assert!(matches!(event_rx.try_recv(), Ok(AgentEvent::Token { .. })));
852
853        // Both must return while the one terminal event still fills the only
854        // channel slot. The losing reservation cannot wait for another drain.
855        tokio::time::timeout(std::time::Duration::from_secs(5), async {
856            tokio::join!(route, cleanup);
857        })
858        .await
859        .expect("the losing terminal sender must be notified");
860        assert!(terminal.claimed.load(Ordering::Acquire));
861        assert!(matches!(
862            event_rx.try_recv(),
863            Ok(AgentEvent::GoldEvaluationCancelled { ref session_id, ref reason })
864                if session_id == "synthetic-session"
865                    && (reason == "route_rejected" || reason == "run_cancelled")
866        ));
867        assert!(event_rx.try_recv().is_err(), "exactly one terminal event");
868    }
869
870    #[tokio::test]
871    async fn gold_terminal_closed_channel_never_claims_a_terminal() {
872        let (event_tx, mut event_rx) = mpsc::channel(1);
873        event_tx
874            .send(AgentEvent::Token {
875                content: "occupy".into(),
876            })
877            .await
878            .unwrap();
879        let terminal = GoldEvaluationTerminal::default();
880        let mut route = Box::pin(emit_gold_cancelled_once(
881            &event_tx,
882            &terminal,
883            "synthetic-session",
884            "route_rejected",
885        ));
886        let mut cleanup = Box::pin(emit_gold_cancelled_once(
887            &event_tx,
888            &terminal,
889            "synthetic-session",
890            "run_cancelled",
891        ));
892        assert!(futures::poll!(route.as_mut()).is_pending());
893        assert!(futures::poll!(cleanup.as_mut()).is_pending());
894        event_rx.close();
895        tokio::time::timeout(std::time::Duration::from_secs(5), async {
896            tokio::join!(route, cleanup);
897        })
898        .await
899        .expect("closed channel must unblock both reservations");
900        assert!(!terminal.claimed.load(Ordering::Acquire));
901        assert!(matches!(event_rx.try_recv(), Ok(AgentEvent::Token { .. })));
902        assert!(
903            event_rx.try_recv().is_err(),
904            "no terminal could be enqueued"
905        );
906    }
907
908    #[tokio::test]
909    async fn gold_terminal_cancelled_waiter_leaves_claim_available_for_cleanup() {
910        let (event_tx, mut event_rx) = mpsc::channel(1);
911        event_tx
912            .send(AgentEvent::Token {
913                content: "occupy".into(),
914            })
915            .await
916            .unwrap();
917        let terminal = GoldEvaluationTerminal::default();
918        let mut route = Box::pin(emit_gold_cancelled_once(
919            &event_tx,
920            &terminal,
921            "synthetic-session",
922            "route_rejected",
923        ));
924        let mut cleanup = Box::pin(emit_gold_cancelled_once(
925            &event_tx,
926            &terminal,
927            "synthetic-session",
928            "run_cancelled",
929        ));
930        assert!(futures::poll!(route.as_mut()).is_pending());
931        assert!(futures::poll!(cleanup.as_mut()).is_pending());
932        drop(route);
933        assert!(!terminal.claimed.load(Ordering::Acquire));
934        assert!(matches!(event_rx.try_recv(), Ok(AgentEvent::Token { .. })));
935        tokio::time::timeout(std::time::Duration::from_secs(5), cleanup)
936            .await
937            .expect("cleanup must claim the released slot");
938        assert!(terminal.claimed.load(Ordering::Acquire));
939        assert!(matches!(
940            event_rx.try_recv(),
941            Ok(AgentEvent::GoldEvaluationCancelled { ref session_id, ref reason })
942                if session_id == "synthetic-session" && reason == "run_cancelled"
943        ));
944        assert!(event_rx.try_recv().is_err(), "exactly one terminal event");
945    }
946
947    #[tokio::test]
948    async fn rejected_fast_never_waits_for_budget_or_emits_a_gold_verdict() {
949        let routing = crate::app_context::model_routing_test_support::RoutingContext::new(None);
950        let mut config = routing.config.read().await.clone();
951        config.provider_instances.get_mut("fast-b").unwrap().enabled = false;
952        let (model, provider) = crate::model_config_helper::resolve_fast_model_for_runner(
953            Some(&config),
954            "chat-a",
955            &routing.registry,
956        );
957        let model = model.unwrap();
958        let provider = provider.unwrap();
959        // Also verify trait-object propagation through the role decorator.
960        let mut reference = bamboo_domain::ProviderModelRef::new("fast-b", model.as_str());
961        reference.reasoning_effort = Some(bamboo_domain::ReasoningEffort::Low);
962        let decorated = bamboo_llm::ResolvedModel::from_ref(provider.clone(), &reference).provider;
963
964        for selected in [provider, decorated] {
965            let held =
966                crate::runtime::runner::auxiliary_budget::acquire(&selected, &model, 1).await;
967            let (event_tx, mut event_rx) = mpsc::channel(8);
968            let outcome = tokio::time::timeout(
969                std::time::Duration::from_secs(2),
970                execute_async_gold_evaluation(synthetic_request(&model), selected, event_tx, 1),
971            )
972            .await
973            .expect("rejected Fast must not wait for an auxiliary permit");
974            assert!(
975                outcome.is_routing_refusal(),
976                "refusal must not become a Gold verdict"
977            );
978            assert!(
979                event_rx.try_recv().is_err(),
980                "no Gold Started or Completed event"
981            );
982            drop(held);
983        }
984        assert!(routing.requests("fast-b").is_empty());
985        assert!(routing.requests("chat-a").is_empty());
986    }
987
988    #[tokio::test]
989    async fn managed_gold_route_revoked_after_started_has_route_terminal_and_releases_permit() {
990        use crate::app_context::model_routing_test_support::CaptureProvider;
991        use bamboo_domain::ProviderModelRef;
992        use bamboo_llm::{Config, ProviderRegistry};
993        use std::time::Duration;
994        use tokio::sync::RwLock;
995
996        // This managed handle is only constructed and validated. If routing
997        // incorrectly dispatches, the only delegate is a synthetic capture;
998        // no LLM request is sent to the loopback address in the config.
999        let dir = tempfile::tempdir().unwrap();
1000        let mut config = Config::default();
1001        config.provider = "gold-a".into();
1002        config.default_provider_instance = Some("gold-a".into());
1003        config.http_proxy = "http://127.0.0.1:9".into();
1004        config.https_proxy.clear();
1005        config.proxy_auth = None;
1006        config.provider_instances.insert(
1007            "gold-a".into(),
1008            serde_json::from_value(serde_json::json!({
1009                "provider_type": "openai",
1010                "enabled": true,
1011                "api_key": "synthetic-only",
1012                "base_url": "http://127.0.0.1:1",
1013                "model": "main-model",
1014                "runtime_models": ["main-model", "explicit-gold"]
1015            }))
1016            .unwrap(),
1017        );
1018        let registry = Arc::new(
1019            ProviderRegistry::from_config(&config, dir.path().to_path_buf())
1020                .await
1021                .unwrap(),
1022        );
1023        let target = ProviderModelRef::new("gold-a", "explicit-gold");
1024        let (selected, origin) = registry.provider_for_model_with_origin(&target).unwrap();
1025        let capture = Arc::new(CaptureProvider::default());
1026        let live = Arc::new(RwLock::new(config));
1027        let guard = bamboo_llm::gold_admission::guarded_gold_target_with_origin(
1028            live.clone(),
1029            origin.expect("the factory-built registry is managed"),
1030            target,
1031            selected,
1032            capture.clone(),
1033        );
1034        assert!(guard.known_pre_dispatch_error().is_none());
1035
1036        let held =
1037            crate::runtime::runner::auxiliary_budget::acquire(&guard, "explicit-gold", 1).await;
1038        let (event_tx, mut event_rx) = mpsc::channel(1);
1039        let runner_guard = guard.clone();
1040        let runner_tx = event_tx.clone();
1041        let handle = tokio::spawn(async move {
1042            execute_async_gold_evaluation(
1043                synthetic_request("explicit-gold"),
1044                runner_guard,
1045                runner_tx,
1046                1,
1047            )
1048            .await
1049        });
1050        let started = tokio::time::timeout(Duration::from_secs(5), event_rx.recv())
1051            .await
1052            .expect("Gold should start before waiting for its auxiliary slot")
1053            .expect("Gold event sender must remain open");
1054        assert!(matches!(
1055            started,
1056            AgentEvent::GoldEvaluationStarted {
1057                ref session_id,
1058                checkpoint: GoldCheckpoint::PostRound,
1059                iteration: 1,
1060            } if session_id == "gold-preflight-fixture"
1061        ));
1062        assert!(!handle.is_finished(), "the held permit blocks dispatch");
1063        assert!(capture.requests.lock().unwrap().is_empty());
1064        // Fill the event channel to ensure a failure event cannot be sent yet.
1065        // The refused evaluation must release its auxiliary slot before it
1066        // waits for the event sink to accept that event.
1067        event_tx
1068            .send(AgentEvent::Token {
1069                content: "saturation-fixture".into(),
1070            })
1071            .await
1072            .unwrap();
1073
1074        live.write()
1075            .await
1076            .provider_instances
1077            .get_mut("gold-a")
1078            .unwrap()
1079            .extra
1080            .insert("runtime_models".into(), serde_json::json!(["main-model"]));
1081        drop(held);
1082
1083        tokio::time::timeout(Duration::from_secs(5), async {
1084            while !guard.local_dispatch_refused() {
1085                tokio::task::yield_now().await;
1086            }
1087        })
1088        .await
1089        .expect("the managed guard must refuse the revoked route");
1090        assert!(
1091            capture.requests.lock().unwrap().is_empty(),
1092            "no provider call"
1093        );
1094        let available = tokio::time::timeout(
1095            Duration::from_secs(5),
1096            crate::runtime::runner::auxiliary_budget::acquire(&guard, "explicit-gold", 1),
1097        )
1098        .await
1099        .expect("the refused evaluation must release its auxiliary permit before its event send");
1100        drop(available);
1101        assert!(matches!(
1102            event_rx.try_recv(),
1103            Ok(AgentEvent::Token { ref content }) if content == "saturation-fixture"
1104        ));
1105
1106        let outcome = tokio::time::timeout(Duration::from_secs(5), handle)
1107            .await
1108            .expect("the revoked evaluation must stop")
1109            .expect("the evaluation task must not panic");
1110        let error = outcome.expect_err("a revoked route must not become a verdict");
1111        match error {
1112            AgentError::LLM(message) => assert!(
1113                message.contains("no longer admitted"),
1114                "unexpected routing error: {message}"
1115            ),
1116            other => panic!("expected an LLM routing error, got {other:?}"),
1117        }
1118        assert!(guard.local_dispatch_refused());
1119        assert!(
1120            capture.requests.lock().unwrap().is_empty(),
1121            "no provider call"
1122        );
1123
1124        // This describes the auxiliary evaluation's route refusal, not a user
1125        // cancellation of the owning run and not a completed Gold verdict.
1126        let terminal = event_rx
1127            .try_recv()
1128            .expect("Started must have a route terminal");
1129        let terminal = serde_json::to_value(terminal).unwrap();
1130        assert_eq!(terminal["type"], "gold_evaluation_cancelled");
1131        assert_eq!(terminal["session_id"], "gold-preflight-fixture");
1132        assert_eq!(terminal["reason"], "route_rejected");
1133        assert!(event_rx.try_recv().is_err(), "exactly one terminal event");
1134    }
1135
1136    #[tokio::test]
1137    async fn ordinary_provider_failure_keeps_existing_async_gold_behavior() {
1138        use crate::app_context::model_routing_test_support::{Failure, RoutingContext};
1139        let routing = RoutingContext::new(None);
1140        *routing.providers["fast-b"].failure.lock().unwrap() = Some(Failure::Network);
1141        let provider: Arc<dyn LLMProvider> = routing.providers["fast-b"].clone();
1142        let (event_tx, mut event_rx) = mpsc::channel(8);
1143        let outcome =
1144            execute_async_gold_evaluation(synthetic_request("cheap-b"), provider, event_tx, 1)
1145                .await;
1146        assert!(outcome
1147            .ordinary_failure_reasoning()
1148            .unwrap()
1149            .contains("Gold evaluation failed"));
1150        assert!(matches!(
1151            event_rx.try_recv(),
1152            Ok(AgentEvent::GoldEvaluationStarted { .. })
1153        ));
1154        assert!(event_rx.try_recv().is_err());
1155        assert_eq!(routing.requests("fast-b").len(), 1);
1156        assert!(routing.requests("chat-a").is_empty());
1157    }
1158
1159    #[tokio::test]
1160    async fn network_error_is_not_reclassified_after_provider_state_changes() {
1161        use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1162
1163        struct SwitchAfterNetworkError {
1164            changed: AtomicBool,
1165            calls: AtomicUsize,
1166        }
1167        #[async_trait::async_trait]
1168        impl LLMProvider for SwitchAfterNetworkError {
1169            fn known_pre_dispatch_error(&self) -> Option<bamboo_llm::LLMError> {
1170                self.changed
1171                    .load(Ordering::SeqCst)
1172                    .then(|| bamboo_llm::LLMError::Auth("different later provider state".into()))
1173            }
1174
1175            async fn chat_stream(
1176                &self,
1177                _: &[bamboo_agent_core::Message],
1178                _: &[bamboo_agent_core::ToolSchema],
1179                _: Option<u32>,
1180                _: &str,
1181            ) -> Result<bamboo_llm::LLMStream, bamboo_llm::LLMError> {
1182                self.calls.fetch_add(1, Ordering::SeqCst);
1183                self.changed.store(true, Ordering::SeqCst);
1184                Err(bamboo_llm::LLMError::RequestFailed(
1185                    "synthetic network failure".into(),
1186                ))
1187            }
1188        }
1189        let provider = Arc::new(SwitchAfterNetworkError {
1190            changed: AtomicBool::new(false),
1191            calls: AtomicUsize::new(0),
1192        });
1193        let (event_tx, mut event_rx) = mpsc::channel(8);
1194        let outcome = execute_async_gold_evaluation(
1195            synthetic_request("fixture-model"),
1196            provider.clone(),
1197            event_tx,
1198            1,
1199        )
1200        .await
1201        .expect("an ordinary request error retains the previous async fallback");
1202        assert!(outcome
1203            .evaluation_result
1204            .reasoning
1205            .contains("synthetic network failure"));
1206        assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
1207        assert!(provider.known_pre_dispatch_error().is_some());
1208        assert!(matches!(
1209            event_rx.try_recv(),
1210            Ok(AgentEvent::GoldEvaluationStarted { .. })
1211        ));
1212        assert!(event_rx.try_recv().is_err());
1213    }
1214
1215    fn report_call(arguments: serde_json::Value) -> ToolCall {
1216        ToolCall {
1217            id: "call-1".to_string(),
1218            tool_type: "function".to_string(),
1219            function: FunctionCall {
1220                name: "report_gold_evaluation".to_string(),
1221                arguments: arguments.to_string(),
1222            },
1223        }
1224    }
1225
1226    #[test]
1227    fn parse_gold_result_from_tool_calls_reads_structured_fields() {
1228        let parsed = parse_gold_result_from_tool_calls(&[report_call(json!({
1229            "decision": "blocked",
1230            "confidence": "high",
1231            "reasoning": "Missing credentials",
1232            "missing_information": ["API key", "  ", "Database URL"],
1233            "next_action": "  Ask the user for the API key  "
1234        }))])
1235        .expect("gold result should parse");
1236
1237        assert_eq!(parsed.decision, GoldDecision::Blocked);
1238        assert_eq!(parsed.confidence, GoldConfidence::High);
1239        assert_eq!(parsed.reasoning, "Missing credentials");
1240        assert_eq!(
1241            parsed.missing_information,
1242            vec!["API key".to_string(), "Database URL".to_string()]
1243        );
1244        assert_eq!(
1245            parsed.next_action.as_deref(),
1246            Some("Ask the user for the API key")
1247        );
1248    }
1249
1250    #[test]
1251    fn parse_gold_result_from_tool_calls_ignores_other_tools() {
1252        let parsed = parse_gold_result_from_tool_calls(&[ToolCall {
1253            id: "call-1".to_string(),
1254            tool_type: "function".to_string(),
1255            function: FunctionCall {
1256                name: "other_tool".to_string(),
1257                arguments: "{}".to_string(),
1258            },
1259        }]);
1260
1261        assert!(parsed.is_none());
1262    }
1263
1264    #[test]
1265    fn apply_gold_evaluation_result_updates_metadata_keys() {
1266        let mut session = Session::new("session-1", "model");
1267        let result = GoldEvaluationResult {
1268            checkpoint: GoldCheckpoint::PostRound,
1269            iteration: 2,
1270            decision: GoldDecision::Achieved,
1271            confidence: GoldConfidence::Medium,
1272            reasoning: "Goal satisfied".to_string(),
1273            missing_information: Vec::new(),
1274            next_action: None,
1275            prompt_tokens: 10,
1276            completion_tokens: 5,
1277        };
1278
1279        let usage = apply_gold_evaluation_result(&mut session, &result);
1280
1281        assert_eq!(
1282            session
1283                .metadata
1284                .get("gold.last_decision")
1285                .map(String::as_str),
1286            Some("achieved")
1287        );
1288        assert_eq!(
1289            session
1290                .metadata
1291                .get("gold.last_confidence")
1292                .map(String::as_str),
1293            Some("medium")
1294        );
1295        assert_eq!(
1296            session
1297                .metadata
1298                .get("gold.last_checkpoint")
1299                .map(String::as_str),
1300            Some("post_round")
1301        );
1302        assert_eq!(
1303            session
1304                .metadata
1305                .get("gold.evaluation_count")
1306                .map(String::as_str),
1307            Some("1")
1308        );
1309        assert_eq!(usage.prompt_tokens, 10);
1310        assert_eq!(usage.completion_tokens, 5);
1311        assert_eq!(usage.total_tokens, 15);
1312    }
1313}