1use serde_json::Value;
24
25pub const ASK_QUESTION_TOOL_NAME: &str = "ask_question";
32
33pub const MAX_QUESTIONS_PER_CALL: usize = 3;
36
37pub const MIN_OPTIONS: usize = 2;
40
41pub const MAX_OPTIONS: usize = 4;
44
45pub const MAX_HEADER_CHARS: usize = 60;
48
49pub const MAX_OPTION_LABEL_CHARS: usize = 48;
52
53pub const MAX_SENTENCE_CHARS: usize = 200;
57
58const ARG_QUESTIONS: &str = "questions";
59const ARG_HEADER: &str = "header";
60const ARG_QUESTION: &str = "question";
61const ARG_OPTIONS: &str = "options";
62const ARG_LABEL: &str = "label";
63const ARG_DESCRIPTION: &str = "description";
64const ARG_RECOMMENDED: &str = "recommended";
65
66#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct QuestionOption {
70 pub label: String,
72 pub description: String,
74 pub recommended: bool,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct QuestionItem {
82 pub header: String,
84 pub question: String,
86 pub options: Vec<QuestionOption>,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct PendingQuestion {
98 pub call_id: String,
102 pub index: u32,
105 pub item: QuestionItem,
107 pub args_json: String,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
124#[error("{0}")]
125pub struct QuestionArgsError(String);
126
127impl QuestionArgsError {
128 fn new(message: impl Into<String>) -> Self {
129 Self(message.into())
130 }
131}
132
133pub fn parse_ask_question_args(args_json: &str) -> Result<Vec<QuestionItem>, QuestionArgsError> {
150 let value: Value = serde_json::from_str(args_json)
151 .map_err(|_| QuestionArgsError::new("That ask_question call did not parse as JSON."))?;
152 let questions = value
153 .get(ARG_QUESTIONS)
154 .and_then(Value::as_array)
155 .ok_or_else(|| QuestionArgsError::new("ask_question needs a \"questions\" array."))?;
156
157 if questions.is_empty() {
158 return Err(QuestionArgsError::new(
159 "ask_question needs at least 1 question, got 0.",
160 ));
161 }
162 if questions.len() > MAX_QUESTIONS_PER_CALL {
163 return Err(QuestionArgsError::new(format!(
164 "ask_question allows at most {MAX_QUESTIONS_PER_CALL} questions per call, got {}.",
165 questions.len()
166 )));
167 }
168
169 questions.iter().map(parse_question_item).collect()
170}
171
172fn parse_question_item(v: &Value) -> Result<QuestionItem, QuestionArgsError> {
174 let header = required_str(v, ARG_HEADER, "header")?;
175 if header.chars().count() > MAX_HEADER_CHARS {
176 return Err(QuestionArgsError::new(format!(
177 "A question's header must be at most {MAX_HEADER_CHARS} characters."
178 )));
179 }
180 let question = required_str(v, ARG_QUESTION, "question")?;
181 if question.chars().count() > MAX_SENTENCE_CHARS {
182 return Err(QuestionArgsError::new(format!(
183 "A question must be at most {MAX_SENTENCE_CHARS} characters."
184 )));
185 }
186
187 let options_v = v
188 .get(ARG_OPTIONS)
189 .and_then(Value::as_array)
190 .ok_or_else(|| QuestionArgsError::new("Each question needs an \"options\" array."))?;
191 if options_v.len() < MIN_OPTIONS || options_v.len() > MAX_OPTIONS {
192 return Err(QuestionArgsError::new(format!(
193 "Each question needs between {MIN_OPTIONS} and {MAX_OPTIONS} options, got {}.",
194 options_v.len()
195 )));
196 }
197
198 let mut options = Vec::with_capacity(options_v.len());
199 let mut recommended_count = 0usize;
200 for o in options_v {
201 let label = required_str(o, ARG_LABEL, "label")?;
202 if label.chars().count() > MAX_OPTION_LABEL_CHARS {
203 return Err(QuestionArgsError::new(format!(
204 "An option's label must be at most {MAX_OPTION_LABEL_CHARS} characters."
205 )));
206 }
207 let description = required_str(o, ARG_DESCRIPTION, "description")?;
208 if description.chars().count() > MAX_SENTENCE_CHARS {
209 return Err(QuestionArgsError::new(format!(
210 "An option's description must be at most {MAX_SENTENCE_CHARS} characters."
211 )));
212 }
213 let recommended = o
214 .get(ARG_RECOMMENDED)
215 .and_then(Value::as_bool)
216 .unwrap_or(false);
217 if recommended {
218 recommended_count += 1;
219 }
220 options.push(QuestionOption {
221 label,
222 description,
223 recommended,
224 });
225 }
226 if recommended_count > 1 {
227 return Err(QuestionArgsError::new(
228 "A question may mark at most one option recommended.",
229 ));
230 }
231
232 Ok(QuestionItem {
233 header,
234 question,
235 options,
236 })
237}
238
239fn required_str(v: &Value, key: &str, human: &str) -> Result<String, QuestionArgsError> {
242 let s = v
243 .get(key)
244 .and_then(Value::as_str)
245 .ok_or_else(|| QuestionArgsError::new(format!("ask_question is missing a {human}.")))?;
246 if s.trim().is_empty() {
247 return Err(QuestionArgsError::new(format!(
248 "ask_question's {human} can't be empty."
249 )));
250 }
251 Ok(s.to_owned())
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum AnswerState {
258 Answered,
260 Declined,
264 AutoResolved,
269}
270
271#[derive(Debug, Clone, thiserror::Error)]
274#[error("unrecognized question-answer state {0:?}")]
275pub struct UnrecognizedAnswerState(String);
276
277impl std::str::FromStr for AnswerState {
285 type Err = UnrecognizedAnswerState;
286
287 fn from_str(s: &str) -> Result<Self, Self::Err> {
288 if s == polyc_crypto::question::ANSWERED_STATE {
289 Ok(Self::Answered)
290 } else if s == polyc_crypto::question::DECLINED_STATE {
291 Ok(Self::Declined)
292 } else if s == polyc_crypto::question::AUTO_RESOLVED_STATE {
293 Ok(Self::AutoResolved)
294 } else {
295 Err(UnrecognizedAnswerState(s.to_owned()))
296 }
297 }
298}
299
300impl From<AnswerState> for String {
303 fn from(state: AnswerState) -> Self {
304 match state {
305 AnswerState::Answered => polyc_crypto::question::ANSWERED_STATE,
306 AnswerState::Declined => polyc_crypto::question::DECLINED_STATE,
307 AnswerState::AutoResolved => polyc_crypto::question::AUTO_RESOLVED_STATE,
308 }
309 .to_owned()
310 }
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
320pub struct VerifiedAnswer {
321 pub call_id: String,
323 pub index: u32,
325 pub state: AnswerState,
327 pub selected_index: Option<u32>,
330 pub selected_label: String,
333 pub answered_by: String,
335}
336
337const DECLINED_NOTE: &str =
341 "The user explicitly declined to choose — use your own judgment and proceed.";
342
343const AUTO_RESOLVED_NOTE: &str = "Nobody answered before the idle window elapsed, so this was \
345 auto-resolved to the recommended option — this is an assumption, not a real answer; flag it \
346 and re-ask later if it turns out to matter.";
347
348#[must_use]
361pub fn question_call_result_json(items: &[QuestionItem], answers: &[VerifiedAnswer]) -> String {
362 let entries: Vec<Value> = items
363 .iter()
364 .enumerate()
365 .map(|(i, item)| {
366 let index = u32::try_from(i).unwrap_or(u32::MAX);
367 let Some(answer) = answers.iter().find(|a| a.index == index) else {
368 return serde_json::json!({ "header": item.header, "state": "unresolved" });
369 };
370 match answer.state {
371 AnswerState::Answered => serde_json::json!({
372 "header": item.header,
373 "state": "answered",
374 "selected_index": answer.selected_index,
375 "selected_label": answer.selected_label,
376 }),
377 AnswerState::Declined => serde_json::json!({
378 "header": item.header,
379 "state": "declined",
380 "note": DECLINED_NOTE,
381 }),
382 AnswerState::AutoResolved => serde_json::json!({
383 "header": item.header,
384 "state": "auto_resolved",
385 "selected_index": answer.selected_index,
386 "selected_label": answer.selected_label,
387 "note": AUTO_RESOLVED_NOTE,
388 }),
389 }
390 })
391 .collect();
392 serde_json::json!({ "answers": entries }).to_string()
393}
394
395const STILL_PENDING_NOTE: &str = "Nobody has answered this yet — it's still open, not a real \
402 answer. Don't re-ask it and don't assume what the answer will be. Handle whatever the user \
403 just said, and only circle back to this question if it still matters once you have.";
404
405#[must_use]
418pub fn question_still_pending_json(items: &[QuestionItem]) -> String {
419 let entries: Vec<Value> = items
420 .iter()
421 .map(|item| {
422 serde_json::json!({
423 "header": item.header,
424 "state": "still_pending",
425 "note": STILL_PENDING_NOTE,
426 })
427 })
428 .collect();
429 serde_json::json!({ "answers": entries }).to_string()
430}
431
432#[cfg(test)]
433mod tests {
434 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
435 use super::*;
436 use std::str::FromStr;
437
438 #[test]
443 fn answer_state_round_trips_through_its_wire_string() {
444 for state in [
445 AnswerState::Answered,
446 AnswerState::Declined,
447 AnswerState::AutoResolved,
448 ] {
449 let wire: String = state.into();
450 assert_eq!(AnswerState::from_str(&wire).unwrap(), state);
451 }
452 }
453
454 #[test]
455 fn answer_state_wire_strings_match_the_crypto_crate_consts() {
456 assert_eq!(
457 String::from(AnswerState::Answered),
458 polyc_crypto::question::ANSWERED_STATE
459 );
460 assert_eq!(
461 String::from(AnswerState::Declined),
462 polyc_crypto::question::DECLINED_STATE
463 );
464 assert_eq!(
465 String::from(AnswerState::AutoResolved),
466 polyc_crypto::question::AUTO_RESOLVED_STATE
467 );
468 }
469
470 #[test]
471 fn answer_state_rejects_an_unrecognized_string() {
472 assert!(AnswerState::from_str("not_a_real_state").is_err());
473 }
474
475 fn valid_call() -> String {
477 serde_json::json!({
478 "questions": [{
479 "header": "Deploy target",
480 "question": "Which environment should this ship to?",
481 "options": [
482 {"label": "Staging", "description": "Deploys to staging only.", "recommended": true},
483 {"label": "Production", "description": "Deploys straight to production."}
484 ]
485 }]
486 })
487 .to_string()
488 }
489
490 #[test]
491 fn parses_a_well_formed_call() {
492 let items = parse_ask_question_args(&valid_call()).expect("valid call parses");
493 assert_eq!(items.len(), 1);
494 let q = &items[0];
495 assert_eq!(q.header, "Deploy target");
496 assert_eq!(q.question, "Which environment should this ship to?");
497 assert_eq!(q.options.len(), 2);
498 assert!(q.options[0].recommended);
499 assert!(!q.options[1].recommended);
500 }
501
502 #[test]
503 fn rejects_garbage_json() {
504 let err = parse_ask_question_args("not json").unwrap_err();
505 assert!(!err.to_string().is_empty());
506 }
507
508 #[test]
514 fn error_display_is_exactly_the_model_facing_sentence() {
515 let err = parse_ask_question_args(r#"{"questions": []}"#).unwrap_err();
516 assert_eq!(
517 err.to_string(),
518 "ask_question needs at least 1 question, got 0."
519 );
520 }
521
522 #[test]
523 fn rejects_zero_questions() {
524 let err = parse_ask_question_args(r#"{"questions": []}"#).unwrap_err();
525 assert!(err.to_string().contains("at least 1 question"), "{err}");
526 }
527
528 #[test]
529 fn rejects_more_than_three_questions() {
530 let one = serde_json::json!({
531 "header": "h", "question": "q?",
532 "options": [
533 {"label": "a", "description": "d"},
534 {"label": "b", "description": "d"}
535 ]
536 });
537 let args = serde_json::json!({ "questions": [one.clone(), one.clone(), one.clone(), one] })
538 .to_string();
539 let err = parse_ask_question_args(&args).unwrap_err();
540 assert!(err.to_string().contains("at most 3 questions"), "{err}");
541 }
542
543 #[test]
544 fn rejects_fewer_than_two_options() {
545 let args = serde_json::json!({
546 "questions": [{
547 "header": "h", "question": "q?",
548 "options": [{"label": "a", "description": "d"}]
549 }]
550 })
551 .to_string();
552 let err = parse_ask_question_args(&args).unwrap_err();
553 assert!(err.to_string().contains("between 2 and 4 options"), "{err}");
554 }
555
556 #[test]
557 fn rejects_more_than_four_options() {
558 let opt = serde_json::json!({"label": "a", "description": "d"});
559 let args = serde_json::json!({
560 "questions": [{
561 "header": "h", "question": "q?",
562 "options": [opt.clone(), opt.clone(), opt.clone(), opt.clone(), opt]
563 }]
564 })
565 .to_string();
566 let err = parse_ask_question_args(&args).unwrap_err();
567 assert!(err.to_string().contains("between 2 and 4 options"), "{err}");
568 }
569
570 #[test]
571 fn rejects_empty_option_label() {
572 let args = serde_json::json!({
573 "questions": [{
574 "header": "h", "question": "q?",
575 "options": [
576 {"label": "", "description": "d"},
577 {"label": "b", "description": "d"}
578 ]
579 }]
580 })
581 .to_string();
582 let err = parse_ask_question_args(&args).unwrap_err();
583 assert!(err.to_string().contains("label"), "{err}");
584 assert!(err.to_string().contains("empty"), "{err}");
585 }
586
587 #[test]
588 fn rejects_over_length_header() {
589 let long_header = "x".repeat(MAX_HEADER_CHARS + 1);
590 let args = serde_json::json!({
591 "questions": [{
592 "header": long_header, "question": "q?",
593 "options": [
594 {"label": "a", "description": "d"},
595 {"label": "b", "description": "d"}
596 ]
597 }]
598 })
599 .to_string();
600 let err = parse_ask_question_args(&args).unwrap_err();
601 assert!(err.to_string().contains("header"), "{err}");
602 }
603
604 #[test]
605 fn rejects_two_recommended_options() {
606 let args = serde_json::json!({
607 "questions": [{
608 "header": "h", "question": "q?",
609 "options": [
610 {"label": "a", "description": "d", "recommended": true},
611 {"label": "b", "description": "d", "recommended": true}
612 ]
613 }]
614 })
615 .to_string();
616 let err = parse_ask_question_args(&args).unwrap_err();
617 assert!(err.to_string().contains("at most one option"), "{err}");
618 }
619
620 #[test]
621 fn rejects_missing_options_field() {
622 let args = serde_json::json!({
623 "questions": [{"header": "h", "question": "q?"}]
624 })
625 .to_string();
626 let err = parse_ask_question_args(&args).unwrap_err();
627 assert!(err.to_string().contains("options"), "{err}");
628 }
629
630 fn two_items() -> Vec<QuestionItem> {
631 vec![
632 QuestionItem {
633 header: "Deploy target".to_owned(),
634 question: "Which environment?".to_owned(),
635 options: vec![
636 QuestionOption {
637 label: "Staging".to_owned(),
638 description: "d1".to_owned(),
639 recommended: false,
640 },
641 QuestionOption {
642 label: "Production".to_owned(),
643 description: "d2".to_owned(),
644 recommended: true,
645 },
646 ],
647 },
648 QuestionItem {
649 header: "Notify team?".to_owned(),
650 question: "Should we notify the team?".to_owned(),
651 options: vec![
652 QuestionOption {
653 label: "Yes".to_owned(),
654 description: "d3".to_owned(),
655 recommended: false,
656 },
657 QuestionOption {
658 label: "No".to_owned(),
659 description: "d4".to_owned(),
660 recommended: false,
661 },
662 ],
663 },
664 ]
665 }
666
667 #[test]
671 fn answered_declined_and_auto_resolved_produce_distinct_results() {
672 let items = vec![two_items()[0].clone()];
673 let answered = question_call_result_json(
674 &items,
675 &[VerifiedAnswer {
676 call_id: "call-1".to_owned(),
677 index: 0,
678 state: AnswerState::Answered,
679 selected_index: Some(1),
680 selected_label: "Production".to_owned(),
681 answered_by: "slack:T1:U9".to_owned(),
682 }],
683 );
684 let declined = question_call_result_json(
685 &items,
686 &[VerifiedAnswer {
687 call_id: "call-1".to_owned(),
688 index: 0,
689 state: AnswerState::Declined,
690 selected_index: None,
691 selected_label: String::new(),
692 answered_by: "slack:T1:U9".to_owned(),
693 }],
694 );
695 let auto_resolved = question_call_result_json(
696 &items,
697 &[VerifiedAnswer {
698 call_id: "call-1".to_owned(),
699 index: 0,
700 state: AnswerState::AutoResolved,
701 selected_index: Some(1),
702 selected_label: "Production".to_owned(),
703 answered_by: String::new(),
704 }],
705 );
706
707 assert_ne!(answered, declined);
708 assert_ne!(answered, auto_resolved);
709 assert_ne!(declined, auto_resolved);
710
711 let a: serde_json::Value = serde_json::from_str(&answered).unwrap();
712 assert_eq!(a["answers"][0]["state"], "answered");
713 assert_eq!(a["answers"][0]["selected_label"], "Production");
714
715 let d: serde_json::Value = serde_json::from_str(&declined).unwrap();
716 assert_eq!(d["answers"][0]["state"], "declined");
717 assert!(d["answers"][0].get("selected_index").is_none());
718
719 let r: serde_json::Value = serde_json::from_str(&auto_resolved).unwrap();
720 assert_eq!(r["answers"][0]["state"], "auto_resolved");
721 assert!(
722 r["answers"][0]["note"]
723 .as_str()
724 .unwrap()
725 .contains("assumption"),
726 "an auto-resolved answer must flag itself as an assumption, not a real answer"
727 );
728 }
729
730 #[test]
734 fn still_pending_is_distinct_from_every_real_answer_state() {
735 let items = vec![two_items()[0].clone()];
736 let still_pending = question_still_pending_json(&items);
737 let answered = question_call_result_json(
738 &items,
739 &[VerifiedAnswer {
740 call_id: "call-1".to_owned(),
741 index: 0,
742 state: AnswerState::Answered,
743 selected_index: Some(1),
744 selected_label: "Production".to_owned(),
745 answered_by: "slack:T1:U9".to_owned(),
746 }],
747 );
748 let declined = question_call_result_json(
749 &items,
750 &[VerifiedAnswer {
751 call_id: "call-1".to_owned(),
752 index: 0,
753 state: AnswerState::Declined,
754 selected_index: None,
755 selected_label: String::new(),
756 answered_by: "slack:T1:U9".to_owned(),
757 }],
758 );
759 let auto_resolved = question_call_result_json(
760 &items,
761 &[VerifiedAnswer {
762 call_id: "call-1".to_owned(),
763 index: 0,
764 state: AnswerState::AutoResolved,
765 selected_index: Some(1),
766 selected_label: "Production".to_owned(),
767 answered_by: String::new(),
768 }],
769 );
770
771 assert_ne!(still_pending, answered);
772 assert_ne!(still_pending, declined);
773 assert_ne!(still_pending, auto_resolved);
774
775 let v: serde_json::Value = serde_json::from_str(&still_pending).unwrap();
776 assert_eq!(v["answers"][0]["state"], "still_pending");
777 assert!(v["answers"][0].get("selected_index").is_none());
778 assert!(
779 v["answers"][0]["note"]
780 .as_str()
781 .unwrap()
782 .contains("still open"),
783 "the still-pending note must tell the model this is not a real answer"
784 );
785 }
786
787 #[test]
790 fn multi_question_call_renders_one_entry_per_question() {
791 let items = two_items();
792 let json = question_call_result_json(
793 &items,
794 &[
795 VerifiedAnswer {
796 call_id: "call-1".to_owned(),
797 index: 0,
798 state: AnswerState::Answered,
799 selected_index: Some(0),
800 selected_label: "Staging".to_owned(),
801 answered_by: "slack:T1:U9".to_owned(),
802 },
803 VerifiedAnswer {
804 call_id: "call-1".to_owned(),
805 index: 1,
806 state: AnswerState::Declined,
807 selected_index: None,
808 selected_label: String::new(),
809 answered_by: "slack:T1:U9".to_owned(),
810 },
811 ],
812 );
813 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
814 assert_eq!(v["answers"].as_array().unwrap().len(), 2);
815 assert_eq!(v["answers"][0]["header"], "Deploy target");
816 assert_eq!(v["answers"][0]["state"], "answered");
817 assert_eq!(v["answers"][1]["header"], "Notify team?");
818 assert_eq!(v["answers"][1]["state"], "declined");
819 }
820}