polyc-tools 2026.9.0

The in-process tool core for polychrome agents: local executors (coding, web fetch, wallet, ...), the tool registry, and MCP composition. The networked connectors live in polyc-connectors.
//! Spec for the agent-evaluable `ask_question` tool: the agent pauses a turn
//! to present the user with a short batch of clarifying questions, each with
//! 2-4 mutually exclusive options.
//!
//! Like `paid_fetch`/`web_fetch`/`peer_call`, `ask_question` is
//! **advertise-only** in this crate: the [`ToolRegistry`](crate::ToolRegistry)
//! offers its [`spec`] but never executes it. Unlike those tools, it has no
//! proxy either — calling it always short-circuits the turn loop's own
//! question-pause phase (`polyc_agent::question`) before any
//! [`polyc_agent::ToolExecutor::execute`] is ever reached, the same way an
//! approval-gated call never reaches `execute` until it clears the HITL gate.
//! `ask_question` is a *sibling* pause path to that gate, not a reuse of it —
//! see issue #1660 and its parent PRD #1659.
//!
//! The model never authors an "other"/free-text option itself; a surface
//! that supports free text adds that affordance on the user's behalf.

use polyc_llm::ToolSpec;
use serde_json::json;

/// The `ask_question` tool name.
pub const TOOL_NAME: &str = "ask_question";

/// Every `ask_question` tool name, for allowlist checks and dispatch (one,
/// today).
pub const ALL: &[&str] = &[TOOL_NAME];

/// The required argument: the batch of 1-3 questions.
pub const ARG_QUESTIONS: &str = "questions";

/// A question's short button-row label.
pub const ARG_HEADER: &str = "header";

/// A question's one-sentence prompt.
pub const ARG_QUESTION: &str = "question";

/// A question's 2-4 mutually exclusive options.
pub const ARG_OPTIONS: &str = "options";

/// An option's short label (a few words).
pub const ARG_LABEL: &str = "label";

/// An option's one-sentence consequence of picking it.
pub const ARG_DESCRIPTION: &str = "description";

/// Whether this option is the model's recommendation (at most one per
/// question).
pub const ARG_RECOMMENDED: &str = "recommended";

/// Maximum number of questions in a single `ask_question` call.
pub const MAX_QUESTIONS_PER_CALL: usize = 3;

/// Minimum number of options a question may offer.
pub const MIN_OPTIONS: usize = 2;

/// Maximum number of options a question may offer.
pub const MAX_OPTIONS: usize = 4;

/// Maximum length (in characters) of a question's `header` — short enough to
/// render as a chat-surface button-row label. Slack button text caps at 75
/// characters; 60 leaves margin for a per-edge prefix.
pub const MAX_HEADER_CHARS: usize = 60;

/// Maximum length (in characters) of an option's `label`.
pub const MAX_OPTION_LABEL_CHARS: usize = 48;

/// Maximum length (in characters) of a one-sentence field (`question` or an
/// option's `description`).
pub const MAX_SENTENCE_CHARS: usize = 200;

