shuvarie-core 0.3.2

Blazingly fast AI coding TUI for chivalrous people (core module)
use serde_json::{Value, json};
use shuvarie_llm::{Tool, ToolContext, ToolExecutionError, ToolOutput};

use crate::question::{QuestionGate, QuestionOption, QuestionPrompt};

pub(crate) struct Question {
    gate: QuestionGate,
}

impl Question {
    pub(crate) fn new(gate: QuestionGate) -> Self {
        Self { gate }
    }
}

impl Tool for Question {
    const NAME: &'static str = "question";

    type Args = Value;
    type Output = ToolOutput;
    type Error = ToolExecutionError;

    fn description(&self) -> String {
        "Use this tool when you need to ask the user questions during execution. This allows you to:\n1. Gather user preferences or requirements\n2. Clarify ambiguous instructions\n3. Get decisions on implementation choices as you work\n4. Offer choices to the user about what direction to take.\n\nUsage notes:\n- When `custom` is enabled (default), a \"Type your own answer\" option is added automatically; don't include \"Other\" or catch-all options\n- Answers are returned as arrays of labels; set `multiple: true` to allow selecting more than one\n- If you recommend a specific option, make that the first option in the list and add \"(Recommended)\" at the end of the label".to_string()
    }

    fn parameters(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "questions": {
                    "type": "array",
                    "description": "Questions to ask",
                    "items": {
                        "type": "object",
                        "properties": {
                            "question": { "type": "string", "description": "Complete question" },
                            "header": { "type": "string", "description": "Very short label (max 30 chars)" },
                            "options": {
                                "type": "array",
                                "description": "Available choices",
                                "items": {
                                    "type": "object",
                                    "properties": {
                                        "label": { "type": "string", "description": "Display text (1-5 words, concise)" },
                                        "description": { "type": "string", "description": "Explanation of choice" }
                                    },
                                    "required": ["label"]
                                }
                            },
                            "multiple": { "type": "boolean", "description": "Allow selecting multiple choices" },
                            "custom": { "type": "boolean", "description": "Allow typing a custom answer (default: true)" }
                        },
                        "required": ["question", "header", "options"]
                    }
                }
            },
            "required": ["questions"]
        })
    }

    async fn call(
        &self,
        _ctx: &mut ToolContext,
        args: Value,
    ) -> Result<ToolOutput, ToolExecutionError> {
        let gate = self.gate.clone();
        let result: Result<ToolOutput, String> = async move {
            let raw = args
                .get("questions")
                .and_then(Value::as_array)
                .ok_or("missing 'questions' array argument")?;
            let prompts: Vec<QuestionPrompt> = raw
                .iter()
                .map(|q| {
                    let question = q
                        .get("question")
                        .and_then(Value::as_str)
                        .ok_or("missing string argument 'question'")?
                        .to_string();
                    let header = q
                        .get("header")
                        .and_then(Value::as_str)
                        .ok_or("missing string argument 'header'")?
                        .to_string();
                    let options = q
                        .get("options")
                        .and_then(Value::as_array)
                        .map(|arr| {
                            arr.iter()
                                .map(|o| QuestionOption {
                                    label: o
                                        .get("label")
                                        .and_then(Value::as_str)
                                        .unwrap_or_default()
                                        .to_string(),
                                    description: o
                                        .get("description")
                                        .and_then(Value::as_str)
                                        .unwrap_or_default()
                                        .to_string(),
                                })
                                .collect()
                        })
                        .unwrap_or_default();
                    let multiple = q.get("multiple").and_then(Value::as_bool).unwrap_or(false);
                    let custom = q.get("custom").and_then(Value::as_bool).unwrap_or(true);
                    Ok::<QuestionPrompt, String>(QuestionPrompt {
                        question,
                        header,
                        options,
                        multiple,
                        custom,
                    })
                })
                .collect::<Result<_, String>>()?;
            let count = prompts.len();
            let answers = gate.ask(prompts.clone()).await?;
            let formatted = prompts
                .iter()
                .zip(&answers)
                .map(|(q, a)| {
                    let joined = if a.is_empty() {
                        "Unanswered".to_string()
                    } else {
                        a.join(", ")
                    };
                    format!("\"{}\"=\"{joined}\"", q.question)
                })
                .collect::<Vec<_>>()
                .join(", ");
            let plural = if count == 1 { "" } else { "s" };
            Ok(ToolOutput::text(format!(
                "User answered {count} question{plural}: {formatted}"
            )))
        }
        .await;
        result.map_err(ToolExecutionError::other)
    }
}