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