bamboo-engine 2026.6.4

Execution engine and orchestration for the Bamboo agent framework
Documentation
use bamboo_agent_core::{GoldConfidence, PendingQuestion, PendingQuestionSource, Session, ToolCall};

use super::GOLD_AUTO_ANSWER_TOOL_NAME;

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GoldAutoAnswerDecision {
    pub(crate) apply: bool,
    pub(crate) answer: Option<String>,
    pub(crate) confidence: GoldConfidence,
    pub(crate) reasoning: String,
}

impl GoldAutoAnswerDecision {
    pub(crate) fn decline(reason: impl Into<String>) -> Self {
        Self {
            apply: false,
            answer: None,
            confidence: GoldConfidence::Low,
            reasoning: reason.into(),
        }
    }
}

pub(crate) fn should_attempt_gold_auto_answer(pending: &PendingQuestion) -> bool {
    if matches!(pending.source, PendingQuestionSource::Gold) {
        return false;
    }

    matches!(
        normalized_pending_tool_name(&pending.tool_name).as_str(),
        "ExitPlanMode" | "conclusion_with_options"
    )
}

pub(crate) fn session_is_awaiting_clarification(session: &Session) -> bool {
    if session
        .metadata
        .get("runtime.suspend_reason")
        .map(String::as_str)
        == Some("awaiting_clarification")
    {
        return true;
    }

    session
        .agent_runtime_state
        .as_ref()
        .and_then(|state| state.suspension.as_ref())
        .map(|suspension| suspension.reason.as_str() == "awaiting_clarification")
        .unwrap_or(false)
}

pub(crate) fn normalized_pending_tool_name(tool_name: &str) -> String {
    let trimmed = tool_name.trim();
    if trimmed.eq_ignore_ascii_case("conclusion_with_options")
        || trimmed.eq_ignore_ascii_case("ConclusionWithOptions")
        || trimmed.eq_ignore_ascii_case("conclusionWithOptions")
    {
        return "conclusion_with_options".to_string();
    }
    if trimmed.eq_ignore_ascii_case("ExitPlanMode") {
        return "ExitPlanMode".to_string();
    }
    if trimmed.eq_ignore_ascii_case("request_permissions")
        || trimmed.eq_ignore_ascii_case("RequestPermissions")
    {
        return "request_permissions".to_string();
    }

    bamboo_tools::normalize_tool_ref(trimmed).unwrap_or_else(|| trimmed.to_string())
}

pub(crate) fn parse_gold_auto_answer_decision(
    tool_calls: &[ToolCall],
) -> Option<GoldAutoAnswerDecision> {
    for tool_call in tool_calls {
        if tool_call.function.name != GOLD_AUTO_ANSWER_TOOL_NAME {
            continue;
        }

        let Ok(args) = serde_json::from_str::<serde_json::Value>(&tool_call.function.arguments)
        else {
            continue;
        };

        let apply = args
            .get("apply")
            .and_then(|value| value.as_bool())
            .unwrap_or(false);
        let answer = args
            .get("answer")
            .and_then(|value| value.as_str())
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToOwned::to_owned);
        let confidence = match args.get("confidence").and_then(|value| value.as_str()) {
            Some("high") => GoldConfidence::High,
            Some("medium") => GoldConfidence::Medium,
            _ => GoldConfidence::Low,
        };
        let reasoning = args
            .get("reasoning")
            .and_then(|value| value.as_str())
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .unwrap_or("Gold auto-answer produced no reasoning")
            .to_string();

        return Some(GoldAutoAnswerDecision {
            apply,
            answer,
            confidence,
            reasoning,
        });
    }

    None
}

pub(crate) fn canonicalize_pending_answer(
    pending: &PendingQuestion,
    raw_answer: &str,
) -> Option<String> {
    if pending.options.is_empty() {
        return None;
    }

    canonicalize_option(raw_answer, &pending.options)
}

fn canonicalize_option(raw_answer: &str, options: &[String]) -> Option<String> {
    let trimmed_answer = raw_answer.trim();
    if trimmed_answer.is_empty() {
        return None;
    }

    if let Some(exact) = options
        .iter()
        .find(|option| option.trim() == trimmed_answer)
    {
        return Some(exact.clone());
    }

    let normalized_answer = normalize_answer_key(trimmed_answer);
    if normalized_answer.is_empty() {
        return None;
    }

    let matches = options
        .iter()
        .filter(|option| normalize_answer_key(option) == normalized_answer)
        .collect::<Vec<_>>();

    if matches.len() == 1 {
        Some(matches[0].clone())
    } else {
        None
    }
}

fn normalize_answer_key(value: &str) -> String {
    value
        .trim()
        .trim_matches(['"', '\'', '`'])
        .trim()
        .trim_end_matches(['.', '', '!', '', '?', ''])
        .trim()
        .to_ascii_lowercase()
}