1use serde::{Deserialize, Serialize};
22use std::sync::Arc;
23use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
24use std::time::Duration;
25use tokio::sync::oneshot;
26
27use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
28use async_trait::async_trait;
29
30#[derive(Clone)]
33pub struct AskBridge {
34 inner: Arc<parking_lot::Mutex<Option<PendingAsk>>>,
35 ui_attached: Arc<AtomicBool>,
39 session_id: Arc<parking_lot::Mutex<Option<String>>>,
46 timeout: Option<Duration>,
49 mode: Arc<AtomicU8>,
54}
55
56impl AskBridge {
57 pub fn new() -> Self {
59 Self {
60 inner: Arc::new(parking_lot::Mutex::new(None)),
61 ui_attached: Arc::new(AtomicBool::new(false)),
62 session_id: Arc::new(parking_lot::Mutex::new(None)),
63 timeout: None,
64 mode: Arc::new(AtomicU8::new(crate::config::Mode::Default.as_u8())),
65 }
66 }
67
68 pub fn with_timeout(timeout: Option<Duration>) -> Self {
70 Self {
71 timeout,
72 ..Self::new()
73 }
74 }
75
76 pub fn attach_with_session(&self, session_id: impl Into<String>) {
83 let id = session_id.into();
84 debug_assert!(
85 !id.is_empty(),
86 "AskBridge::attach_with_session called with empty session_id"
87 );
88 *self.session_id.lock() = Some(id);
89 self.ui_attached.store(true, Ordering::SeqCst);
90 }
91
92 pub fn is_ui_attached(&self) -> bool {
94 self.ui_attached.load(Ordering::SeqCst)
95 }
96
97 #[cfg(any(test, debug_assertions))]
101 pub fn attach(&self) {
102 self.ui_attached.store(true, Ordering::SeqCst);
103 }
104
105 pub fn session_id(&self) -> Option<String> {
107 self.session_id.lock().clone()
108 }
109 pub fn timeout(&self) -> Option<Duration> {
111 self.timeout
112 }
113
114 pub fn mode(&self) -> crate::config::Mode {
116 crate::config::Mode::load(&self.mode)
117 }
118
119 pub fn set_mode(&self, mode: crate::config::Mode) {
121 self.mode.store(mode.as_u8(), Ordering::SeqCst);
122 }
123
124 pub fn mode_handle(&self) -> Arc<AtomicU8> {
128 Arc::clone(&self.mode)
129 }
130
131 pub fn set(&self, pending: PendingAsk) -> bool {
135 let mut lock = self.inner.lock();
136 if lock.is_some() {
137 return false;
138 }
139 *lock = Some(pending);
140 true
141 }
142
143 pub fn try_take(&self) -> Option<PendingAsk> {
146 self.inner.lock().take()
147 }
148
149 pub fn has_pending(&self) -> bool {
151 self.inner.lock().is_some()
152 }
153}
154
155impl Default for AskBridge {
156 fn default() -> Self {
157 Self::new()
158 }
159}
160
161pub struct PendingAsk {
165 pub questions: Vec<Question>,
167 pub responder: oneshot::Sender<AskResponse>,
170 pub timeout: Option<Duration>,
172 pub session_id: Option<String>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct Question {
181 pub id: String,
183 #[serde(default)]
186 pub label: String,
187 pub prompt: String,
189 #[serde(default)]
191 pub options: Vec<QuestionOption>,
192 #[serde(default = "default_true")]
196 pub allow_other: bool,
197 #[serde(default)]
199 pub multi_select: bool,
200 #[serde(default)]
204 pub recommended: Option<usize>,
205}
206
207fn default_true() -> bool {
208 true
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct QuestionOption {
214 pub value: String,
216 pub label: String,
218 pub description: Option<String>,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct AskResponse {
225 pub answers: Vec<Answer>,
227 pub cancelled: bool,
229 #[serde(default)]
231 pub timed_out: bool,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct Answer {
237 pub id: String,
239 pub value: String,
241 pub label: String,
243 pub was_custom: bool,
245 pub index: Option<usize>,
247}
248
249pub struct AskTool {
253 bridge: Arc<AskBridge>,
254}
255
256impl AskTool {
257 pub fn new(bridge: Arc<AskBridge>) -> Self {
259 Self { bridge }
260 }
261}
262
263impl Clone for AskTool {
266 fn clone(&self) -> Self {
267 Self {
268 bridge: self.bridge.clone(),
269 }
270 }
271}
272
273#[async_trait]
274impl AgentTool for AskTool {
275 fn name(&self) -> &str {
276 "ask"
277 }
278
279 fn label(&self) -> &str {
280 "Ask"
281 }
282
283 fn description(&self) -> &str {
284 "Ask the user a clarifying question when choices have materially \
285 different tradeoffs the user must decide. Default to action — pick \
286 the conservative/standard option and proceed when a reasonable \
287 default exists; only ask when the user must weigh the tradeoff. Do \
288 NOT include an 'Other' option — the UI appends 'Other (type your \
289 own)' automatically. Use 'recommended' (0-indexed) to mark the \
290 default; a '(Recommended)' suffix is added automatically. Set \
291 'multiSelect' true to allow multiple selections. Provide 2-5 \
292 concise options with short labels; put explanatory tradeoffs in \
293 'description'. Batch related questions in one call via 'questions'."
294 }
295
296 fn parameters_schema(&self) -> serde_json::Value {
297 serde_json::json!({
298 "type": "object",
299 "properties": {
300 "questions": {
301 "type": "array",
302 "description": "Questions to ask the user",
303 "items": {
304 "type": "object",
305 "properties": {
306 "id": {
307 "type": "string",
308 "description": "Unique identifier for this question"
309 },
310 "label": {
311 "type": "string",
312 "description": "Short contextual label (defaults to the id)"
313 },
314 "prompt": {
315 "type": "string",
316 "description": "The full question text to display"
317 },
318 "options": {
319 "type": "array",
320 "description": "Available options (2-5). Do NOT include 'Other' — the UI adds it automatically.",
321 "default": [],
322 "items": {
323 "type": "object",
324 "properties": {
325 "value": {
326 "type": "string",
327 "description": "The value returned when selected"
328 },
329 "label": {
330 "type": "string",
331 "description": "Short display label for the option"
332 },
333 "description": {
334 "type": "string",
335 "description": "Optional explanatory tradeoff shown below the label"
336 }
337 },
338 "required": ["value", "label"]
339 }
340 },
341 "allowOther": {
342 "type": "boolean",
343 "description": "Show 'Other (type your own)' (default: true)",
344 "default": true
345 },
346 "multiSelect": {
347 "type": "boolean",
348 "description": "Allow multiple selections (default: false)",
349 "default": false
350 },
351 "recommended": {
352 "type": "number",
353 "description": "Recommended option index (0-based). Marks the default and is used for timeout auto-selection.",
354 "minimum": 0
355 }
356 },
357 "required": ["id", "prompt"]
358 }
359 },
360 },
361 "required": ["questions"]
362 })
363 }
364
365 fn intent(&self) -> Option<&str> {
366 Some("Ask the user clarifying questions")
367 }
368
369 async fn execute(
370 &self,
371 _tool_call_id: &str,
372 params: serde_json::Value,
373 signal: Option<oneshot::Receiver<()>>,
374 _ctx: &ToolContext,
375 ) -> Result<AgentToolResult, ToolError> {
376 if self.bridge.mode().is_auto() {
381 return Ok(AgentToolResult::success(
382 "Auto mode is active — the user is unavailable. Do not ask the \
383 user; make a reasonable autonomous decision and proceed to \
384 completion. Do not call the ask tool again.",
385 ));
386 }
387
388 if !self.bridge.is_ui_attached() {
390 return Ok(AgentToolResult::error(
391 "Ask requires interactive TUI mode. \
392 Not available in --print or RPC mode.",
393 ));
394 }
395
396 let session_id = self.bridge.session_id();
403 debug_assert!(
404 session_id.as_deref().is_some_and(|s| !s.is_empty()),
405 "AskBridge was attached without a non-empty session_id; refusing to run"
406 );
407
408 let questions = parse_questions(¶ms)?;
410 let timeout = self.bridge.timeout();
411
412 let (tx, rx) = oneshot::channel();
414
415 if !self.bridge.set(PendingAsk {
417 questions,
418 responder: tx,
419 timeout,
420 session_id,
421 }) {
422 return Ok(AgentToolResult::error("Another ask is already pending"));
423 }
424
425 select_with_abort(rx, signal, &self.bridge).await
427 }
428}
429
430async fn select_with_abort(
432 rx: oneshot::Receiver<AskResponse>,
433 signal: Option<oneshot::Receiver<()>>,
434 bridge: &AskBridge,
435) -> Result<AgentToolResult, ToolError> {
436 let abort = async {
438 if let Some(sig) = signal {
439 let _ = sig.await;
440 } else {
441 std::future::pending::<()>().await;
442 }
443 };
444
445 tokio::select! {
446 response = rx => {
447 match response {
448 Ok(resp) => {
449 if resp.cancelled {
450 Ok(AgentToolResult::success("User cancelled the question"))
451 } else {
452 Ok(AgentToolResult::success(format_answers(
453 &resp.answers,
454 resp.timed_out,
455 )))
456 }
457 }
458 Err(_) => {
459 Ok(AgentToolResult::success("Question dismissed"))
461 }
462 }
463 }
464 () = abort => {
465 bridge.try_take();
467 Ok(AgentToolResult::success("Question cancelled by user interrupt"))
468 }
469 }
470}
471
472fn parse_questions(params: &serde_json::Value) -> Result<Vec<Question>, ToolError> {
474 let questions = params
475 .get("questions")
476 .and_then(|v| v.as_array())
477 .cloned()
478 .ok_or_else(|| "Missing or invalid 'questions' field".to_string())?;
479
480 let questions: Vec<Question> = questions
481 .into_iter()
482 .map(|v| serde_json::from_value(v).map_err(|e| e.to_string()))
483 .collect::<Result<Vec<_>, _>>()
484 .map_err(|e| format!("Invalid question: {}", e))?;
485
486 if questions.is_empty() {
487 return Err("At least one question is required".to_string());
488 }
489
490 let questions: Vec<Question> = questions
492 .into_iter()
493 .map(|mut q| {
494 if q.label.is_empty() {
495 q.label = q.id.clone();
496 }
497 q
498 })
499 .collect();
500
501 let mut ids = std::collections::HashSet::new();
503 for q in &questions {
504 if !ids.insert(&q.id) {
505 return Err(format!("Duplicate question id: {}", q.id));
506 }
507 }
508
509 Ok(questions)
510}
511
512pub fn format_answers(answers: &[Answer], timed_out: bool) -> String {
523 let suffix = if timed_out {
524 " (auto-selected after timeout)"
525 } else {
526 ""
527 };
528 answers
529 .iter()
530 .map(|a| {
531 let base = if a.was_custom {
532 format!("{}: \"{}\"", a.id, a.label)
533 } else if a.value.contains(',') {
534 let labels: Vec<&str> = a.label.split(", ").collect();
536 format!("{}: [{}]", a.id, labels.join(", "))
537 } else {
538 format!("{}: {}", a.id, a.label)
539 };
540 format!("{base}{suffix}")
541 })
542 .collect::<Vec<_>>()
543 .join("\n")
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549
550 #[tokio::test]
551 async fn auto_mode_short_circuits_ask() {
552 let bridge = std::sync::Arc::new(AskBridge::new());
555 bridge.attach_with_session("test-session");
556 bridge.set_mode(crate::config::Mode::Auto);
557 let tool = AskTool::new(bridge.clone());
558 let ctx = ToolContext::default();
559 let params = serde_json::json!({
560 "questions": [{
561 "id": "x",
562 "prompt": "pick",
563 "options": [
564 { "value": "a", "label": "A" },
565 { "value": "b", "label": "B" }
566 ]
567 }]
568 });
569 let result = tool.execute("call-1", params, None, &ctx).await.unwrap();
570 assert!(
571 result.success,
572 "Auto-mode ask should succeed with steering text"
573 );
574 assert!(
575 result.output.contains("Auto mode"),
576 "steering text should mention Auto mode (got: {})",
577 result.output,
578 );
579 assert!(
580 result.output.contains("autonomous"),
581 "should tell model to decide autonomously",
582 );
583 assert!(!bridge.has_pending());
584 }
585
586 #[tokio::test]
587 async fn default_mode_passes_headless_guard_only() {
588 let bridge = std::sync::Arc::new(AskBridge::new());
592 let tool = AskTool::new(bridge);
594 let ctx = ToolContext::default();
595 let params = serde_json::json!({
596 "questions": [{
597 "id": "x",
598 "prompt": "pick",
599 "options": [{ "value": "a", "label": "A" }]
600 }]
601 });
602 let result = tool.execute("call-2", params, None, &ctx).await.unwrap();
603 assert!(!result.success, "headless default-mode ask should error");
604 }
605 #[test]
606 fn test_parse_questions_valid() {
607 let json = serde_json::json!({
608 "questions": [
609 {
610 "id": "lang",
611 "prompt": "Pick a language",
612 "options": [
613 { "value": "rust", "label": "Rust" },
614 { "value": "ts", "label": "TypeScript" }
615 ]
616 }
617 ]
618 });
619 let questions = parse_questions(&json).unwrap();
620 assert_eq!(questions.len(), 1);
621 assert_eq!(questions[0].id, "lang");
622 assert_eq!(questions[0].label, "lang"); assert_eq!(questions[0].options.len(), 2);
624 assert!(questions[0].allow_other); assert!(!questions[0].multi_select); }
627
628 #[test]
629 fn test_parse_questions_with_label() {
630 let json = serde_json::json!({
631 "questions": [
632 {
633 "id": "lang",
634 "label": "Language",
635 "prompt": "Pick a language"
636 }
637 ]
638 });
639 let questions = parse_questions(&json).unwrap();
640 assert_eq!(questions[0].label, "Language");
641 }
642
643 #[test]
644 fn test_parse_questions_empty_options() {
645 let json = serde_json::json!({
647 "questions": [
648 {
649 "id": "name",
650 "prompt": "What's your project name?",
651 "allowOther": true
652 }
653 ]
654 });
655 let questions = parse_questions(&json).unwrap();
656 assert_eq!(questions[0].options.len(), 0);
657 assert!(questions[0].allow_other);
658 }
659
660 #[test]
661 fn test_parse_questions_missing_questions() {
662 let json = serde_json::json!({});
663 let err = parse_questions(&json).unwrap_err();
664 assert!(err.contains("questions"));
665 }
666
667 #[test]
668 fn test_parse_questions_empty_array() {
669 let json = serde_json::json!({ "questions": [] });
670 let err = parse_questions(&json).unwrap_err();
671 assert!(err.contains("one question"));
672 }
673
674 #[test]
675 fn test_parse_questions_duplicate_ids() {
676 let json = serde_json::json!({
677 "questions": [
678 { "id": "a", "prompt": "Q1" },
679 { "id": "a", "prompt": "Q2" }
680 ]
681 });
682 let err = parse_questions(&json).unwrap_err();
683 assert!(err.contains("Duplicate"));
684 }
685
686 #[test]
687 fn test_format_answers_single() {
688 let answers = vec![Answer {
689 id: "lang".into(),
690 value: "rust".into(),
691 label: "Rust".into(),
692 was_custom: false,
693 index: Some(1),
694 }];
695 let text = format_answers(&answers, false);
696 assert_eq!(text, "lang: Rust");
697 }
698
699 #[test]
700 fn test_format_answers_custom() {
701 let answers = vec![Answer {
702 id: "name".into(),
703 value: "myproj".into(),
704 label: "myproj".into(),
705 was_custom: true,
706 index: None,
707 }];
708 let text = format_answers(&answers, false);
709 assert_eq!(text, "name: \"myproj\"");
710 }
711
712 #[test]
713 fn test_format_answers_multi() {
714 let answers = vec![Answer {
715 id: "lang".into(),
716 value: "rust, go".into(), label: "Rust, Go".into(),
718 was_custom: false,
719 index: None,
720 }];
721 let text = format_answers(&answers, false);
722 assert_eq!(text, "lang: [Rust, Go]");
723 }
724
725 #[test]
726 fn test_format_answers_timed_out() {
727 let answers = vec![Answer {
728 id: "auth".into(),
729 value: "oauth".into(),
730 label: "OAuth2".into(),
731 was_custom: false,
732 index: Some(2),
733 }];
734 let text = format_answers(&answers, true);
735 assert_eq!(text, "auth: OAuth2 (auto-selected after timeout)");
736 }
737
738 #[test]
739 fn test_bridge_set_take() {
740 let bridge = AskBridge::new();
741 assert!(!bridge.has_pending());
742
743 let (tx, _rx) = oneshot::channel();
744 let pending = PendingAsk {
745 questions: vec![],
746 responder: tx,
747 timeout: None,
748 session_id: None,
749 };
750 assert!(bridge.set(pending));
751 assert!(bridge.has_pending());
752
753 let taken = bridge.try_take();
754 assert!(taken.is_some());
755 assert!(!bridge.has_pending());
756
757 assert!(bridge.try_take().is_none());
759 }
760
761 #[test]
762 fn test_bridge_set_idempotent() {
763 let bridge = AskBridge::new();
764 let (tx1, _rx1) = oneshot::channel();
765 let (tx2, _rx2) = oneshot::channel();
766
767 bridge.set(PendingAsk {
768 questions: vec![],
769 responder: tx1,
770 timeout: None,
771 session_id: None,
772 });
773 assert!(!bridge.set(PendingAsk {
774 questions: vec![],
775 responder: tx2,
776 timeout: None,
777 session_id: None,
778 }));
779 }
780
781 #[test]
782 fn test_ui_attached_flag() {
783 let bridge = AskBridge::new();
784 assert!(!bridge.is_ui_attached());
785 bridge.attach();
786 assert!(bridge.is_ui_attached());
787 }
788
789 #[test]
790 fn test_bridge_with_timeout() {
791 let bridge = AskBridge::with_timeout(Some(Duration::from_secs(30)));
792 assert_eq!(bridge.timeout(), Some(Duration::from_secs(30)));
793 assert!(!bridge.is_ui_attached()); let no_timeout = AskBridge::new();
796 assert_eq!(no_timeout.timeout(), None);
797 }
798
799 #[test]
800 fn test_question_deserializes_without_recommended() {
801 let json = serde_json::json!({
803 "id": "test",
804 "prompt": "Test question?",
805 "options": [{"value": "a", "label": "A"}]
806 });
807 let q: Question = serde_json::from_value(json).unwrap();
808 assert_eq!(q.recommended, None);
809 }
810
811 #[test]
812 fn test_question_deserializes_with_recommended() {
813 let json = serde_json::json!({
814 "id": "test",
815 "prompt": "Test question?",
816 "options": [{"value": "a", "label": "A"}, {"value": "b", "label": "B"}],
817 "recommended": 1
818 });
819 let q: Question = serde_json::from_value(json).unwrap();
820 assert_eq!(q.recommended, Some(1));
821 }
822
823 #[test]
824 fn test_tool_name_is_ask() {
825 let bridge = Arc::new(AskBridge::new());
826 let tool = AskTool::new(bridge);
827 assert_eq!(tool.name(), "ask");
828 assert_eq!(tool.label(), "Ask");
829 }
830
831 #[test]
832 fn test_attach_with_session_stores_id() {
833 let bridge = AskBridge::new();
834 assert!(!bridge.is_ui_attached());
835 assert_eq!(bridge.session_id(), None);
836 bridge.attach_with_session("tui");
837 assert!(bridge.is_ui_attached());
838 assert_eq!(bridge.session_id().as_deref(), Some("tui"));
839 }
840
841 #[test]
842 fn test_format_answers_multi_with_comma_label() {
843 let answers = vec![Answer {
847 id: "tags".into(),
848 value: "a,b".into(),
849 label: "A, B".into(),
850 was_custom: false,
851 index: None,
852 }];
853 let text = format_answers(&answers, false);
854 assert_eq!(text, "tags: [A, B]");
855 }
856
857 #[test]
858 fn test_format_answers_cancelled_marker() {
859 let answers = vec![Answer {
860 id: "q1".into(),
861 value: String::new(),
862 label: String::new(),
863 was_custom: false,
864 index: None,
865 }];
866 let text = format_answers(&answers, false);
870 assert_eq!(text, "q1: ");
871 }
872}