1use std::sync::Arc;
21
22use tokio::sync::broadcast;
23
24use bamboo_agent_core::tools::{ToolCall, ToolExecutionContext};
25use bamboo_agent_core::{AgentEvent, Session};
26use bamboo_engine::execution::{
27 create_event_forwarder, get_or_create_event_sender, try_reserve_runner, RunnerReservation,
28};
29use bamboo_engine::runtime::execution::agent_spawn::{
30 spawn_session_execution, SessionExecutionArgs,
31};
32use bamboo_engine::session_app::resolution::resolve_resume_config_snapshot;
33use bamboo_engine::session_app::respond::{
34 submit_pending_response, PERMISSION_REEXECUTE_METADATA_KEY,
35};
36use bamboo_engine::session_app::resume::{
37 resume_session_execution, ResumeExecutionPort, ResumeSpawnRequest,
38};
39use bamboo_engine::session_app::types::RespondInput;
40use bamboo_engine::{ModelRoster, RoleModel};
41
42use super::bridge::ConnectContext;
43use super::platform::{Button, OutboundMessage, Platform, PlatformResult, ReplyCtx};
44use super::render::PendingAsk;
45
46const BUTTON_LABEL_MAX_CHARS: usize = 48;
50
51#[derive(Debug, Clone)]
59pub struct ParkedAsk {
60 pub nonce: String,
64 pub session_id: String,
65 pub tool_call_id: String,
66 pub tool_name: String,
67 pub question: String,
68 pub options: Vec<String>,
69 pub allow_custom: bool,
70}
71
72impl ParkedAsk {
73 pub fn new(nonce: String, session_id: String, ask: &PendingAsk) -> Self {
74 Self {
75 nonce,
76 session_id,
77 tool_call_id: ask.tool_call_id.clone(),
78 tool_name: ask.tool_name.clone(),
79 question: ask.question.clone(),
80 options: ask.options.clone(),
81 allow_custom: ask.allow_custom,
82 }
83 }
84}
85
86pub fn new_nonce() -> String {
91 let raw = uuid::Uuid::new_v4().to_string();
92 raw.split('-').next().unwrap_or(&raw).to_string()
93}
94
95fn truncate_label(text: &str) -> String {
100 if text.chars().count() <= BUTTON_LABEL_MAX_CHARS {
101 return text.to_string();
102 }
103 let mut out: String = text.chars().take(BUTTON_LABEL_MAX_CHARS - 1).collect();
104 out.push('…');
105 out
106}
107
108fn format_ask_text(ask: &ParkedAsk) -> String {
111 let mut text = ask.question.clone();
112 if !ask.options.is_empty() {
113 text.push_str("\n\n");
114 for (index, option) in ask.options.iter().enumerate() {
115 text.push_str(&format!("{}. {}\n", index + 1, option));
116 }
117 }
118 if ask.allow_custom {
119 text.push_str("\n(or reply with your own answer)");
120 }
121 text
122}
123
124pub async fn render_ask(
131 platform: &Arc<dyn Platform>,
132 reply_ctx: &ReplyCtx,
133 ask: &ParkedAsk,
134 buttons_capable: bool,
135) -> PlatformResult<()> {
136 let text = format_ask_text(ask);
137 let outbound = if buttons_capable && !ask.options.is_empty() {
138 let rows: Vec<Vec<Button>> = ask
139 .options
140 .iter()
141 .enumerate()
142 .map(|(index, option)| {
143 vec![Button::new(
144 truncate_label(option),
145 format!("{}:{index}", ask.nonce),
146 )]
147 })
148 .collect();
149 OutboundMessage::text(text).with_buttons(rows)
150 } else {
151 OutboundMessage::text(text)
152 };
153 platform.reply(reply_ctx, outbound).await.map(|_| ())
154}
155
156const AFFIRMATIVE_KEYWORDS: &[&str] = &[
161 "允许", "同意", "确定", "是", "yes", "allow", "approve", "ok",
162];
163const NEGATIVE_KEYWORDS: &[&str] = &["拒绝", "不", "否", "no", "deny", "reject", "stay"];
172
173fn classify_intent(text: &str) -> Option<bool> {
174 let lower = text.trim().to_lowercase();
175 if AFFIRMATIVE_KEYWORDS.iter().any(|keyword| lower == *keyword) {
176 return Some(true);
177 }
178 if NEGATIVE_KEYWORDS.iter().any(|keyword| lower == *keyword) {
179 return Some(false);
180 }
181 None
182}
183
184fn pick_option_by_intent(options: &[String], affirmative: bool) -> Option<String> {
189 let keywords: &[&str] = if affirmative {
190 AFFIRMATIVE_KEYWORDS
191 } else {
192 NEGATIVE_KEYWORDS
193 };
194 if let Some(option) = options.iter().find(|option| {
195 let lower = option.to_lowercase();
196 keywords.iter().any(|keyword| lower.contains(keyword))
197 }) {
198 return Some(option.clone());
199 }
200 if options.len() == 2 {
201 return Some(if affirmative {
202 options[0].clone()
203 } else {
204 options[1].clone()
205 });
206 }
207 None
208}
209
210pub fn match_text_answer(ask: &ParkedAsk, text: &str) -> Option<String> {
222 let trimmed = text.trim();
223 if trimmed.is_empty() {
224 return None;
225 }
226 if let Ok(index) = trimmed.parse::<usize>() {
227 if index >= 1 && index <= ask.options.len() {
228 return Some(ask.options[index - 1].clone());
229 }
230 }
231 if let Some(option) = ask
232 .options
233 .iter()
234 .find(|option| option.eq_ignore_ascii_case(trimmed))
235 {
236 return Some(option.clone());
237 }
238 if !ask.allow_custom {
239 if let Some(intent) = classify_intent(trimmed) {
240 if let Some(option) = pick_option_by_intent(&ask.options, intent) {
241 return Some(option);
242 }
243 }
244 }
245 if ask.allow_custom {
246 return Some(trimmed.to_string());
247 }
248 None
249}
250
251pub fn match_callback_data(ask: &ParkedAsk, data: &str) -> Option<String> {
257 let (nonce, index_str) = data.split_once(':')?;
258 if nonce != ask.nonce {
259 return None;
260 }
261 let index: usize = index_str.parse().ok()?;
262 ask.options.get(index).cloned()
263}
264
265pub enum RespondAndResumeOutcome {
271 Resumed(broadcast::Receiver<AgentEvent>),
274 NotResumed(String),
279}
280
281#[derive(Debug, thiserror::Error)]
285pub enum ResponderError {
286 #[error("session not found")]
287 NotFound,
288 #[error("no pending question waiting for a response")]
289 NoPendingQuestion,
290 #[error("invalid response: {0}")]
291 InvalidResponse(String),
292 #[error("{0}")]
293 Other(String),
294}
295
296#[async_trait::async_trait]
303pub trait Responder: Send + Sync {
304 async fn respond_and_resume(
305 &self,
306 session_id: &str,
307 answer: String,
308 ) -> Result<RespondAndResumeOutcome, ResponderError>;
309}
310
311fn map_respond_error(error: bamboo_engine::session_app::errors::RespondError) -> ResponderError {
312 use bamboo_engine::session_app::errors::RespondError;
313 match error {
314 RespondError::NotFound(_) => ResponderError::NotFound,
315 RespondError::NoPendingQuestion => ResponderError::NoPendingQuestion,
316 RespondError::InvalidResponse(message) => ResponderError::InvalidResponse(message),
317 other => ResponderError::Other(other.to_string()),
318 }
319}
320
321pub struct EngineResponder {
331 ctx: ConnectContext,
332}
333
334impl EngineResponder {
335 pub fn new(ctx: ConnectContext) -> Self {
336 Self { ctx }
337 }
338}
339
340#[async_trait::async_trait]
341impl Responder for EngineResponder {
342 async fn respond_and_resume(
343 &self,
344 session_id: &str,
345 answer: String,
346 ) -> Result<RespondAndResumeOutcome, ResponderError> {
347 let input = RespondInput {
348 session_id: session_id.to_string(),
349 user_response: answer,
350 model: None,
351 model_ref: None,
352 provider: None,
353 reasoning_effort: None,
354 };
355
356 let (session, _submitted_answer, plan_mode_transition, permission_grants) =
357 submit_pending_response(&self.ctx.session_repo, input)
358 .await
359 .map_err(map_respond_error)?;
360
361 for (perm_type, resource) in &permission_grants {
365 self.ctx
366 .permission_checker
367 .grant_session_permission(*perm_type, resource.clone());
368 }
369
370 let session_tx =
374 get_or_create_event_sender(&self.ctx.session_event_senders, session_id).await;
375 let receiver = session_tx.subscribe();
376
377 if let Some(event) = plan_mode_transition_event(session_id, plan_mode_transition.as_ref()) {
378 let _ = session_tx.send(event);
379 }
380
381 let config_snapshot = self.ctx.config.read().await.clone();
382 let resume_config = resolve_resume_config_snapshot(
383 &config_snapshot,
384 &self.ctx.provider_registry,
385 &session,
386 None,
387 );
388
389 let port = ConnectResumePort {
390 ctx: self.ctx.clone(),
391 };
392 let outcome = resume_session_execution(&port, session_id, resume_config).await;
393
394 match outcome {
395 bamboo_engine::session_app::types::ResumeOutcome::Started { .. } => {
396 Ok(RespondAndResumeOutcome::Resumed(receiver))
397 }
398 bamboo_engine::session_app::types::ResumeOutcome::AlreadyRunning { .. } => Ok(
399 RespondAndResumeOutcome::NotResumed("this session is already running".to_string()),
400 ),
401 bamboo_engine::session_app::types::ResumeOutcome::Completed => Ok(
402 RespondAndResumeOutcome::NotResumed("nothing left to resume".to_string()),
403 ),
404 bamboo_engine::session_app::types::ResumeOutcome::NotFound => Ok(
405 RespondAndResumeOutcome::NotResumed("session no longer exists".to_string()),
406 ),
407 }
408 }
409}
410
411fn plan_mode_transition_event(
412 session_id: &str,
413 transition: Option<&bamboo_engine::session_app::respond::PlanModeTransition>,
414) -> Option<AgentEvent> {
415 use bamboo_engine::session_app::respond::PlanModeTransition;
416 transition.map(|transition| match transition {
417 PlanModeTransition::Entered {
418 reason,
419 pre_permission_mode,
420 entered_at,
421 status,
422 plan_file_path,
423 } => AgentEvent::PlanModeEntered {
424 session_id: session_id.to_string(),
425 reason: reason.clone(),
426 pre_permission_mode: pre_permission_mode.clone(),
427 entered_at: *entered_at,
428 status: *status,
429 plan_file_path: plan_file_path.clone(),
430 },
431 PlanModeTransition::Exited {
432 approved,
433 restored_mode,
434 plan,
435 } => AgentEvent::PlanModeExited {
436 session_id: session_id.to_string(),
437 approved: *approved,
438 restored_mode: restored_mode.clone(),
439 plan: plan.clone(),
440 },
441 })
442}
443
444struct ConnectResumePort {
458 ctx: ConnectContext,
459}
460
461#[async_trait::async_trait]
462impl ResumeExecutionPort for ConnectResumePort {
463 async fn load_session(&self, session_id: &str) -> Option<Session> {
464 self.ctx.session_repo.load_merged(session_id).await
465 }
466
467 async fn save_and_cache_session(&self, session: &mut Session) {
468 self.ctx.session_repo.save_and_cache(session).await;
469 }
470
471 async fn try_reserve_runner(
472 &self,
473 session_id: &str,
474 event_sender: &broadcast::Sender<AgentEvent>,
475 ) -> Option<RunnerReservation> {
476 try_reserve_runner(
477 &self.ctx.agent_runners,
478 &self.ctx.session_event_senders,
479 session_id,
480 event_sender,
481 )
482 .await
483 }
484
485 async fn get_existing_runner_run_id(&self, session_id: &str) -> Option<String> {
486 let runners = self.ctx.agent_runners.read().await;
487 runners.get(session_id).map(|runner| runner.run_id.clone())
488 }
489
490 async fn get_or_create_event_sender(&self, session_id: &str) -> broadcast::Sender<AgentEvent> {
491 get_or_create_event_sender(&self.ctx.session_event_senders, session_id).await
492 }
493
494 async fn spawn_resume_execution(&self, request: ResumeSpawnRequest) {
495 let ResumeSpawnRequest {
496 session_id,
497 session,
498 cancel_token,
499 run_id: _,
500 event_sender,
501 config,
502 } = request;
503
504 let model = session.model.clone();
505 let reasoning_effort = session.reasoning_effort;
506 let model_roster = ModelRoster {
507 model: Some(model),
508 provider_name: Some(config.provider_name.clone()),
509 provider_type: config.provider_type.clone(),
510 fast: RoleModel::from_parts(config.fast_model.clone(), None),
511 background: RoleModel::from_parts(
512 config.background_model.clone(),
513 config.background_model_provider.clone(),
514 ),
515 summarization: RoleModel::from_parts(
516 config.summarization_model.clone(),
517 config.summarization_model_provider.clone(),
518 ),
519 };
520
521 let (mpsc_tx, _forwarder_handle) = create_event_forwarder(
522 session_id.clone(),
523 event_sender,
524 self.ctx.agent_runners.clone(),
525 self.ctx.account_feed_inbox.clone(),
526 );
527
528 let reexecute_tool_call_id = session
536 .metadata
537 .get(PERMISSION_REEXECUTE_METADATA_KEY)
538 .cloned();
539
540 let Some(reexecute_tool_call_id) = reexecute_tool_call_id else {
541 spawn_session_execution(SessionExecutionArgs {
542 agent: self.ctx.agent.clone(),
543 session_id,
544 session,
545 tools_override: Some(self.ctx.tools.clone()),
546 provider_override: None,
547 model_roster,
548 reasoning_effort,
549 reasoning_effort_source: "connect_resume".to_string(),
550 auxiliary_model_resolver: None,
551 disabled_filter_resolver: None,
552 disabled_tools: Some(config.disabled_tools.clone()),
553 disabled_skill_ids: Some(config.disabled_skill_ids.clone()),
554 selected_skill_ids: None,
555 selected_skill_mode: None,
556 cancel_token,
557 mpsc_tx,
558 image_fallback: config.image_fallback.clone(),
559 gold_config: config.gold_config.clone(),
560 guardian_config: None,
561 guardian_spawner: None,
562 bash_resume_hook: None,
563 bash_completion_sink: None,
564 app_data_dir: self.ctx.app_data_dir.clone(),
565 run_budget: None,
568 runners: self.ctx.agent_runners.clone(),
569 sessions_cache: self.ctx.session_repo.cache().clone(),
570 on_complete: None,
571 child_completion_handler: None,
574 });
575 return;
576 };
577
578 let ctx = self.ctx.clone();
579 tokio::spawn(async move {
580 let mut session = session;
581 session.metadata.remove(PERMISSION_REEXECUTE_METADATA_KEY);
582
583 if let Some(tool_call) = find_pending_tool_call(&session, &reexecute_tool_call_id) {
584 let executor = ctx.tools.clone();
585 let tool_name = tool_call.function.name.clone();
586 let is_mutating = bamboo_tools::orchestrator::classify_tool(&tool_name)
587 == bamboo_tools::orchestrator::ToolMutability::Mutating;
588
589 let mut emitter =
590 bamboo_tools::ToolEmitter::new(&tool_call.id, &tool_name, is_mutating);
591 emitter.set_auto_approved(true);
592 let _ = mpsc_tx
593 .send(emitter.begin().clone().into_agent_event())
594 .await;
595
596 let exec_result = {
597 let exec_ctx = ToolExecutionContext {
598 session_id: Some(session.id.as_str()),
599 tool_call_id: reexecute_tool_call_id.as_str(),
600 event_tx: Some(&mpsc_tx),
601 available_tool_schemas: None,
602 bypass_permissions: false,
603 can_async_resume: false,
604 bash_completion_sink: None,
605 pre_parsed_args: None,
606 };
607 executor.execute_with_context(&tool_call, exec_ctx).await
608 };
609
610 let (content, success) = match exec_result {
611 Ok(tool_result) => {
612 let _ = mpsc_tx
613 .send(
614 emitter
615 .finish(Some("Re-executed after approval".to_string()))
616 .clone()
617 .into_agent_event(),
618 )
619 .await;
620 let _ = mpsc_tx
621 .send(AgentEvent::ToolComplete {
622 tool_call_id: tool_call.id.clone(),
623 result: tool_result.clone(),
624 })
625 .await;
626 (tool_result.result, tool_result.success)
627 }
628 Err(error) => {
629 let message = format!("Tool re-execution after approval failed: {error}");
630 let _ = mpsc_tx
631 .send(emitter.error(message.clone()).clone().into_agent_event())
632 .await;
633 (message, false)
634 }
635 };
636
637 tracing::info!(
638 "[{}] connect: re-executed approved tool '{}' ({}) -> success={}",
639 session_id,
640 tool_name,
641 reexecute_tool_call_id,
642 success
643 );
644 apply_tool_result(&mut session, &reexecute_tool_call_id, content, success);
645 ctx.session_repo.save_and_cache(&mut session).await;
646 } else {
647 tracing::warn!(
648 "[{}] connect: permission re-exec marker set but tool call '{}' not found in history",
649 session_id,
650 reexecute_tool_call_id
651 );
652 }
653
654 spawn_session_execution(SessionExecutionArgs {
655 agent: ctx.agent.clone(),
656 session_id,
657 session,
658 tools_override: Some(ctx.tools.clone()),
659 provider_override: None,
660 model_roster,
661 reasoning_effort,
662 reasoning_effort_source: "connect_resume".to_string(),
663 auxiliary_model_resolver: None,
664 disabled_filter_resolver: None,
665 disabled_tools: Some(config.disabled_tools.clone()),
666 disabled_skill_ids: Some(config.disabled_skill_ids.clone()),
667 selected_skill_ids: None,
668 selected_skill_mode: None,
669 cancel_token,
670 mpsc_tx,
671 image_fallback: config.image_fallback.clone(),
672 gold_config: config.gold_config.clone(),
673 guardian_config: None,
674 guardian_spawner: None,
675 bash_resume_hook: None,
676 bash_completion_sink: None,
677 app_data_dir: ctx.app_data_dir.clone(),
678 run_budget: None,
681 runners: ctx.agent_runners.clone(),
682 sessions_cache: ctx.session_repo.cache().clone(),
683 on_complete: None,
684 child_completion_handler: None,
687 });
688 });
689 }
690}
691
692fn find_pending_tool_call(session: &Session, tool_call_id: &str) -> Option<ToolCall> {
695 session.messages.iter().find_map(|message| {
696 message
697 .tool_calls
698 .as_ref()
699 .and_then(|calls| calls.iter().find(|call| call.id == tool_call_id).cloned())
700 })
701}
702
703fn apply_tool_result(session: &mut Session, tool_call_id: &str, content: String, success: bool) {
706 for message in &mut session.messages {
707 if message.tool_call_id.as_deref() == Some(tool_call_id) {
708 message.content = content;
709 message.tool_success = Some(success);
710 return;
711 }
712 }
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718
719 fn ask(options: Vec<&str>, allow_custom: bool) -> ParkedAsk {
720 ParkedAsk {
721 nonce: "abc12345".to_string(),
722 session_id: "sess-1".to_string(),
723 tool_call_id: "call-1".to_string(),
724 tool_name: "conclusion_with_options".to_string(),
725 question: "Approve?".to_string(),
726 options: options.into_iter().map(str::to_string).collect(),
727 allow_custom,
728 }
729 }
730
731 #[test]
732 fn new_nonce_is_short_and_hex_like() {
733 let nonce = new_nonce();
734 assert!(!nonce.is_empty());
735 assert!(nonce.len() <= 16);
736 assert!(nonce.chars().all(|c| c.is_ascii_hexdigit()));
737 }
738
739 #[test]
740 fn match_text_answer_numeric_index_selects_option() {
741 let pending = ask(vec!["Approve", "Deny"], false);
742 assert_eq!(
743 match_text_answer(&pending, "1"),
744 Some("Approve".to_string())
745 );
746 assert_eq!(match_text_answer(&pending, "2"), Some("Deny".to_string()));
747 assert_eq!(match_text_answer(&pending, "3"), None);
748 }
749
750 #[test]
751 fn match_text_answer_exact_text_is_case_insensitive() {
752 let pending = ask(vec!["Approve", "Deny"], false);
753 assert_eq!(
754 match_text_answer(&pending, "approve"),
755 Some("Approve".to_string())
756 );
757 }
758
759 #[test]
760 fn match_text_answer_binary_keyword_mapping() {
761 let pending = ask(vec!["Approve", "Deny"], false);
762 assert_eq!(
763 match_text_answer(&pending, "允许"),
764 Some("Approve".to_string())
765 );
766 assert_eq!(
767 match_text_answer(&pending, "yes"),
768 Some("Approve".to_string())
769 );
770 assert_eq!(
771 match_text_answer(&pending, "deny"),
772 Some("Deny".to_string())
773 );
774 assert_eq!(match_text_answer(&pending, "no"), Some("Deny".to_string()));
775 }
776
777 #[test]
782 fn match_text_answer_exact_option_named_stay_beats_negative_keyword_fallback() {
783 let pending = ask(vec!["Stay", "Leave"], false);
784 assert_eq!(
785 match_text_answer(&pending, "stay"),
786 Some("Stay".to_string())
787 );
788 let plan_pending = ask(vec!["Approve", "Stay in plan mode"], false);
791 assert_eq!(
792 match_text_answer(&plan_pending, "stay"),
793 Some("Stay in plan mode".to_string())
794 );
795 }
796
797 #[test]
798 fn match_text_answer_closed_ask_non_matching_text_falls_through() {
799 let pending = ask(vec!["Approve", "Deny"], false);
800 assert_eq!(match_text_answer(&pending, "banana"), None);
801 }
802
803 #[test]
804 fn match_text_answer_open_question_accepts_any_free_text() {
805 let pending = ask(vec!["OK", "Need changes"], true);
806 assert_eq!(
807 match_text_answer(&pending, "please add tests too"),
808 Some("please add tests too".to_string())
809 );
810 }
811
812 #[test]
813 fn match_text_answer_empty_text_never_matches() {
814 let pending = ask(vec!["OK", "Need changes"], true);
815 assert_eq!(match_text_answer(&pending, " "), None);
816 }
817
818 #[test]
819 fn match_callback_data_requires_the_exact_nonce() {
820 let pending = ask(vec!["Approve", "Deny"], false);
821 assert_eq!(
822 match_callback_data(&pending, "abc12345:0"),
823 Some("Approve".to_string())
824 );
825 assert_eq!(match_callback_data(&pending, "stale-nonce:0"), None);
826 }
827
828 #[test]
829 fn match_callback_data_rejects_out_of_range_index() {
830 let pending = ask(vec!["Approve", "Deny"], false);
831 assert_eq!(match_callback_data(&pending, "abc12345:9"), None);
832 }
833
834 #[test]
835 fn match_callback_data_rejects_malformed_data() {
836 let pending = ask(vec!["Approve", "Deny"], false);
837 assert_eq!(match_callback_data(&pending, "not-a-valid-shape"), None);
838 assert_eq!(match_callback_data(&pending, "abc12345:not-a-number"), None);
839 }
840
841 #[test]
842 fn format_ask_text_numbers_every_option() {
843 let pending = ask(vec!["Approve", "Deny"], false);
844 let text = format_ask_text(&pending);
845 assert!(text.contains("1. Approve"));
846 assert!(text.contains("2. Deny"));
847 assert!(!text.contains("reply with your own answer"));
848 }
849
850 #[test]
851 fn format_ask_text_open_question_mentions_free_text() {
852 let pending = ask(vec!["OK", "Need changes"], true);
853 assert!(format_ask_text(&pending).contains("reply with your own answer"));
854 }
855}