supercode-harness 0.5.45

The optional native Volter Harness agent and tool harness
Documentation
//! BP-3 (§2 module 6 `tools.question`, catalog row "Structured
//! user-question tool"): the tool a model uses to ask the USER a
//! multiple-choice or free-text question mid-run, and wait for the answer.
//!
//! **One door, not a new one.** The design already names this module's
//! protocol side: `crate::mcp::McpElicitationHandler` is documented as "the
//! `tools.question` surface's PROTOCOL side (§2.1 dep)". So this tool does
//! not invent a transport — it asks through that same handler, which under
//! an SDK-owned runtime is the frontend request broker
//! (`crate::server::FrontendRequestBridge::elicitation_handler`): the
//! request is published into the sequenced frontend event stream as a
//! `{"type":"request","request":{…}}` envelope and the turn BLOCKS on it
//! until `harness.v1.runtimes.respond` answers with the content. That is the
//! same broker, the same `respond` door, and the same request id space the
//! approvals path uses; the two differ only in `kind` (an approval is
//! allow/deny, a question carries structured answers back), which is exactly
//! why `crate::approvals` filters non-approval kinds out of its listing.
//!
//! **Headless is deny-default** (§2 module 6's own "⚡ headless print mode
//! (deny-default like OC, oc§1)"): with no handler installed nobody can
//! answer, so the call fails with a message telling the model to decide for
//! itself rather than hanging or silently inventing an answer.
//!
//! **Shape.** [`AskUserTool`] takes Claude Code's `AskUserQuestion` shape —
//! 1-4 questions, each with a short header, 1-4 labelled options, an
//! optional `multiSelect`, and (always) a free-text fallback. The same tool
//! object is registered under Codex's experimental spelling
//! [`REQUEST_USER_INPUT`] when a preset asks for it, so a continued Codex
//! session's own tool name keeps resolving.

use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

use crate::error::{Error, Result};
use crate::mcp::{ElicitationAction, ElicitationRequest};
use crate::tools::{Tool, ToolContext};

/// Registered name of the question tool (Claude Code's `AskUserQuestion`).
pub const ASK_USER: &str = "ask_user";

/// Codex's experimental spelling for the same capability (cx§1
/// `request_user_input`), registered as an alias under `cx-parity`.
pub const REQUEST_USER_INPUT: &str = "request_user_input";

/// The handler `ask_user` asks through, wrapped so [`ToolContext`] can stay
/// `Debug` — the same newtype shape (and the same reason) as
/// [`crate::tools::ToolApprovalHandler`].
#[derive(Clone)]
pub struct UserQuestionHandler(pub std::sync::Arc<dyn crate::mcp::McpElicitationHandler>);

impl std::fmt::Debug for UserQuestionHandler {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("UserQuestionHandler(..)")
    }
}

impl std::ops::Deref for UserQuestionHandler {
    type Target = dyn crate::mcp::McpElicitationHandler;
    fn deref(&self) -> &Self::Target {
        &*self.0
    }
}

/// Claude Code's cap: at most four questions in one call.
pub const MAX_QUESTIONS: usize = 4;

/// At most four options per question (the CC shape's own cap).
pub const MAX_OPTIONS: usize = 4;

/// One selectable answer.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct QuestionOption {
    /// Short label shown to the user (and the token an answer names).
    pub label: String,
    /// Optional longer explanation.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
}

/// One question in an [`AskUserTool`] call.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Question {
    /// The question text.
    pub question: String,
    /// Short header naming what is being decided (CC renders this as the
    /// tab/column label). Defaults to the empty string.
    #[serde(default)]
    pub header: String,
    /// Whether the user may pick more than one option.
    #[serde(default, rename = "multiSelect", alias = "multi_select")]
    pub multi_select: bool,
    /// The offered options. May be empty for a purely free-text question.
    #[serde(default)]
    pub options: Vec<QuestionOption>,
}

#[derive(Debug, Deserialize)]
struct AskUserArgs {
    questions: Vec<Question>,
}

/// The structured user-question tool. `name` is the registered spelling —
/// [`ASK_USER`] under `cc-parity`, additionally [`REQUEST_USER_INPUT`]
/// under `cx-parity`.
#[derive(Debug, Clone)]
pub struct AskUserTool {
    name: &'static str,
}

impl AskUserTool {
    /// A tool registered under `name` (one of [`ASK_USER`] /
    /// [`REQUEST_USER_INPUT`]).
    pub fn new(name: &'static str) -> Self {
        AskUserTool { name }
    }
}

impl Default for AskUserTool {
    fn default() -> Self {
        AskUserTool::new(ASK_USER)
    }
}

/// The JSON Schema handed to the frontend as the requested answer shape:
/// one property per question, keyed by its 1-based index (`q1`, `q2`, …) so
/// the mapping back is positional and cannot be confused by duplicate
/// headers. Every property is free-text-capable — the offered labels travel
/// as `x-options` (and are repeated in the description) rather than as a
/// JSON-Schema `enum`, because the CC shape always permits a free-text
/// answer that is not one of the labels.
fn requested_schema(questions: &[Question]) -> Value {
    let mut properties = serde_json::Map::new();
    let mut required = Vec::new();
    for (index, q) in questions.iter().enumerate() {
        let key = format!("q{}", index + 1);
        let labels: Vec<&str> = q.options.iter().map(|o| o.label.as_str()).collect();
        let mut description = q.question.clone();
        if !labels.is_empty() {
            description.push_str(&format!(
                " (options: {}; free text is also accepted)",
                labels.join(" | ")
            ));
        }
        let mut prop = json!({
            "title": if q.header.is_empty() { q.question.clone() } else { q.header.clone() },
            "description": description,
            "x-options": q.options,
            "x-multi-select": q.multi_select,
        });
        if q.multi_select {
            prop["type"] = json!("array");
            prop["items"] = json!({"type": "string"});
        } else {
            prop["type"] = json!("string");
        }
        properties.insert(key.clone(), prop);
        required.push(key);
    }
    json!({
        "type": "object",
        "properties": properties,
        "required": required,
    })
}