/// The `ask_question` tool spec.
///
/// Always pauses the turn — it is never executed synchronously. No
/// `.approval_required()`: this is not a danger/permission decision, so it
/// never enters the HITL approval gate; its own pause/resume path in
/// `polyc_agent::question` owns what happens when the model calls it.
#[must_use]
pub fn spec() -> ToolSpec {
    ToolSpec::new(
        TOOL_NAME,
        "Pause the turn and ask the user 1-3 short clarifying questions, each with 2-4 \
         mutually exclusive options. Use this when you hit a genuinely ambiguous or \
         exploratory decision point — which of several plausible interpretations the user \
         meant, or which of a few reasonable approaches to take — instead of guessing or \
         writing the question as plain text. Every option must state its own one-sentence \
         consequence so the user can decide from the options alone. Mark at most one option \
         per question `recommended` when you have a genuine preference. Never author an \
         \"other\"/free-text option yourself — the surface the user is on adds that on its \
         own when it supports free text. Calling this ALWAYS pauses the turn; nothing else \
         in this batch executes until the user answers, and if nobody answers in time the \
         question auto-resolves to the recommended option (or the first option if none was \
         marked) and you are told explicitly that this was an assumption, not a real \
         answer.",
        json!({
            "type": "object",
            "properties": {
                ARG_QUESTIONS: {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": MAX_QUESTIONS_PER_CALL,
                    "items": {
                        "type": "object",
                        "properties": {
                            ARG_HEADER: {
                                "type": "string",
                                "maxLength": MAX_HEADER_CHARS,
                                "description": "A short label (fits a chat-surface button-row \
                                    heading) naming what this question is about."
                            },
                            ARG_QUESTION: {
                                "type": "string",
                                "maxLength": MAX_SENTENCE_CHARS,
                                "description": "The one-sentence question to ask."
                            },
                            ARG_OPTIONS: {
                                "type": "array",
                                "minItems": MIN_OPTIONS,
                                "maxItems": MAX_OPTIONS,
                                "items": {
                                    "type": "object",
                                    "properties": {
                                        ARG_LABEL: {
                                            "type": "string",
                                            "maxLength": MAX_OPTION_LABEL_CHARS,
                                            "description": "A few words naming this option."
                                        },
                                        ARG_DESCRIPTION: {
                                            "type": "string",
                                            "maxLength": MAX_SENTENCE_CHARS,
                                            "description": "The one-sentence consequence of \
                                                picking this option."
                                        },
                                        ARG_RECOMMENDED: {
                                            "type": "boolean",
                                            "description": "Set true on AT MOST ONE option \
                                                per question — your recommendation."
                                        }
                                    },
                                    "required": [ARG_LABEL, ARG_DESCRIPTION],
                                    "additionalProperties": false
                                },
                                "description": "2-4 mutually exclusive options; never author \
                                    an \"other\"/free-text option."
                            }
                        },
                        "required": [ARG_HEADER, ARG_QUESTION, ARG_OPTIONS],
                        "additionalProperties": false
                    }
                }
            },
            "required": [ARG_QUESTIONS],
            "additionalProperties": false
        }),
    )
    .read_only()
    .interactive()
    .titled("Ask a clarifying question")
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
    use super::*;

    #[test]
    fn all_matches_tool_name() {
        assert_eq!(ALL, &[TOOL_NAME]);
    }

    /// `polyc-agent` cannot depend on `polyc-tools` (this crate already
    /// depends on `polyc-agent` for `ToolExecutor`, so the reverse edge would
    /// cycle), so `polyc_agent::question::ASK_QUESTION_TOOL_NAME` duplicates
    /// this literal rather than importing it — this test is the parity pin
    /// that keeps the two from drifting apart.
    #[test]
    fn tool_name_matches_agent_loop_constant() {
        assert_eq!(TOOL_NAME, polyc_agent::question::ASK_QUESTION_TOOL_NAME);
    }

    #[test]
    fn spec_name_matches_tool_name() {
        assert_eq!(spec().name, TOOL_NAME);
    }

    #[test]
    fn spec_is_read_only_and_never_approval_gated() {
        let s = spec();
        assert!(
            s.read_only,
            "ask_question performs no side effect of its own"
        );
        assert!(
            !s.needs_approval,
            "ask_question is a sibling pause path, not routed through the HITL approval gate"
        );
        assert!(
            !s.destructive,
            "ask_question never mutates anything on its own"
        );
    }

    #[test]
    fn spec_is_interactive() {
        assert!(
            spec().interactive,
            "ask_question pauses the turn to ask a person, so a fire dispatch must exclude it"
        );
    }

    #[test]
    fn spec_schema_declares_the_full_shape() {
        let s = spec();
        let schema = &s.schema_json;
        assert_eq!(schema["type"], "object");
        assert_eq!(schema["additionalProperties"], false);
        assert_eq!(schema["required"], json!([ARG_QUESTIONS]));

        let questions = &schema["properties"][ARG_QUESTIONS];
        assert_eq!(questions["type"], "array");
        assert_eq!(questions["minItems"], 1);
        assert_eq!(questions["maxItems"], MAX_QUESTIONS_PER_CALL);

        let item = &questions["items"];
        assert_eq!(item["additionalProperties"], false);
        assert_eq!(
            item["required"],
            json!([ARG_HEADER, ARG_QUESTION, ARG_OPTIONS])
        );
        assert_eq!(
            item["properties"][ARG_HEADER]["maxLength"],
            MAX_HEADER_CHARS
        );
        assert_eq!(
            item["properties"][ARG_QUESTION]["maxLength"],
            MAX_SENTENCE_CHARS
        );

        let options = &item["properties"][ARG_OPTIONS];
        assert_eq!(options["minItems"], MIN_OPTIONS);
        assert_eq!(options["maxItems"], MAX_OPTIONS);
        let opt_item = &options["items"];
        assert_eq!(opt_item["additionalProperties"], false);
        assert_eq!(opt_item["required"], json!([ARG_LABEL, ARG_DESCRIPTION]));
        assert_eq!(
            opt_item["properties"][ARG_LABEL]["maxLength"],
            MAX_OPTION_LABEL_CHARS
        );
        assert_eq!(
            opt_item["properties"][ARG_DESCRIPTION]["maxLength"],
            MAX_SENTENCE_CHARS
        );
        assert_eq!(opt_item["properties"][ARG_RECOMMENDED]["type"], "boolean");
    }
}