Skip to main content

bamboo_engine/gold_auto_answer/
mod.rs

1use bamboo_agent_core::GoldDecision;
2use crate::config::GoldConfig;
3
4use crate::app_context::AgentSessionContext;
5use crate::events::publish_replayable_session_event;
6use crate::session_app::repository::SessionAccess;
7use crate::session_app::respond::{submit_pending_response_with_source, ResponseSource};
8use crate::session_app::resume::{resume_session_execution, ResumeExecutionPort};
9use crate::session_app::types::{RespondInput, ResumeOutcome};
10use crate::model_config_helper::{resolve_gold_config, GOLD_CONFIG_METADATA_KEY};
11
12mod decision;
13mod evaluation;
14mod prompt;
15mod resume;
16
17#[cfg(test)]
18mod tests;
19
20use decision::{
21    canonicalize_pending_answer, session_is_awaiting_clarification, should_attempt_gold_auto_answer,
22};
23use evaluation::{evaluate_gold_auto_answer_question, evaluate_gold_state_for_pending_question};
24use resume::{build_resume_config_snapshot, plan_mode_transition_event};
25
26const GOLD_AUTO_ANSWER_TOOL_NAME: &str = "report_gold_auto_answer";
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum GoldAutoAnswerOutcome {
30    Skipped {
31        reason: String,
32    },
33    Applied {
34        answer: String,
35        resume_outcome: ResumeOutcome,
36    },
37}
38
39/// Attempt a Gold auto-answer for a session's pending clarification.
40///
41/// `state` supplies session/provider/event context (and session persistence
42/// via [`SessionAccess`]); `resume_port` is the server-side adapter that knows
43/// how to actually spawn a resumed agent execution.
44pub async fn maybe_auto_answer_pending_question<S>(
45    state: &S,
46    resume_port: &dyn ResumeExecutionPort,
47    session_id: &str,
48    gold_config_override: Option<GoldConfig>,
49) -> GoldAutoAnswerOutcome
50where
51    S: AgentSessionContext + SessionAccess,
52{
53    let Some(session) = state.load_session_merged(session_id).await else {
54        return GoldAutoAnswerOutcome::Skipped {
55            reason: "session_not_found".to_string(),
56        };
57    };
58
59    let config_snapshot = state.config().read().await.clone();
60    let Some(gold_config) = gold_config_override.or_else(|| {
61        resolve_gold_config(
62            &config_snapshot,
63            session
64                .metadata
65                .get(GOLD_CONFIG_METADATA_KEY)
66                .map(String::as_str),
67        )
68    }) else {
69        return GoldAutoAnswerOutcome::Skipped {
70            reason: "gold_config_unavailable".to_string(),
71        };
72    };
73
74    let Some(pending_question) = session.pending_question.as_ref() else {
75        return GoldAutoAnswerOutcome::Skipped {
76            reason: "no_pending_question".to_string(),
77        };
78    };
79
80    if !gold_config.enabled {
81        return GoldAutoAnswerOutcome::Skipped {
82            reason: "gold_disabled".to_string(),
83        };
84    }
85
86    if !gold_config.auto_answer_enabled {
87        return GoldAutoAnswerOutcome::Skipped {
88            reason: "gold_auto_answer_disabled".to_string(),
89        };
90    }
91
92    if !session_is_awaiting_clarification(&session) {
93        return GoldAutoAnswerOutcome::Skipped {
94            reason: "session_not_awaiting_clarification".to_string(),
95        };
96    }
97
98    if !should_attempt_gold_auto_answer(pending_question) {
99        return GoldAutoAnswerOutcome::Skipped {
100            reason: "pending_question_not_whitelisted".to_string(),
101        };
102    }
103
104    let state_evaluation = match evaluate_gold_state_for_pending_question(
105        state,
106        session_id,
107        &session,
108        &gold_config,
109    )
110    .await
111    {
112        Ok(result) => result,
113        Err(error) => {
114            tracing::warn!(
115                session_id = %session_id,
116                error = %error,
117                "Gold auto-answer skipped because Gold state evaluation failed"
118            );
119            return GoldAutoAnswerOutcome::Skipped {
120                reason: format!("state_evaluation_failed:{error}"),
121            };
122        }
123    };
124
125    if !state_evaluation
126        .confidence
127        .meets(gold_config.min_auto_continue_confidence)
128    {
129        return GoldAutoAnswerOutcome::Skipped {
130            reason: format!(
131                "state_evaluation_confidence_{}",
132                state_evaluation.confidence.as_str()
133            ),
134        };
135    }
136
137    if !matches!(
138        state_evaluation.decision,
139        GoldDecision::Continue | GoldDecision::NeedInput
140    ) {
141        return GoldAutoAnswerOutcome::Skipped {
142            reason: format!(
143                "state_evaluation_decision_{}",
144                state_evaluation.decision.as_str()
145            ),
146        };
147    }
148
149    let answer_decision = match evaluate_gold_auto_answer_question(
150        state,
151        session_id,
152        &session,
153        &gold_config,
154        &state_evaluation,
155    )
156    .await
157    {
158        Ok(result) => result,
159        Err(error) => {
160            tracing::warn!(
161                session_id = %session_id,
162                error = %error,
163                "Gold auto-answer skipped because question evaluation failed"
164            );
165            return GoldAutoAnswerOutcome::Skipped {
166                reason: format!("question_evaluation_failed:{error}"),
167            };
168        }
169    };
170
171    if !answer_decision.apply {
172        return GoldAutoAnswerOutcome::Skipped {
173            reason: format!("question_decision_declined:{}", answer_decision.reasoning),
174        };
175    }
176
177    if !answer_decision
178        .confidence
179        .meets(gold_config.min_auto_continue_confidence)
180    {
181        return GoldAutoAnswerOutcome::Skipped {
182            reason: format!(
183                "question_decision_confidence_{}",
184                answer_decision.confidence.as_str()
185            ),
186        };
187    }
188
189    let Some(raw_answer) = answer_decision.answer.as_deref() else {
190        return GoldAutoAnswerOutcome::Skipped {
191            reason: "question_decision_missing_answer".to_string(),
192        };
193    };
194
195    let Some(answer) = canonicalize_pending_answer(pending_question, raw_answer) else {
196        return GoldAutoAnswerOutcome::Skipped {
197            reason: "question_decision_answer_not_canonical".to_string(),
198        };
199    };
200
201    tracing::info!(
202        session_id = %session_id,
203        tool_name = %pending_question.tool_name,
204        answer = %answer,
205        reasoning = %answer_decision.reasoning,
206        "Applying Gold auto-answer for pending clarification"
207    );
208
209    let respond_input = RespondInput {
210        session_id: session_id.to_string(),
211        user_response: answer.clone(),
212        model: None,
213        model_ref: None,
214        provider: None,
215        reasoning_effort: session.reasoning_effort,
216    };
217
218    let (updated_session, _submitted_answer, plan_mode_transition) =
219        match submit_pending_response_with_source(
220            state,
221            respond_input,
222            ResponseSource::Gold,
223        )
224        .await
225        {
226            Ok(result) => result,
227            Err(error) => {
228                tracing::warn!(
229                    session_id = %session_id,
230                    error = %error,
231                    "Gold auto-answer skipped because submitting the response failed"
232                );
233                return GoldAutoAnswerOutcome::Skipped {
234                    reason: format!("submit_pending_response_failed:{error}"),
235                };
236            }
237        };
238
239    if let Some(event) = plan_mode_transition_event(session_id, plan_mode_transition.as_ref()) {
240        publish_replayable_session_event(state, session_id, event).await;
241    }
242
243    let resume_config =
244        build_resume_config_snapshot(state, &updated_session, Some(gold_config.clone()))
245            .await;
246    let resume_outcome =
247        resume_session_execution(resume_port, session_id, resume_config)
248            .await;
249
250    tracing::info!(
251        session_id = %session_id,
252        resume_status = %resume_outcome.status_str(),
253        resume_run_id = %resume_outcome.run_id().map(String::as_str).unwrap_or_default(),
254        "Gold auto-answer completed"
255    );
256
257    GoldAutoAnswerOutcome::Applied {
258        answer,
259        resume_outcome,
260    }
261}