stynx-code 3.7.2

stynx-code — interactive AI coding assistant
use std::sync::Arc;
use std::sync::atomic::AtomicU8;

use stynx_code_permission::ConfigAwarePermissionChecker;
use stynx_code_provider::OpenAiProvider;
use stynx_code_tools::ToolRegistry;

use crate::infrastructure::agent_tool::InternTool;

const INTERN_TOOLS: &[&str] = &[
    "bash", "read", "file_write", "file_edit", "glob", "grep",
];

struct ResolvedIntern {
    name: String,
    provider_label: String,
    description: String,
    base_url: String,
    api_key: String,
    model: String,
}

pub fn build_intern_tools(
    config: &stynx_code_config::Settings,
    sub_registry: &Arc<ToolRegistry>,
    permission: &Arc<ConfigAwarePermissionChecker>,
    mode_flag: &Arc<AtomicU8>,
    hooks: &stynx_code_config::HooksConfig,
) -> Vec<Arc<InternTool>> {
    let resolved = resolve_interns(config);
    let intern_registry = Arc::new(sub_registry.keep_only(INTERN_TOOLS));

    let mut out: Vec<Arc<InternTool>> = Vec::new();
    let mut seen_tool_names: std::collections::HashSet<String> = std::collections::HashSet::new();

    for r in resolved {
        let tool_name = sanitize_tool_name(&format!("delegate_to_{}", r.name));
        if !seen_tool_names.insert(tool_name.clone()) {
            tracing::warn!(name = %r.name, "duplicate intern name, skipping");
            continue;
        }

        let intern_provider: Arc<dyn stynx_code_types::Provider> = Arc::new(
            OpenAiProvider::new(&r.provider_label, r.base_url.clone(), r.api_key.clone(), &r.model),
        );

        eprintln!(
            "  \x1b[2m· intern ready: {name} ({provider} / {model})\x1b[0m",
            name = r.name, provider = r.provider_label, model = r.model,
        );

        out.push(Arc::new(InternTool::new(
            intern_provider,
            intern_registry.clone(),
            permission.clone(),
            mode_flag.clone(),
            hooks.clone(),
            r.name.clone(),
            tool_name,
            r.description.clone(),
        )));
    }
    out
}

fn resolve_interns(config: &stynx_code_config::Settings) -> Vec<ResolvedIntern> {
    let mut out: Vec<ResolvedIntern> = Vec::new();
    let mut names: std::collections::HashSet<String> = std::collections::HashSet::new();

    for cfg in &config.interns {
        if let Some(r) = resolve_one(cfg) {
            if names.insert(r.name.clone()) {
                out.push(r);
            }
        }
    }

    if !names.contains("deepseek") {
        if let Some(r) = legacy_deepseek_intern() {
            names.insert(r.name.clone());
            out.push(r);
        }
    }

    if !names.contains("qwen") {
        if let Some(r) = legacy_qwen_intern() {
            names.insert(r.name.clone());
            out.push(r);
        }
    }

    for r in openrouter_env_interns() {
        if names.insert(r.name.clone()) {
            out.push(r);
        }
    }

    for r in qwen_env_interns() {
        if names.insert(r.name.clone()) {
            out.push(r);
        }
    }

    out
}

fn resolve_one(cfg: &stynx_code_config::InternConfig) -> Option<ResolvedIntern> {
    let name = cfg.name.trim();
    if name.is_empty() { return None; }
    let provider = cfg.provider.trim().to_lowercase();

    let (default_base, default_key_env, provider_label) = match provider.as_str() {
        "deepseek" => ("https://api.deepseek.com/v1", "DEEPSEEK_API_KEY", "deepseek"),
        "openrouter" => ("https://openrouter.ai/api/v1", "OPENROUTER_API_KEY", "openrouter"),
        "openai" => ("https://api.openai.com/v1", "OPENAI_API_KEY", "openai"),
        "qwen" => (
            "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
            "QWEN_API_KEY",
            "qwen",
        ),
        "custom" => ("", "", "custom"),
        _ => {
            tracing::warn!(name = %name, provider = %provider, "unknown intern provider, skipping");
            return None;
        }
    };

    let base_url = cfg.base_url.clone()
        .filter(|s| !s.trim().is_empty())
        .unwrap_or_else(|| default_base.to_string());
    if base_url.is_empty() {
        tracing::warn!(name = %name, "custom intern needs base_url, skipping");
        return None;
    }

    let key_env = cfg.api_key_env.clone()
        .filter(|s| !s.trim().is_empty())
        .unwrap_or_else(|| default_key_env.to_string());
    let api_key = std::env::var(&key_env).ok().filter(|s| !s.trim().is_empty());
    let api_key = match api_key {
        Some(k) => k,
        None => {
            tracing::warn!(name = %name, env = %key_env, "intern missing api key in env, skipping");
            return None;
        }
    };

    let description = cfg.description.clone().unwrap_or_else(|| default_description(name, &cfg.model));

    Some(ResolvedIntern {
        name: name.to_string(),
        provider_label: provider_label.to_string(),
        description,
        base_url,
        api_key,
        model: cfg.model.clone(),
    })
}

