tandem-server 0.4.18

HTTP server for Tandem engine APIs
Documentation
use super::*;
use serde_json::Value;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AutomationQualityMode {
    StrictResearchV1,
    Legacy,
}

impl AutomationQualityMode {
    pub(crate) fn stable_key(self) -> &'static str {
        match self {
            Self::StrictResearchV1 => "strict_research_v1",
            Self::Legacy => "legacy",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AutomationQualityModeResolution {
    pub(crate) requested: Option<AutomationQualityMode>,
    pub(crate) effective: AutomationQualityMode,
    pub(crate) legacy_rollback_enabled: bool,
}

pub(crate) fn enforcement_requires_external_sources(
    enforcement: &crate::AutomationOutputEnforcement,
) -> bool {
    enforcement
        .required_evidence
        .iter()
        .any(|item| item == "external_sources")
        || enforcement
            .required_tools
            .iter()
            .any(|tool| tool == "websearch")
        || enforcement
            .prewrite_gates
            .iter()
            .any(|gate| gate == "successful_web_research")
}

fn automation_node_legacy_builder(
    node: &AutomationFlowNode,
) -> Option<&serde_json::Map<String, Value>> {
    node.metadata
        .as_ref()
        .and_then(|metadata| metadata.get("builder"))
        .and_then(Value::as_object)
}

fn automation_node_legacy_web_research_expected(node: &AutomationFlowNode) -> bool {
    automation_node_legacy_builder(node)
        .and_then(|builder| builder.get("web_research_expected"))
        .and_then(Value::as_bool)
        .unwrap_or(false)
}

fn automation_node_legacy_required_tools(node: &AutomationFlowNode) -> Vec<String> {
    automation_node_legacy_builder(node)
        .and_then(|builder| builder.get("required_tools"))
        .and_then(Value::as_array)
        .map(|rows| {
            rows.iter()
                .filter_map(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .map(str::to_string)
                .collect()
        })
        .unwrap_or_default()
}

fn parse_quality_mode(value: &str) -> Option<AutomationQualityMode> {
    match value.trim().to_ascii_lowercase().as_str() {
        "strict" | "strict_research_v1" | "strict-research-v1" => {
            Some(AutomationQualityMode::StrictResearchV1)
        }
        "legacy" => Some(AutomationQualityMode::Legacy),
        _ => None,
    }
}

fn requested_quality_mode_from_metadata(
    metadata: Option<&serde_json::Map<String, Value>>,
) -> Option<AutomationQualityMode> {
    metadata.and_then(|metadata| {
        metadata
            .get("quality_mode")
            .or_else(|| metadata.get("qualityMode"))
            .and_then(Value::as_str)
            .and_then(parse_quality_mode)
            .or_else(|| {
                metadata
                    .get("builder")
                    .and_then(Value::as_object)
                    .and_then(|builder| builder.get("quality_mode"))
                    .and_then(Value::as_str)
                    .and_then(parse_quality_mode)
            })
    })
}

pub(crate) fn automation_quality_mode_resolution_from_metadata(
    metadata: Option<&serde_json::Map<String, Value>>,
    strict_default: bool,
    legacy_rollback_enabled: bool,
) -> AutomationQualityModeResolution {
    let requested = requested_quality_mode_from_metadata(metadata);
    let effective = match requested {
        Some(AutomationQualityMode::Legacy) if !legacy_rollback_enabled => {
            AutomationQualityMode::StrictResearchV1
        }
        Some(mode) => mode,
        None => {
            if crate::config::env::resolve_automation_strict_research_quality() && strict_default {
                AutomationQualityMode::StrictResearchV1
            } else {
                AutomationQualityMode::Legacy
            }
        }
    };
    AutomationQualityModeResolution {
        requested,
        effective,
        legacy_rollback_enabled,
    }
}

pub(crate) fn automation_quality_mode_from_metadata(
    metadata: Option<&serde_json::Map<String, Value>>,
    strict_default: bool,
) -> AutomationQualityMode {
    automation_quality_mode_resolution_from_metadata(
        metadata,
        strict_default,
        crate::config::env::resolve_automation_quality_legacy_rollback_enabled(),
    )
    .effective
}

pub(crate) fn automation_node_quality_mode(node: &AutomationFlowNode) -> AutomationQualityMode {
    automation_quality_mode_from_metadata(node.metadata.as_ref().and_then(Value::as_object), true)
}

pub(crate) fn automation_node_quality_mode_resolution(
    node: &AutomationFlowNode,
) -> AutomationQualityModeResolution {
    automation_quality_mode_resolution_from_metadata(
        node.metadata.as_ref().and_then(Value::as_object),
        true,
        crate::config::env::resolve_automation_quality_legacy_rollback_enabled(),
    )
}

pub(crate) fn automation_node_is_strict_quality(node: &AutomationFlowNode) -> bool {
    matches!(
        automation_node_quality_mode(node),
        AutomationQualityMode::StrictResearchV1
    )
}

pub(crate) fn automation_node_output_enforcement(
    node: &AutomationFlowNode,
) -> crate::AutomationOutputEnforcement {
    let mut enforcement = node
        .output_contract
        .as_ref()
        .and_then(|contract| contract.enforcement.clone())
        .unwrap_or_default();
    let validator_kind = automation_output_validator_kind(node);
    let legacy_required_tools = automation_node_legacy_required_tools(node);
    let legacy_web_research_expected = automation_node_legacy_web_research_expected(node);
    let is_research_contract =
        validator_kind == crate::AutomationOutputValidatorKind::ResearchBrief;
    let code_patch_contract = node
        .output_contract
        .as_ref()
        .map(|contract| contract.kind.trim().to_ascii_lowercase())
        .is_some_and(|kind| kind == "code_patch");
    let contract_kind = node
        .output_contract
        .as_ref()
        .map(|contract| contract.kind.trim().to_ascii_lowercase())
        .unwrap_or_else(|| "structured_json".to_string());
    let citations_contract = contract_kind == "citations";
    let validation_profile = enforcement
        .validation_profile
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(|value| value.to_ascii_lowercase())
        .unwrap_or_else(|| {
            if validator_kind == crate::AutomationOutputValidatorKind::ReviewDecision {
                "review_gate".to_string()
            } else if validator_kind == crate::AutomationOutputValidatorKind::CodePatch {
                "code_change".to_string()
            } else if node.node_id == "collect_inputs" {
                "artifact_only".to_string()
            } else if code_patch_contract {
                "code_change".to_string()
            } else if legacy_web_research_expected
                || legacy_required_tools.iter().any(|tool| tool == "websearch")
            {
                "external_research".to_string()
            } else if automation_node_is_research_finalize(node)
                || ((is_research_contract || citations_contract)
                    && matches!(
                        contract_kind.as_str(),
                        "brief" | "report_markdown" | "text_summary"
                    ))
            {
                "research_synthesis".to_string()
            } else if legacy_required_tools.iter().any(|tool| tool == "read")
                || is_research_contract
                || citations_contract
            {
                "local_research".to_string()
            } else {
                "artifact_only".to_string()
            }
        });
    enforcement.validation_profile = Some(validation_profile.clone());
    let is_local_research = validation_profile == "local_research";
    let is_external_research = validation_profile == "external_research";
    let is_research_synthesis = validation_profile == "research_synthesis";

    if enforcement.required_tools.is_empty() {
        enforcement.required_tools = legacy_required_tools.clone();
        if is_local_research && !enforcement.required_tools.iter().any(|tool| tool == "glob") {
            enforcement.required_tools.push("glob".to_string());
        }
        if is_local_research && !enforcement.required_tools.iter().any(|tool| tool == "read") {
            enforcement.required_tools.push("read".to_string());
        }
        if (is_external_research || legacy_web_research_expected)
            && !enforcement
                .required_tools
                .iter()
                .any(|tool| tool == "websearch")
        {
            enforcement.required_tools.push("websearch".to_string());
        }
    }
    if code_patch_contract && !enforcement.required_tools.iter().any(|tool| tool == "read") {
        enforcement.required_tools.push("read".to_string());
    }

    if enforcement.required_evidence.is_empty() {
        if is_local_research
            || (is_research_synthesis
                && enforcement.required_tools.iter().any(|tool| tool == "read"))
        {
            enforcement
                .required_evidence
                .push("local_source_reads".to_string());
        }
        if is_external_research
            || legacy_web_research_expected
            || (is_research_synthesis
                && enforcement
                    .required_tools
                    .iter()
                    .any(|tool| tool == "websearch"))
            || enforcement
                .required_tools
                .iter()
                .any(|tool| tool == "websearch")
        {
            enforcement
                .required_evidence
                .push("external_sources".to_string());
        }
    }
    if code_patch_contract
        && !enforcement
            .required_evidence
            .iter()
            .any(|value| value == "local_source_reads")
    {
        enforcement
            .required_evidence
            .push("local_source_reads".to_string());
    }

    if enforcement.required_sections.is_empty() && is_research_contract {
        if is_external_research {
            enforcement.required_sections.push("citations".to_string());
        } else if is_research_synthesis && enforcement_requires_external_sources(&enforcement) {
            enforcement.required_sections.push("citations".to_string());
        }
    }

    if enforcement.prewrite_gates.is_empty() && automation_node_required_output_path(node).is_some()
    {
        if is_local_research {
            enforcement
                .prewrite_gates
                .push("workspace_inspection".to_string());
            enforcement
                .prewrite_gates
                .push("concrete_reads".to_string());
        }
        if is_external_research && enforcement_requires_external_sources(&enforcement) {
            enforcement
                .prewrite_gates
                .push("successful_web_research".to_string());
        }
    }
    if code_patch_contract
        && automation_node_required_output_path(node).is_some()
        && !enforcement
            .prewrite_gates
            .iter()
            .any(|gate| gate == "workspace_inspection")
    {
        enforcement
            .prewrite_gates
            .push("workspace_inspection".to_string());
    }
    if code_patch_contract
        && automation_node_required_output_path(node).is_some()
        && !enforcement
            .prewrite_gates
            .iter()
            .any(|gate| gate == "concrete_reads")
    {
        enforcement
            .prewrite_gates
            .push("concrete_reads".to_string());
    }

    if enforcement.retry_on_missing.is_empty() {
        enforcement
            .retry_on_missing
            .extend(enforcement.required_evidence.iter().cloned());
        enforcement
            .retry_on_missing
            .extend(enforcement.required_sections.iter().cloned());
        enforcement
            .retry_on_missing
            .extend(enforcement.prewrite_gates.iter().cloned());
    }

    if enforcement.terminal_on.is_empty() && !enforcement.retry_on_missing.is_empty() {
        enforcement.terminal_on.extend([
            "tool_unavailable".to_string(),
            "repair_budget_exhausted".to_string(),
        ]);
    }

    if enforcement.repair_budget.is_none()
        && (!enforcement.retry_on_missing.is_empty() || !enforcement.required_tools.is_empty())
    {
        enforcement.repair_budget = Some(tandem_core::prewrite_repair_retry_max_attempts() as u32);
    }

    if enforcement.session_text_recovery.is_none() {
        enforcement.session_text_recovery = Some(
            if !enforcement.prewrite_gates.is_empty()
                || enforcement
                    .required_sections
                    .iter()
                    .any(|item| item == "files_reviewed")
            {
                "require_prewrite_satisfied".to_string()
            } else {
                "allow".to_string()
            },
        );
    }

    enforcement.required_tools = super::super::normalize_non_empty_list(enforcement.required_tools);
    enforcement.required_evidence =
        super::super::normalize_non_empty_list(enforcement.required_evidence);
    enforcement.required_sections =
        super::super::normalize_non_empty_list(enforcement.required_sections);
    enforcement.prewrite_gates = super::super::normalize_non_empty_list(enforcement.prewrite_gates);
    enforcement.retry_on_missing =
        super::super::normalize_non_empty_list(enforcement.retry_on_missing);
    enforcement.terminal_on = super::super::normalize_non_empty_list(enforcement.terminal_on);
    enforcement
}