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
24pub 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 pub missing_information: Vec<String>,
46 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#[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#[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#[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 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 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 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 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 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 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 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 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 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 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 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}