tandem-server 0.6.0

HTTP server for Tandem engine APIs
use serde_json::Value;

pub(crate) fn resolve_identity_block(config: &Value, agent_name: Option<&str>) -> Option<String> {
    let allow_agent_override = agent_name
        .map(|name| !matches!(name, "compaction" | "title" | "summary"))
        .unwrap_or(false);
    let legacy_bot_name = config
        .get("bot_name")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|v| !v.is_empty());
    let bot_name = config
        .get("identity")
        .and_then(|identity| identity.get("bot"))
        .and_then(|bot| bot.get("canonical_name"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|v| !v.is_empty())
        .or(legacy_bot_name)
        .unwrap_or("Tandem");

    let default_profile = config
        .get("identity")
        .and_then(|identity| identity.get("personality"))
        .and_then(|personality| personality.get("default"));
    let default_preset = default_profile
        .and_then(|profile| profile.get("preset"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|v| !v.is_empty())
        .unwrap_or("balanced");
    let default_custom = default_profile
        .and_then(|profile| profile.get("custom_instructions"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|v| !v.is_empty())
        .map(ToString::to_string);
    let legacy_persona = config
        .get("persona")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|v| !v.is_empty())
        .map(ToString::to_string);

    let per_agent_profile = if allow_agent_override {
        agent_name.and_then(|name| {
            config
                .get("identity")
                .and_then(|identity| identity.get("personality"))
                .and_then(|personality| personality.get("per_agent"))
                .and_then(|per_agent| per_agent.get(name))
        })
    } else {
        None
    };
    let preset = per_agent_profile
        .and_then(|profile| profile.get("preset"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|v| !v.is_empty())
        .unwrap_or(default_preset);
    let custom = per_agent_profile
        .and_then(|profile| profile.get("custom_instructions"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|v| !v.is_empty())
        .map(ToString::to_string)
        .or(default_custom)
        .or(legacy_persona);

    let mut lines = vec![
        format!("You are {bot_name}, an AI assistant."),
        personality_preset_text(preset).to_string(),
    ];
    if let Some(custom) = custom {
        lines.push(format!("Additional personality instructions: {custom}"));
    }
    Some(lines.join("\n"))
}

pub(crate) fn build_memory_scope_block(
    session_id: &str,
    project_id: Option<&str>,
    workspace_root: Option<&str>,
) -> String {
    let mut lines = vec![
        "<memory_scope>".to_string(),
        format!("- current_session_id: {}", session_id),
    ];
    if let Some(project_id) = project_id.map(str::trim).filter(|value| !value.is_empty()) {
        lines.push(format!("- current_project_id: {}", project_id));
    }
    if let Some(workspace_root) = workspace_root
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        lines.push(format!("- workspace_root: {}", workspace_root));
    }
    lines.push(
        "- default_memory_search_behavior: search current session, then current project/workspace, then global memory"
            .to_string(),
    );
    lines.push(
        "- use memory_search without IDs for normal recall; only pass tier/session_id/project_id when narrowing scope"
            .to_string(),
    );
    lines.push(
        "- when memory is sparse or stale, inspect the workspace with glob, grep, and read"
            .to_string(),
    );
    lines.push("</memory_scope>".to_string());
    lines.join("\n")
}

pub(crate) fn build_kb_grounding_block(
    policy: &tandem_core::KnowledgebaseGroundingPolicy,
) -> String {
    let servers = if policy.server_names.is_empty() {
        "enabled knowledgebase MCP".to_string()
    } else {
        policy.server_names.join(", ")
    };
    let patterns = if policy.tool_patterns.is_empty() {
        "configured KB MCP tools".to_string()
    } else {
        policy.tool_patterns.join(", ")
    };
    let preferred_tools = kb_grounding_preferred_tools(policy);
    [
        "<knowledgebase_grounding_policy>".to_string(),
        format!("- required: {}", policy.required),
        format!("- strict: {}", policy.strict),
        format!("- servers: {}", servers),
        format!("- tool_patterns: {}", patterns),
        format!(
            "- preferred_question_tools: {}",
            preferred_tools.join(", ")
        ),
        "- For factual/project/product/channel questions, answer from the enabled KB MCP for this channel before using model knowledge, memory, or general chat.".to_string(),
        "- First choice: call the KB MCP `answer_question` tool with the user's question when that tool is available.".to_string(),
        "- Fallback: call the KB MCP search tool, then fetch the full matching document with `get_document` before answering.".to_string(),
        "- Do not answer from search result snippets alone when a full document tool is available.".to_string(),
        "- Use only the KB MCP tools listed by this policy for KB evidence; do not switch to unrelated MCPs or built-in docs search for this channel's KB questions.".to_string(),
        "- If the KB has no matching evidence, say `I do not see that in the connected knowledgebase.` instead of relying on model memory.".to_string(),
        "- When strict grounding is enabled, answer only from retrieved KB evidence and do not add external product instructions, inferred policy, or best-practice guidance.".to_string(),
        "</knowledgebase_grounding_policy>".to_string(),
    ]
    .join("\n")
}

fn personality_preset_text(preset: &str) -> &'static str {
    match preset {
        "concise" => {
            "Default style: concise and high-signal. Prefer short direct responses unless detail is requested."
        }
        "friendly" => {
            "Default style: friendly and supportive while staying technically rigorous and concrete."
        }
        "mentor" => {
            "Default style: mentor-like. Explain decisions and tradeoffs clearly when complexity is non-trivial."
        }
        "critical" => {
            "Default style: critical and risk-first. Surface failure modes and assumptions early."
        }
        _ => {
            "Default style: balanced, pragmatic, and factual. Focus on concrete outcomes and actionable guidance."
        }
    }
}

fn kb_grounding_preferred_tools(policy: &tandem_core::KnowledgebaseGroundingPolicy) -> Vec<String> {
    let mut tools = Vec::new();
    if !policy.server_names.is_empty() {
        for server in &policy.server_names {
            let namespace = mcp_namespace_segment_for_prompt(server);
            tools.push(format!("mcp.{namespace}.answer_question"));
            tools.push(format!("mcp.{namespace}.search_docs"));
            tools.push(format!("mcp.{namespace}.get_document"));
        }
    }
    if tools.is_empty() {
        tools.push("mcp.<knowledgebase>.answer_question".to_string());
        tools.push("mcp.<knowledgebase>.search_docs".to_string());
        tools.push("mcp.<knowledgebase>.get_document".to_string());
    }
    tools
}

fn mcp_namespace_segment_for_prompt(name: &str) -> String {
    let mut out = String::new();
    let mut previous_underscore = false;
    for ch in name.trim().chars() {
        if ch.is_ascii_alphanumeric() {
            out.push(ch.to_ascii_lowercase());
            previous_underscore = false;
        } else if !previous_underscore {
            out.push('_');
            previous_underscore = true;
        }
    }
    let cleaned = out.trim_matches('_');
    if cleaned.is_empty() {
        "server".to_string()
    } else {
        cleaned.to_string()
    }
}