litany 0.0.11

A git-backed agent harness
Documentation
//! Per-repo `providers.yaml` — role → (provider, model) assignments
//! frozen at conversation creation (ARCH §4.3).
//!
//! The conversation-repo file carries only the `roles:` section: which
//! provider row name and which model id each role dispatches to — the
//! single home of that pointer (bl-35e2). Endpoint and auth resolve
//! inside brazen at call time (never a harness file, ARCH §4.1); model
//! id validity is the wire's fact, caught at the first live model call
//! (§4.2).
//!
//! A legacy `providers:` or `models:` block (the v0.2 shape) is a hard
//! load error: neither section exists any more — provider rows are
//! brazen's config, and the global models table is retired (bl-35e2) —
//! so a per-repo file carrying one is structurally wrong rather than
//! just noisy. (Phase 1 of the v0.3 layout migration warned; Phase 4
//! escalated to error once the v0.2 template was retired.)

use crate::config::effort::Effort;
use crate::config::error::LoadError;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;

/// Top-level shape of the per-repo `providers.yaml`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
pub struct PerRepoProviders {
    #[serde(default)]
    pub roles: BTreeMap<String, RoleAssignment>,
}

/// One role's assignment: which provider (by brazen row name) and which
/// model (by wire id), plus the role's enabled tools (ARCH §4.3). This
/// pointer is the whole model binding (bl-35e2) — no global table
/// mediates it; id validity is caught at the first live model call
/// (§4.2). Endpoint and auth resolve inside brazen at call time (§4.1 —
/// no `auth_env` / `endpoint_env` here). `tools` selects which tools
/// the role's agent may call (§3.3); omitted or empty means none.
/// `effort` is the role's reasoning-effort level ([`Effort`], §4.3);
/// omitted means none requested — the general path with empty inputs.
/// `priority` asks the provider's priority lane for the role's model
/// calls (§4.3); `false` and omitted are one fact — no lane preference,
/// the provider's default lane — so there is no third state to carry.
/// `max_output_tokens` is the ceiling on ONE model call's output
/// (§4.3, bl-a928) — distinct from the §6 spend budgets and from the
/// §5.2 manifest's `budget_tokens`, which bounds the assembled
/// *prompt*. Per role because how much a role writes is a property of
/// the role: a compactor emits a summary, a worker writes files. Omitted
/// takes the harness default (`canonical::DEFAULT_MAX_TOKENS`) — the
/// general path with empty inputs. It is NOT deferred to a provider
/// row's `body_defaults`: brazen fills only what a request leaves
/// absent, and the canonical request always states this field, so a row
/// naming it never won and never will.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct RoleAssignment {
    pub provider: String,
    pub model: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tools: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub effort: Option<Effort>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub priority: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_output_tokens: Option<u32>,
}

const LEGACY_KEYS: &[&str] = &["providers", "models"];

impl PerRepoProviders {
    /// Parse `providers.yaml` content already in hand — the
    /// governing-config read path (ARCH §2.2: control is read from the
    /// config commit's tree, never from a worktree file). `origin`
    /// labels errors (e.g. `<config-commit>:providers.yaml`).
    pub fn parse(raw: &str, path: &Path) -> Result<Self, LoadError> {
        let doc: serde_yaml_ng::Value =
            serde_yaml_ng::from_str(raw).map_err(|source| LoadError::Yaml {
                path: path.to_path_buf(),
                source,
            })?;

        if let Some(map) = doc.as_mapping() {
            for legacy in LEGACY_KEYS {
                if map.contains_key(*legacy) {
                    return Err(LoadError::Invalid {
                        path: path.to_path_buf(),
                        key: (*legacy).to_string(),
                        message: format!(
                            "{legacy:?} block is retired: provider rows are \
                             brazen's config and models are named directly on \
                             roles; the per-repo file must only carry the \
                             'roles:' section (ARCH §4.1, §4.3)",
                        ),
                    });
                }
            }
        }

        let roles_value = doc
            .as_mapping()
            .and_then(|m| m.get("roles"))
            .cloned()
            .unwrap_or(serde_yaml_ng::Value::Null);
        let roles: BTreeMap<String, RoleAssignment> = if roles_value.is_null() {
            BTreeMap::new()
        } else {
            serde_yaml_ng::from_value(roles_value).map_err(|source| LoadError::Yaml {
                path: path.to_path_buf(),
                source,
            })?
        };

        Ok(Self { roles })
    }
}

#[cfg(test)]
mod tests;