Skip to main content

bamboo_engine/gold_auto_answer/
mod.rs

1use crate::config::GoldConfig;
2use bamboo_agent_core::GoldDecision;
3
4use crate::app_context::AgentSessionContext;
5use crate::events::publish_replayable_session_event;
6use crate::model_config_helper::{resolve_gold_config, GOLD_CONFIG_METADATA_KEY};
7use crate::session_app::repository::SessionAccess;
8use crate::session_app::respond::{
9    acquire_pending_response_guard, inspect_pending_response_guarded,
10    submit_pending_response_with_source_checked_guarded, ResponseSource,
11};
12use crate::session_app::resume::ResumeExecutionPort;
13use crate::session_app::types::{RespondInput, ResumeOutcome};
14
15mod decision;
16mod evaluation;
17mod prompt;
18mod resume;
19
20#[cfg(test)]
21mod tests;
22
23use decision::{
24    canonicalize_pending_answer, session_is_awaiting_clarification, should_attempt_gold_auto_answer,
25};
26use evaluation::{evaluate_gold_auto_answer_question, evaluate_gold_state_for_pending_question};
27#[cfg(test)]
28pub(crate) use evaluation::{
29    evaluate_gold_auto_answer_question_with_target, evaluate_gold_state_with_target,
30    GoldAuxiliaryTarget,
31};
32use resume::{build_resume_config_snapshot, plan_mode_transition_event};
33
34const GOLD_AUTO_ANSWER_TOOL_NAME: &str = "report_gold_auto_answer";
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum GoldAutoAnswerOutcome {
38    Skipped {
39        reason: String,
40    },
41    Applied {
42        answer: String,
43        resume_outcome: ResumeOutcome,
44    },
45}
46
47/// Attempt a Gold auto-answer for a session's pending clarification.
48///
49/// `state` supplies session/provider/event context (and session persistence
50/// via [`SessionAccess`]); `resume_port` is the server-side adapter that knows
51/// how to actually spawn a resumed agent execution.
52pub async fn maybe_auto_answer_pending_question<S>(
53    state: &S,
54    resume_port: &dyn ResumeExecutionPort,
55    session_id: &str,
56    gold_config_override: Option<GoldConfig>,
57) -> GoldAutoAnswerOutcome
58where
59    S: AgentSessionContext + SessionAccess,
60{
61    let Some(session) = state.load_session_merged(session_id).await else {
62        return GoldAutoAnswerOutcome::Skipped {
63            reason: "session_not_found".to_string(),
64        };
65    };
66
67    let config_snapshot = state.config().read().await.clone();
68    let Some(gold_config) = gold_config_override.or_else(|| {
69        resolve_gold_config(
70            &config_snapshot,
71            session
72                .metadata
73                .get(GOLD_CONFIG_METADATA_KEY)
74                .map(String::as_str),
75        )
76    }) else {
77        return GoldAutoAnswerOutcome::Skipped {
78            reason: "gold_config_unavailable".to_string(),
79        };
80    };
81
82    let Some(pending_question) = session.pending_question.as_ref() else {
83        return GoldAutoAnswerOutcome::Skipped {
84            reason: "no_pending_question".to_string(),
85        };
86    };
87
88    if !gold_config.enabled {
89        return GoldAutoAnswerOutcome::Skipped {
90            reason: "gold_disabled".to_string(),
91        };
92    }
93
94    if !gold_config.auto_answer_enabled {
95        return GoldAutoAnswerOutcome::Skipped {
96            reason: "gold_auto_answer_disabled".to_string(),
97        };
98    }
99
100    if !session_is_awaiting_clarification(&session) {
101        return GoldAutoAnswerOutcome::Skipped {
102            reason: "session_not_awaiting_clarification".to_string(),
103        };
104    }
105
106    if !should_attempt_gold_auto_answer(pending_question) {
107        return GoldAutoAnswerOutcome::Skipped {
108            reason: "pending_question_not_whitelisted".to_string(),
109        };
110    }
111
112    let state_evaluation =
113        match evaluate_gold_state_for_pending_question(state, session_id, &session, &gold_config)
114            .await
115        {
116            Ok(result) => result,
117            Err(error) => {
118                tracing::warn!(
119                    session_id = %session_id,
120                    error = %error,
121                    "Gold auto-answer skipped because Gold state evaluation failed"
122                );
123                return GoldAutoAnswerOutcome::Skipped {
124                    reason: format!("state_evaluation_failed:{error}"),
125                };
126            }
127        };
128
129    if !state_evaluation
130        .confidence
131        .meets(gold_config.min_auto_continue_confidence)
132    {
133        return GoldAutoAnswerOutcome::Skipped {
134            reason: format!(
135                "state_evaluation_confidence_{}",
136                state_evaluation.confidence.as_str()
137            ),
138        };
139    }
140
141    if !matches!(
142        state_evaluation.decision,
143        GoldDecision::Continue | GoldDecision::NeedInput
144    ) {
145        return GoldAutoAnswerOutcome::Skipped {
146            reason: format!(
147                "state_evaluation_decision_{}",
148                state_evaluation.decision.as_str()
149            ),
150        };
151    }
152
153    let answer_decision = match evaluate_gold_auto_answer_question(
154        state,
155        session_id,
156        &session,
157        &gold_config,
158        &state_evaluation,
159    )
160    .await
161    {
162        Ok(result) => result,
163        Err(error) => {
164            tracing::warn!(
165                session_id = %session_id,
166                error = %error,
167                "Gold auto-answer skipped because question evaluation failed"
168            );
169            return GoldAutoAnswerOutcome::Skipped {
170                reason: format!("question_evaluation_failed:{error}"),
171            };
172        }
173    };
174
175    if !answer_decision.apply {
176        return GoldAutoAnswerOutcome::Skipped {
177            reason: format!("question_decision_declined:{}", answer_decision.reasoning),
178        };
179    }
180
181    if !answer_decision
182        .confidence
183        .meets(gold_config.min_auto_continue_confidence)
184    {
185        return GoldAutoAnswerOutcome::Skipped {
186            reason: format!(
187                "question_decision_confidence_{}",
188                answer_decision.confidence.as_str()
189            ),
190        };
191    }
192
193    let Some(raw_answer) = answer_decision.answer.as_deref() else {
194        return GoldAutoAnswerOutcome::Skipped {
195            reason: "question_decision_missing_answer".to_string(),
196        };
197    };
198
199    // Gold evaluation is intentionally outside the response single-flight, but
200    // the answer transaction re-enters the same gate as HTTP and Connect. A
201    // human may have answered while the evaluator was running; reload before
202    // reserving so stale Gold work cannot replace that successor's runner.
203    let evaluated_tool_call_id = pending_question.tool_call_id.clone();
204    let response_guard = acquire_pending_response_guard(session_id).await;
205    let current = match inspect_pending_response_guarded(state, session_id, &response_guard).await {
206        Ok(Some(session)) => session,
207        Ok(None) => {
208            return GoldAutoAnswerOutcome::Skipped {
209                reason: "session_not_found_after_evaluation".to_string(),
210            };
211        }
212        Err(error) => {
213            return GoldAutoAnswerOutcome::Skipped {
214                reason: format!("response_preflight_failed:{error}"),
215            };
216        }
217    };
218    let Some(current_pending) = current.pending_question.as_ref() else {
219        return GoldAutoAnswerOutcome::Skipped {
220            reason: "pending_question_consumed_during_evaluation".to_string(),
221        };
222    };
223    if current_pending.tool_call_id != evaluated_tool_call_id {
224        return GoldAutoAnswerOutcome::Skipped {
225            reason: "pending_question_changed_during_evaluation".to_string(),
226        };
227    }
228    let Some(answer) = canonicalize_pending_answer(current_pending, raw_answer) else {
229        return GoldAutoAnswerOutcome::Skipped {
230            reason: "question_decision_answer_not_canonical".to_string(),
231        };
232    };
233
234    tracing::info!(
235        session_id = %session_id,
236        tool_name = %current_pending.tool_name,
237        answer = %answer,
238        reasoning = %answer_decision.reasoning,
239        "Applying Gold auto-answer for pending clarification"
240    );
241
242    let mut handoff = match crate::session_app::resume::reserve_response_resume_handoff(
243        resume_port,
244        session_id,
245        std::time::Duration::from_secs(15),
246    )
247    .await
248    {
249        Ok(handoff) => handoff,
250        Err(_) => {
251            return GoldAutoAnswerOutcome::Skipped {
252                reason: "suspending_runner_still_finalizing".to_string(),
253            };
254        }
255    };
256
257    let respond_input = RespondInput {
258        session_id: session_id.to_string(),
259        user_response: answer.clone(),
260        model: None,
261        model_ref: None,
262        provider: None,
263        reasoning_effort: current.reasoning_effort,
264    };
265
266    let bound_access = match state.bind_response_writer(handoff.root_actor_writer()) {
267        Ok(access) => access,
268        Err(error) => {
269            handoff.abandon().await;
270            return GoldAutoAnswerOutcome::Skipped {
271                reason: format!("response_writer_unavailable:{error}"),
272            };
273        }
274    };
275    let response_access: &dyn SessionAccess = bound_access.as_deref().unwrap_or(state);
276
277    // Gold (eval) auto-answers do not record permission grants; eval sessions
278    // should run with a permissive posture (e.g. BypassPermissions) so they never
279    // pause for approval in the first place.
280    let (updated_session, _submitted_answer, plan_mode_transition, _permission_grants) =
281        match submit_pending_response_with_source_checked_guarded(
282            response_access,
283            respond_input,
284            Some(evaluated_tool_call_id),
285            ResponseSource::Gold,
286            &response_guard,
287        )
288        .await
289        {
290            Ok(result) => result,
291            Err(error) => {
292                handoff.abandon().await;
293                tracing::warn!(
294                    session_id = %session_id,
295                    error = %error,
296                    "Gold auto-answer skipped because submitting the response failed"
297                );
298                return GoldAutoAnswerOutcome::Skipped {
299                    reason: format!("submit_pending_response_failed:{error}"),
300                };
301            }
302        };
303
304    let plan_mode_event = plan_mode_transition_event(session_id, plan_mode_transition.as_ref());
305    let root_response = handoff.root_actor_writer().is_some();
306    if root_response {
307        if let Some(event) = plan_mode_event.clone() {
308            handoff.publish_event(event);
309        }
310    }
311    let resume_config = build_resume_config_snapshot(
312        state,
313        &config_snapshot,
314        &updated_session,
315        Some(gold_config.clone()),
316    );
317    let resume_outcome = crate::session_app::resume::resume_session_execution_with_handoff(
318        resume_port,
319        session_id,
320        updated_session,
321        resume_config,
322        handoff,
323    )
324    .await;
325    drop(response_guard);
326
327    // The execution handoff is already owned by a detached task, so this
328    // replayable metadata publication cannot strand the committed answer if
329    // the Gold evaluator is cancelled while awaiting its runner/cache locks.
330    if let Some(event) = plan_mode_event.filter(|_| !root_response) {
331        publish_replayable_session_event(state, session_id, event).await;
332    }
333
334    tracing::info!(
335        session_id = %session_id,
336        resume_status = %resume_outcome.status_str(),
337        resume_run_id = %resume_outcome.run_id().map(String::as_str).unwrap_or_default(),
338        "Gold auto-answer completed"
339    );
340
341    GoldAutoAnswerOutcome::Applied {
342        answer,
343        resume_outcome,
344    }
345}