use polyc_llm::ToolSpec;
use serde_json::json;
pub const TOOL_NAME: &str = "ask_question";
pub const ALL: &[&str] = &[TOOL_NAME];
pub const ARG_QUESTIONS: &str = "questions";
pub const ARG_HEADER: &str = "header";
pub const ARG_QUESTION: &str = "question";
pub const ARG_OPTIONS: &str = "options";
pub const ARG_LABEL: &str = "label";
pub const ARG_DESCRIPTION: &str = "description";
pub const ARG_RECOMMENDED: &str = "recommended";
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;
#[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]);
}
#[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");
}
}