malvin 0.2.7

Non-interactive research and coding agent
use pi::sdk::SessionOptions;

const LOCAL_ENABLED_TOOLS: &[&str] = &["read", "bash", "edit", "write", "grep", "find", "ls"];

const LOCAL_TEXT_ONLY_APPEND: &str = concat!(
    "You are a non-interactive CLI agent. Answer in plain text only. ",
    "You have no tools; do not invent shell output, file contents, or tool results."
);

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum LocalAgentMode {
    NonKeyless,
    KeylessTools,
    KeylessTextOnly,
}

impl LocalAgentMode {
    pub(crate) fn for_provider_model(keyless: bool, provider: &str, model: &str) -> Self {
        if !keyless {
            return Self::NonKeyless;
        }
        if keyless_local_tools_enabled(provider, model) {
            Self::KeylessTools
        } else {
            Self::KeylessTextOnly
        }
    }
}

pub(crate) fn local_append_system_prompt(mode: LocalAgentMode) -> Option<String> {
    match mode {
        LocalAgentMode::NonKeyless => None,
        LocalAgentMode::KeylessTools => Some(
            concat!(
                "You are a non-interactive CLI agent. For any shell/file action emit ONLY ",
                "a JSON tool call {\"name\":\"bash\",\"parameters\":{\"command\":\"...\"}} ",
                "(or read/write/edit/grep/find/ls). Never invent results. Never answer with ",
                "markdown ```bash fences. Stay in the workspace unless a temp path is named. ",
                "For HTTP(S) downloads prefer curl -L -o FILE URL. ",
                "To count files prefer bash with find . -type f | wc -l; do not install packages."
            )
            .to_string(),
        ),
        LocalAgentMode::KeylessTextOnly => Some(LOCAL_TEXT_ONLY_APPEND.to_string()),
    }
}

pub(crate) fn local_enabled_tools(mode: LocalAgentMode) -> Option<Vec<String>> {
    match mode {
        LocalAgentMode::NonKeyless => None,
        LocalAgentMode::KeylessTools => Some(
            LOCAL_ENABLED_TOOLS
                .iter()
                .map(|s| (*s).to_string())
                .collect(),
        ),
        LocalAgentMode::KeylessTextOnly => Some(Vec::new()),
    }
}

pub(crate) fn local_max_tool_iterations(mode: LocalAgentMode) -> usize {
    match mode {
        LocalAgentMode::KeylessTools => 40,
        LocalAgentMode::KeylessTextOnly => 1,
        LocalAgentMode::NonKeyless => SessionOptions::default().max_tool_iterations,
    }
}

fn keyless_local_tools_enabled(provider: &str, model: &str) -> bool {
    if !provider.eq_ignore_ascii_case("ollama") {
        return true;
    }
    super::local_llm_ollama::ollama_model_supports_tools(model).unwrap_or(true)
}

pub(crate) fn local_session_overrides(
    keyless: bool,
    provider: &str,
    model: &str,
) -> (Option<String>, Option<Vec<String>>, usize) {
    let mode = LocalAgentMode::for_provider_model(keyless, provider, model);
    (
        local_append_system_prompt(mode),
        local_enabled_tools(mode),
        local_max_tool_iterations(mode),
    )
}