/// Render the questions as the human-readable prompt line the request
/// carries alongside its schema.
fn message_for(questions: &[Question]) -> String {
    let mut out = String::new();
    for (index, q) in questions.iter().enumerate() {
        if index > 0 {
            out.push_str("\n\n");
        }
        if !q.header.is_empty() {
            out.push_str(&format!("[{}] ", q.header));
        }
        out.push_str(&q.question);
        for opt in &q.options {
            out.push_str(&format!("\n  - {}", opt.label));
            if let Some(d) = &opt.description {
                out.push_str(&format!(" — {d}"));
            }
        }
        if q.multi_select {
            out.push_str("\n  (multiple selections allowed)");
        }
    }
    out
}

/// Format the frontend's `content` object back into the text the model
/// reads: one `header/question -> answer` line per question, plus the raw
/// JSON so a model that prefers structure has it.
fn format_answers(questions: &[Question], content: &Value) -> String {
    let mut lines = Vec::new();
    for (index, q) in questions.iter().enumerate() {
        let key = format!("q{}", index + 1);
        let answer = content.get(&key).map(render_answer).unwrap_or_else(|| {
            content
                .get(&q.header)
                .map(render_answer)
                .unwrap_or_else(|| "(no answer)".to_string())
        });
        let label = if q.header.is_empty() {
            q.question.clone()
        } else {
            q.header.clone()
        };
        lines.push(format!("{label}: {answer}"));
    }
    format!(
        "The user answered:\n{}\n\nraw: {}",
        lines.join("\n"),
        content
    )
}

fn render_answer(v: &Value) -> String {
    match v {
        Value::String(s) => s.clone(),
        Value::Array(items) => items
            .iter()
            .map(render_answer)
            .collect::<Vec<_>>()
            .join(", "),
        other => other.to_string(),
    }
}

#[async_trait]
impl Tool for AskUserTool {
    fn name(&self) -> &str {
        self.name
    }
    fn description(&self) -> &str {
        "Ask the user 1-4 structured questions and wait for the answers. Use it when a \
         decision is genuinely the user's to make (a choice between real alternatives, a \
         missing fact only they have) — never to ask permission for work you were already \
         asked to do. Each question offers labelled options; the user may also answer in \
         free text."
    }
    fn parameters(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "questions": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": MAX_QUESTIONS,
                    "description": "1-4 questions to ask at once.",
                    "items": {
                        "type": "object",
                        "properties": {
                            "question": {"type": "string", "description": "The question text."},
                            "header": {
                                "type": "string",
                                "description": "Short label (a few words) naming what is being decided."
                            },
                            "multiSelect": {
                                "type": "boolean",
                                "description": "Whether the user may pick more than one option."
                            },
                            "options": {
                                "type": "array",
                                "maxItems": MAX_OPTIONS,
                                "items": {
                                    "type": "object",
                                    "properties": {
                                        "label": {"type": "string"},
                                        "description": {"type": "string"}
                                    },
                                    "required": ["label"],
                                    "additionalProperties": false
                                }
                            }
                        },
                        "required": ["question", "options"],
                        "additionalProperties": false
                    }
                }
            },
            "required": ["questions"],
            "additionalProperties": false
        })
    }
    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
        let a: AskUserArgs = serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
            tool: self.name().to_string(),
            message: e.to_string(),
        })?;
        if a.questions.is_empty() || a.questions.len() > MAX_QUESTIONS {
            return Err(Error::InvalidArguments {
                tool: self.name().to_string(),
                message: format!(
                    "ask between 1 and {MAX_QUESTIONS} questions in one call (got {})",
                    a.questions.len()
                ),
            });
        }
        for q in &a.questions {
            if q.question.trim().is_empty() {
                return Err(Error::InvalidArguments {
                    tool: self.name().to_string(),
                    message: "every question needs non-empty text".to_string(),
                });
            }
            if q.options.len() > MAX_OPTIONS {
                return Err(Error::InvalidArguments {
                    tool: self.name().to_string(),
                    message: format!("at most {MAX_OPTIONS} options per question"),
                });
            }
            if q.options.iter().any(|o| o.label.trim().is_empty()) {
                return Err(Error::InvalidArguments {
                    tool: self.name().to_string(),
                    message: "every option needs a non-empty label".to_string(),
                });
            }
        }
        // Headless (no interactive frontend attached) is deny-default: the
        // model is told plainly that nobody can answer, so it decides for
        // itself instead of waiting on a request that can never resolve.
        let Some(handler) = ctx.question_handler.as_ref() else {
            return Err(Error::tool(
                self.name(),
                "no interactive frontend is attached, so the user cannot be asked (headless \
                 run): make the best decision you can and say which assumption you made",
            ));
        };
        let request = ElicitationRequest {
            message: message_for(&a.questions),
            requested_schema: requested_schema(&a.questions),
        };
        let response = handler.handle(&request).await;
        match response.action {
            ElicitationAction::Accept => {
                let content = response.content.unwrap_or_else(|| json!({}));
                Ok(format_answers(&a.questions, &content))
            }
            ElicitationAction::Decline => Ok(
                "The user declined to answer. Proceed with your own best judgement and say \
                    what you assumed."
                    .to_string(),
            ),
            ElicitationAction::Cancel => Ok("The user dismissed the question without \
                                             answering. Proceed with your own best judgement \
                                             and say what you assumed."
                .to_string()),
        }
    }
}