use super::error::Result;
use super::r#trait::{Tool, ToolCapability, ToolExecutionContext, ToolResult};
use crate::brain::agent::ProgressEvent;
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::Value;
pub const MAX_SUGGESTIONS: usize = 4;
pub struct SuggestFollowupsTool;
#[derive(Debug, Deserialize)]
struct SuggestInput {
options: Vec<String>,
}
#[async_trait]
impl Tool for SuggestFollowupsTool {
fn name(&self) -> &str {
"suggest_followups"
}
fn description(&self) -> &str {
"Optionally surface 1-4 short follow-up messages the user might send next. \
Non-blocking: the suggestions appear in the input area (one as gray ghost \
text to accept with Tab, several as a pick-list) and the user may accept, \
edit, or ignore them. Call this at the END of a turn when there is an \
obvious next step (1 option) or a small set of distinct next directions \
(2-4 options). Each option must be a complete, ready-to-send user message \
phrased in the user's voice (e.g. \"Add tests for the new endpoint\", not \
\"I could add tests\"). Keep each under ~60 chars. Do NOT use this to ask a \
question you need answered to proceed (use follow_up_question), and do not \
repeat the options in your prose."
}
fn input_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"options": {
"type": "array",
"items": { "type": "string" },
"minItems": 1,
"maxItems": MAX_SUGGESTIONS,
"description": "1 to 4 distinct, ready-to-send follow-up messages in the user's voice. One option shows as ghost text (Tab to fill); several show as a pick-list."
}
},
"required": ["options"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![]
}
fn requires_approval(&self) -> bool {
false
}
async fn execute(&self, input: Value, context: &ToolExecutionContext) -> Result<ToolResult> {
let parsed: SuggestInput = serde_json::from_value(input)?;
let options = sanitize_options(parsed.options);
if let Err(msg) = options {
return Ok(ToolResult::error(msg));
}
let options = options.expect("checked Ok above");
let count = options.len();
if let Some(cb) = context.progress_callback.as_ref() {
cb(
context.session_id,
ProgressEvent::SuggestedFollowups(options),
);
}
Ok(ToolResult::success(format!(
"Surfaced {count} follow-up suggestion(s)."
)))
}
}
pub(crate) fn sanitize_options(raw: Vec<String>) -> std::result::Result<Vec<String>, String> {
let options: Vec<String> = raw
.into_iter()
.map(|o| o.trim().to_string())
.filter(|o| !o.is_empty())
.collect();
if options.is_empty() {
return Err("suggest_followups needs at least 1 non-empty option.".into());
}
if options.len() > MAX_SUGGESTIONS {
return Err(format!(
"Too many suggestions ({}). Cap is {}.",
options.len(),
MAX_SUGGESTIONS
));
}
let mut seen = std::collections::HashSet::new();
for opt in &options {
if !seen.insert(opt.as_str()) {
return Err(format!(
"Duplicate suggestion '{opt}'. Suggestions must be distinct."
));
}
}
Ok(options)
}