bamboo_engine/gold_auto_answer/
mod.rs1use 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
47pub 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 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 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 (updated_session, _submitted_answer, plan_mode_transition, _permission_grants) =
270 match submit_pending_response_with_source_checked_guarded(
271 state,
272 respond_input,
273 Some(evaluated_tool_call_id),
274 ResponseSource::Gold,
275 &response_guard,
276 )
277 .await
278 {
279 Ok(result) => result,
280 Err(error) => {
281 handoff.abandon().await;
282 tracing::warn!(
283 session_id = %session_id,
284 error = %error,
285 "Gold auto-answer skipped because submitting the response failed"
286 );
287 return GoldAutoAnswerOutcome::Skipped {
288 reason: format!("submit_pending_response_failed:{error}"),
289 };
290 }
291 };
292
293 let plan_mode_event = plan_mode_transition_event(session_id, plan_mode_transition.as_ref());
294 let resume_config = build_resume_config_snapshot(
295 state,
296 &config_snapshot,
297 &updated_session,
298 Some(gold_config.clone()),
299 );
300 let resume_outcome = crate::session_app::resume::resume_session_execution_with_handoff(
301 resume_port,
302 session_id,
303 updated_session,
304 resume_config,
305 handoff,
306 )
307 .await;
308 drop(response_guard);
309
310 if let Some(event) = plan_mode_event {
314 publish_replayable_session_event(state, session_id, event).await;
315 }
316
317 tracing::info!(
318 session_id = %session_id,
319 resume_status = %resume_outcome.status_str(),
320 resume_run_id = %resume_outcome.run_id().map(String::as_str).unwrap_or_default(),
321 "Gold auto-answer completed"
322 );
323
324 GoldAutoAnswerOutcome::Applied {
325 answer,
326 resume_outcome,
327 }
328}