fn legacy_deepseek_intern() -> Option<ResolvedIntern> {
    let api_key = std::env::var("DEEPSEEK_API_KEY").ok().filter(|s| !s.trim().is_empty())?;
    let base_url = std::env::var("DEEPSEEK_BASE_URL")
        .ok().filter(|s| !s.trim().is_empty())
        .unwrap_or_else(|| "https://api.deepseek.com/v1".to_string());
    let model = std::env::var("INTERN_MODEL")
        .or_else(|_| std::env::var("DEEPSEEK_MODEL"))
        .ok().filter(|s| !s.trim().is_empty())
        .unwrap_or_else(|| "deepseek-chat".to_string());
    Some(ResolvedIntern {
        name: "deepseek".to_string(),
        provider_label: "deepseek".to_string(),
        description: default_description("deepseek", &model),
        base_url,
        api_key,
        model,
    })
}

fn legacy_qwen_intern() -> Option<ResolvedIntern> {
    let api_key = std::env::var("QWEN_API_KEY").ok().filter(|s| !s.trim().is_empty())?;
    let base_url = std::env::var("QWEN_BASE_URL")
        .ok().filter(|s| !s.trim().is_empty())
        .unwrap_or_else(|| "https://dashscope-intl.aliyuncs.com/compatible-mode/v1".to_string());
    let model = std::env::var("QWEN_MODEL")
        .ok().filter(|s| !s.trim().is_empty())
        .unwrap_or_else(|| "qwen-plus".to_string());
    Some(ResolvedIntern {
        name: "qwen".to_string(),
        provider_label: "qwen".to_string(),
        description: default_description("qwen", &model),
        base_url,
        api_key,
        model,
    })
}

fn qwen_env_interns() -> Vec<ResolvedIntern> {
    let mut out: Vec<ResolvedIntern> = Vec::new();
    let api_key = match std::env::var("QWEN_API_KEY").ok().filter(|s| !s.trim().is_empty()) {
        Some(k) => k,
        None => return out,
    };
    let base_url = std::env::var("QWEN_BASE_URL")
        .ok().filter(|s| !s.trim().is_empty())
        .unwrap_or_else(|| "https://dashscope-intl.aliyuncs.com/compatible-mode/v1".to_string());
    let spec = match std::env::var("QWEN_INTERNS").ok().filter(|s| !s.trim().is_empty()) {
        Some(s) => s,
        None => return out,
    };
    for entry in spec.split(',') {
        let entry = entry.trim();
        if entry.is_empty() { continue; }
        let (name, model) = match entry.split_once(':') {
            Some((n, m)) => (n.trim(), m.trim()),
            None => {
                tracing::warn!(entry = %entry, "QWEN_INTERNS entry must be name:model, skipping");
                continue;
            }
        };
        if name.is_empty() || model.is_empty() { continue; }
        out.push(ResolvedIntern {
            name: name.to_string(),
            provider_label: "qwen".to_string(),
            description: default_description(name, model),
            base_url: base_url.clone(),
            api_key: api_key.clone(),
            model: model.to_string(),
        });
    }
    out
}

fn openrouter_env_interns() -> Vec<ResolvedIntern> {
    let mut out: Vec<ResolvedIntern> = Vec::new();
    let api_key = match std::env::var("OPENROUTER_API_KEY").ok().filter(|s| !s.trim().is_empty()) {
        Some(k) => k,
        None => return out,
    };
    let base_url = std::env::var("OPENROUTER_BASE_URL")
        .ok().filter(|s| !s.trim().is_empty())
        .unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string());
    let spec = match std::env::var("OPENROUTER_INTERNS").ok().filter(|s| !s.trim().is_empty()) {
        Some(s) => s,
        None => return out,
    };
    for entry in spec.split(',') {
        let entry = entry.trim();
        if entry.is_empty() { continue; }
        let (name, model) = match entry.split_once(':') {
            Some((n, m)) => (n.trim(), m.trim()),
            None => {
                tracing::warn!(entry = %entry, "OPENROUTER_INTERNS entry must be name:model, skipping");
                continue;
            }
        };
        if name.is_empty() || model.is_empty() { continue; }
        out.push(ResolvedIntern {
            name: name.to_string(),
            provider_label: "openrouter".to_string(),
            description: default_description(name, model),
            base_url: base_url.clone(),
            api_key: api_key.clone(),
            model: model.to_string(),
        });
    }
    out
}

fn default_description(name: &str, model: &str) -> String {
    format!(
        "Hand off a focused, well-scoped subtask to the '{name}' intern (model: {model}). \
The intern has bash/read/file_write/file_edit/glob/grep available but cannot spawn further sub-agents. \
Use this for grunt work — boilerplate, mechanical refactors, gathering data, drafting code that you'll review. \
Provide explicit acceptance criteria. Returns the intern's summary + output."
    )
}

fn sanitize_tool_name(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
            out.push(c);
        } else {
            out.push('_');
        }
    }
    if out.len() > 64 { out.truncate(64); }
    out
}