use serde_json::Value;
pub const ASK_QUESTION_TOOL_NAME: &str = "ask_question";
pub const MAX_QUESTIONS_PER_CALL: usize = 3;
pub const MIN_OPTIONS: usize = 2;
pub const MAX_OPTIONS: usize = 4;
pub const MAX_HEADER_CHARS: usize = 60;
pub const MAX_OPTION_LABEL_CHARS: usize = 48;
pub const MAX_SENTENCE_CHARS: usize = 200;
const ARG_QUESTIONS: &str = "questions";
const ARG_HEADER: &str = "header";
const ARG_QUESTION: &str = "question";
const ARG_OPTIONS: &str = "options";
const ARG_LABEL: &str = "label";
const ARG_DESCRIPTION: &str = "description";
const ARG_RECOMMENDED: &str = "recommended";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QuestionOption {
pub label: String,
pub description: String,
pub recommended: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QuestionItem {
pub header: String,
pub question: String,
pub options: Vec<QuestionOption>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingQuestion {
pub call_id: String,
pub index: u32,
pub item: QuestionItem,
pub args_json: String,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{0}")]
pub struct QuestionArgsError(String);
impl QuestionArgsError {
fn new(message: impl Into<String>) -> Self {
Self(message.into())
}
}
pub fn parse_ask_question_args(args_json: &str) -> Result<Vec<QuestionItem>, QuestionArgsError> {
let value: Value = serde_json::from_str(args_json)
.map_err(|_| QuestionArgsError::new("That ask_question call did not parse as JSON."))?;
let questions = value
.get(ARG_QUESTIONS)
.and_then(Value::as_array)
.ok_or_else(|| QuestionArgsError::new("ask_question needs a \"questions\" array."))?;
if questions.is_empty() {
return Err(QuestionArgsError::new(
"ask_question needs at least 1 question, got 0.",
));
}
if questions.len() > MAX_QUESTIONS_PER_CALL {
return Err(QuestionArgsError::new(format!(
"ask_question allows at most {MAX_QUESTIONS_PER_CALL} questions per call, got {}.",
questions.len()
)));
}
questions.iter().map(parse_question_item).collect()
}
fn parse_question_item(v: &Value) -> Result<QuestionItem, QuestionArgsError> {
let header = required_str(v, ARG_HEADER, "header")?;
if header.chars().count() > MAX_HEADER_CHARS {
return Err(QuestionArgsError::new(format!(
"A question's header must be at most {MAX_HEADER_CHARS} characters."
)));
}
let question = required_str(v, ARG_QUESTION, "question")?;
if question.chars().count() > MAX_SENTENCE_CHARS {
return Err(QuestionArgsError::new(format!(
"A question must be at most {MAX_SENTENCE_CHARS} characters."
)));
}
let options_v = v
.get(ARG_OPTIONS)
.and_then(Value::as_array)
.ok_or_else(|| QuestionArgsError::new("Each question needs an \"options\" array."))?;
if options_v.len() < MIN_OPTIONS || options_v.len() > MAX_OPTIONS {
return Err(QuestionArgsError::new(format!(
"Each question needs between {MIN_OPTIONS} and {MAX_OPTIONS} options, got {}.",
options_v.len()
)));
}
let mut options = Vec::with_capacity(options_v.len());
let mut recommended_count = 0usize;
for o in options_v {
let label = required_str(o, ARG_LABEL, "label")?;
if label.chars().count() > MAX_OPTION_LABEL_CHARS {
return Err(QuestionArgsError::new(format!(
"An option's label must be at most {MAX_OPTION_LABEL_CHARS} characters."
)));
}
let description = required_str(o, ARG_DESCRIPTION, "description")?;
if description.chars().count() > MAX_SENTENCE_CHARS {
return Err(QuestionArgsError::new(format!(
"An option's description must be at most {MAX_SENTENCE_CHARS} characters."
)));
}
let recommended = o
.get(ARG_RECOMMENDED)
.and_then(Value::as_bool)
.unwrap_or(false);
if recommended {
recommended_count += 1;
}
options.push(QuestionOption {
label,
description,
recommended,
});
}
if recommended_count > 1 {
return Err(QuestionArgsError::new(
"A question may mark at most one option recommended.",
));
}
Ok(QuestionItem {
header,
question,
options,
})
}
fn required_str(v: &Value, key: &str, human: &str) -> Result<String, QuestionArgsError> {
let s = v
.get(key)
.and_then(Value::as_str)
.ok_or_else(|| QuestionArgsError::new(format!("ask_question is missing a {human}.")))?;
if s.trim().is_empty() {
return Err(QuestionArgsError::new(format!(
"ask_question's {human} can't be empty."
)));
}
Ok(s.to_owned())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnswerState {
Answered,
Declined,
AutoResolved,
}
#[derive(Debug, Clone, thiserror::Error)]
#[error("unrecognized question-answer state {0:?}")]
pub struct UnrecognizedAnswerState(String);
impl std::str::FromStr for AnswerState {
type Err = UnrecognizedAnswerState;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s == polyc_crypto::question::ANSWERED_STATE {
Ok(Self::Answered)
} else if s == polyc_crypto::question::DECLINED_STATE {
Ok(Self::Declined)
} else if s == polyc_crypto::question::AUTO_RESOLVED_STATE {
Ok(Self::AutoResolved)
} else {
Err(UnrecognizedAnswerState(s.to_owned()))
}
}
}
impl From<AnswerState> for String {
fn from(state: AnswerState) -> Self {
match state {
AnswerState::Answered => polyc_crypto::question::ANSWERED_STATE,
AnswerState::Declined => polyc_crypto::question::DECLINED_STATE,
AnswerState::AutoResolved => polyc_crypto::question::AUTO_RESOLVED_STATE,
}
.to_owned()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedAnswer {
pub call_id: String,
pub index: u32,
pub state: AnswerState,
pub selected_index: Option<u32>,
pub selected_label: String,
pub answered_by: String,
}
const DECLINED_NOTE: &str =
"The user explicitly declined to choose — use your own judgment and proceed.";
const AUTO_RESOLVED_NOTE: &str = "Nobody answered before the idle window elapsed, so this was \
auto-resolved to the recommended option — this is an assumption, not a real answer; flag it \
and re-ask later if it turns out to matter.";
#[must_use]
pub fn question_call_result_json(items: &[QuestionItem], answers: &[VerifiedAnswer]) -> String {
let entries: Vec<Value> = items
.iter()
.enumerate()
.map(|(i, item)| {
let index = u32::try_from(i).unwrap_or(u32::MAX);
let Some(answer) = answers.iter().find(|a| a.index == index) else {
return serde_json::json!({ "header": item.header, "state": "unresolved" });
};
match answer.state {
AnswerState::Answered => serde_json::json!({
"header": item.header,
"state": "answered",
"selected_index": answer.selected_index,
"selected_label": answer.selected_label,
}),
AnswerState::Declined => serde_json::json!({
"header": item.header,
"state": "declined",
"note": DECLINED_NOTE,
}),
AnswerState::AutoResolved => serde_json::json!({
"header": item.header,
"state": "auto_resolved",
"selected_index": answer.selected_index,
"selected_label": answer.selected_label,
"note": AUTO_RESOLVED_NOTE,
}),
}
})
.collect();
serde_json::json!({ "answers": entries }).to_string()
}
const STILL_PENDING_NOTE: &str = "Nobody has answered this yet — it's still open, not a real \
answer. Don't re-ask it and don't assume what the answer will be. Handle whatever the user \
just said, and only circle back to this question if it still matters once you have.";
#[must_use]
pub fn question_still_pending_json(items: &[QuestionItem]) -> String {
let entries: Vec<Value> = items
.iter()
.map(|item| {
serde_json::json!({
"header": item.header,
"state": "still_pending",
"note": STILL_PENDING_NOTE,
})
})
.collect();
serde_json::json!({ "answers": entries }).to_string()
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
use std::str::FromStr;
#[test]
fn answer_state_round_trips_through_its_wire_string() {
for state in [
AnswerState::Answered,
AnswerState::Declined,
AnswerState::AutoResolved,
] {
let wire: String = state.into();
assert_eq!(AnswerState::from_str(&wire).unwrap(), state);
}
}
#[test]
fn answer_state_wire_strings_match_the_crypto_crate_consts() {
assert_eq!(
String::from(AnswerState::Answered),
polyc_crypto::question::ANSWERED_STATE
);
assert_eq!(
String::from(AnswerState::Declined),
polyc_crypto::question::DECLINED_STATE
);
assert_eq!(
String::from(AnswerState::AutoResolved),
polyc_crypto::question::AUTO_RESOLVED_STATE
);
}
#[test]
fn answer_state_rejects_an_unrecognized_string() {
assert!(AnswerState::from_str("not_a_real_state").is_err());
}
fn valid_call() -> String {
serde_json::json!({
"questions": [{
"header": "Deploy target",
"question": "Which environment should this ship to?",
"options": [
{"label": "Staging", "description": "Deploys to staging only.", "recommended": true},
{"label": "Production", "description": "Deploys straight to production."}
]
}]
})
.to_string()
}
#[test]
fn parses_a_well_formed_call() {
let items = parse_ask_question_args(&valid_call()).expect("valid call parses");
assert_eq!(items.len(), 1);
let q = &items[0];
assert_eq!(q.header, "Deploy target");
assert_eq!(q.question, "Which environment should this ship to?");
assert_eq!(q.options.len(), 2);
assert!(q.options[0].recommended);
assert!(!q.options[1].recommended);
}
#[test]
fn rejects_garbage_json() {
let err = parse_ask_question_args("not json").unwrap_err();
assert!(!err.to_string().is_empty());
}
#[test]
fn error_display_is_exactly_the_model_facing_sentence() {
let err = parse_ask_question_args(r#"{"questions": []}"#).unwrap_err();
assert_eq!(
err.to_string(),
"ask_question needs at least 1 question, got 0."
);
}
#[test]
fn rejects_zero_questions() {
let err = parse_ask_question_args(r#"{"questions": []}"#).unwrap_err();
assert!(err.to_string().contains("at least 1 question"), "{err}");
}
#[test]
fn rejects_more_than_three_questions() {
let one = serde_json::json!({
"header": "h", "question": "q?",
"options": [
{"label": "a", "description": "d"},
{"label": "b", "description": "d"}
]
});
let args = serde_json::json!({ "questions": [one.clone(), one.clone(), one.clone(), one] })
.to_string();
let err = parse_ask_question_args(&args).unwrap_err();
assert!(err.to_string().contains("at most 3 questions"), "{err}");
}
#[test]
fn rejects_fewer_than_two_options() {
let args = serde_json::json!({
"questions": [{
"header": "h", "question": "q?",
"options": [{"label": "a", "description": "d"}]
}]
})
.to_string();
let err = parse_ask_question_args(&args).unwrap_err();
assert!(err.to_string().contains("between 2 and 4 options"), "{err}");
}
#[test]
fn rejects_more_than_four_options() {
let opt = serde_json::json!({"label": "a", "description": "d"});
let args = serde_json::json!({
"questions": [{
"header": "h", "question": "q?",
"options": [opt.clone(), opt.clone(), opt.clone(), opt.clone(), opt]
}]
})
.to_string();
let err = parse_ask_question_args(&args).unwrap_err();
assert!(err.to_string().contains("between 2 and 4 options"), "{err}");
}
#[test]
fn rejects_empty_option_label() {
let args = serde_json::json!({
"questions": [{
"header": "h", "question": "q?",
"options": [
{"label": "", "description": "d"},
{"label": "b", "description": "d"}
]
}]
})
.to_string();
let err = parse_ask_question_args(&args).unwrap_err();
assert!(err.to_string().contains("label"), "{err}");
assert!(err.to_string().contains("empty"), "{err}");
}
#[test]
fn rejects_over_length_header() {
let long_header = "x".repeat(MAX_HEADER_CHARS + 1);
let args = serde_json::json!({
"questions": [{
"header": long_header, "question": "q?",
"options": [
{"label": "a", "description": "d"},
{"label": "b", "description": "d"}
]
}]
})
.to_string();
let err = parse_ask_question_args(&args).unwrap_err();
assert!(err.to_string().contains("header"), "{err}");
}
#[test]
fn rejects_two_recommended_options() {
let args = serde_json::json!({
"questions": [{
"header": "h", "question": "q?",
"options": [
{"label": "a", "description": "d", "recommended": true},
{"label": "b", "description": "d", "recommended": true}
]
}]
})
.to_string();
let err = parse_ask_question_args(&args).unwrap_err();
assert!(err.to_string().contains("at most one option"), "{err}");
}
#[test]
fn rejects_missing_options_field() {
let args = serde_json::json!({
"questions": [{"header": "h", "question": "q?"}]
})
.to_string();
let err = parse_ask_question_args(&args).unwrap_err();
assert!(err.to_string().contains("options"), "{err}");
}
fn two_items() -> Vec<QuestionItem> {
vec![
QuestionItem {
header: "Deploy target".to_owned(),
question: "Which environment?".to_owned(),
options: vec![
QuestionOption {
label: "Staging".to_owned(),
description: "d1".to_owned(),
recommended: false,
},
QuestionOption {
label: "Production".to_owned(),
description: "d2".to_owned(),
recommended: true,
},
],
},
QuestionItem {
header: "Notify team?".to_owned(),
question: "Should we notify the team?".to_owned(),
options: vec![
QuestionOption {
label: "Yes".to_owned(),
description: "d3".to_owned(),
recommended: false,
},
QuestionOption {
label: "No".to_owned(),
description: "d4".to_owned(),
recommended: false,
},
],
},
]
}
#[test]
fn answered_declined_and_auto_resolved_produce_distinct_results() {
let items = vec![two_items()[0].clone()];
let answered = question_call_result_json(
&items,
&[VerifiedAnswer {
call_id: "call-1".to_owned(),
index: 0,
state: AnswerState::Answered,
selected_index: Some(1),
selected_label: "Production".to_owned(),
answered_by: "slack:T1:U9".to_owned(),
}],
);
let declined = question_call_result_json(
&items,
&[VerifiedAnswer {
call_id: "call-1".to_owned(),
index: 0,
state: AnswerState::Declined,
selected_index: None,
selected_label: String::new(),
answered_by: "slack:T1:U9".to_owned(),
}],
);
let auto_resolved = question_call_result_json(
&items,
&[VerifiedAnswer {
call_id: "call-1".to_owned(),
index: 0,
state: AnswerState::AutoResolved,
selected_index: Some(1),
selected_label: "Production".to_owned(),
answered_by: String::new(),
}],
);
assert_ne!(answered, declined);
assert_ne!(answered, auto_resolved);
assert_ne!(declined, auto_resolved);
let a: serde_json::Value = serde_json::from_str(&answered).unwrap();
assert_eq!(a["answers"][0]["state"], "answered");
assert_eq!(a["answers"][0]["selected_label"], "Production");
let d: serde_json::Value = serde_json::from_str(&declined).unwrap();
assert_eq!(d["answers"][0]["state"], "declined");
assert!(d["answers"][0].get("selected_index").is_none());
let r: serde_json::Value = serde_json::from_str(&auto_resolved).unwrap();
assert_eq!(r["answers"][0]["state"], "auto_resolved");
assert!(
r["answers"][0]["note"]
.as_str()
.unwrap()
.contains("assumption"),
"an auto-resolved answer must flag itself as an assumption, not a real answer"
);
}
#[test]
fn still_pending_is_distinct_from_every_real_answer_state() {
let items = vec![two_items()[0].clone()];
let still_pending = question_still_pending_json(&items);
let answered = question_call_result_json(
&items,
&[VerifiedAnswer {
call_id: "call-1".to_owned(),
index: 0,
state: AnswerState::Answered,
selected_index: Some(1),
selected_label: "Production".to_owned(),
answered_by: "slack:T1:U9".to_owned(),
}],
);
let declined = question_call_result_json(
&items,
&[VerifiedAnswer {
call_id: "call-1".to_owned(),
index: 0,
state: AnswerState::Declined,
selected_index: None,
selected_label: String::new(),
answered_by: "slack:T1:U9".to_owned(),
}],
);
let auto_resolved = question_call_result_json(
&items,
&[VerifiedAnswer {
call_id: "call-1".to_owned(),
index: 0,
state: AnswerState::AutoResolved,
selected_index: Some(1),
selected_label: "Production".to_owned(),
answered_by: String::new(),
}],
);
assert_ne!(still_pending, answered);
assert_ne!(still_pending, declined);
assert_ne!(still_pending, auto_resolved);
let v: serde_json::Value = serde_json::from_str(&still_pending).unwrap();
assert_eq!(v["answers"][0]["state"], "still_pending");
assert!(v["answers"][0].get("selected_index").is_none());
assert!(
v["answers"][0]["note"]
.as_str()
.unwrap()
.contains("still open"),
"the still-pending note must tell the model this is not a real answer"
);
}
#[test]
fn multi_question_call_renders_one_entry_per_question() {
let items = two_items();
let json = question_call_result_json(
&items,
&[
VerifiedAnswer {
call_id: "call-1".to_owned(),
index: 0,
state: AnswerState::Answered,
selected_index: Some(0),
selected_label: "Staging".to_owned(),
answered_by: "slack:T1:U9".to_owned(),
},
VerifiedAnswer {
call_id: "call-1".to_owned(),
index: 1,
state: AnswerState::Declined,
selected_index: None,
selected_label: String::new(),
answered_by: "slack:T1:U9".to_owned(),
},
],
);
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["answers"].as_array().unwrap().len(), 2);
assert_eq!(v["answers"][0]["header"], "Deploy target");
assert_eq!(v["answers"][0]["state"], "answered");
assert_eq!(v["answers"][1]["header"], "Notify team?");
assert_eq!(v["answers"][1]["state"], "declined");
}
}