opencrabs 0.3.69

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Install with: cargo install opencrabs
//! `suggest_followups` tool.
//!
//! Lets the agent surface OPTIONAL next-step suggestions the user may accept or
//! ignore. Unlike `follow_up_question` this is non-blocking: it fires a
//! `ProgressEvent::SuggestedFollowups` and returns immediately without awaiting
//! any answer. The TUI renders one option as gray ghost text in the input (Tab
//! fills it) and several as a small pick-list; accepting fills the input as
//! editable text and never auto-submits, so the user can edit it before sending.
//!
//! Intended for "here's a likely next thing you might ask" — a convenience, not
//! a question. If the agent genuinely cannot proceed without a choice, it should
//! use `follow_up_question` (blocking) instead.

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;

/// Hard cap on suggestions. One renders as ghost text; a few render as a
/// pick-list. More than a handful is noise the user won't read.
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> {
        // Pure UI signal. No filesystem, shell, or network.
        vec![]
    }

    fn requires_approval(&self) -> bool {
        // The tool IS a passive UI hint — nothing to approve.
        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");

        // Non-blocking: fire the event and return. Surfaces without a progress
        // bridge (channels, A2A) simply don't render it — not an error, the
        // suggestions are always optional.
        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)."
        )))
    }
}

/// Trim, drop empties, enforce the 1..=MAX distinct contract. Extracted so the
/// validation is unit-testable without a live progress callback.
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)
}