Skip to main content

codewhale_config/
harness.rs

1//! Harness posture + profile config types (#3311).
2//!
3//! A *harness posture* is the agent-shaping policy (sub-agent cap, tool
4//! surface, compaction/cache strategy, safety stance); a *harness profile*
5//! binds a posture to a provider route + model pattern. Extracted verbatim
6//! from lib.rs to separate this agent-posture domain from the rest of the
7//! config schema; re-exported at the crate root so existing paths are
8//! unchanged. Behavior is identical.
9
10use std::sync::OnceLock;
11
12use serde::{Deserialize, Serialize};
13
14use crate::ProviderKind;
15
16/// Kinds of built-in harness postures.
17///
18/// A posture names the runtime strategy CodeWhale should use for a
19/// provider/model route: how much context to preload, how aggressively to lean
20/// on sub-agents, and how to balance prompt-cache stability against quick
21/// exploration. Runtime selection is wired in later v0.9 slices; this config
22/// model intentionally keeps the policy data explicit first.
23#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
24#[serde(rename_all = "kebab-case")]
25pub enum HarnessPostureKind {
26    /// Full-featured default: rich constitution, broad tool catalog, and normal
27    /// sub-agent posture.
28    #[default]
29    Standard,
30    /// Cache-heavy: deeper prompt layering and prefix-cache-oriented context.
31    CacheHeavy,
32    /// Lean: smaller starting context, faster compaction, and stronger
33    /// exploration/delegation bias.
34    Lean,
35    /// User-defined posture assembled from explicit knobs below.
36    Custom,
37}
38
39/// How this posture should approach compaction and prompt-cache stability.
40#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
41#[serde(rename_all = "kebab-case")]
42pub enum HarnessCompactionStrategy {
43    #[default]
44    Default,
45    PrefixCache,
46    Aggressive,
47}
48
49/// Which tool catalog shape this posture prefers.
50#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
51#[serde(rename_all = "kebab-case")]
52pub enum HarnessToolSurface {
53    #[default]
54    Full,
55    ReadOnly,
56    Auto,
57}
58
59/// Safety posture applied when the runtime consumes a harness profile.
60#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
61#[serde(rename_all = "kebab-case")]
62pub enum HarnessSafetyPosture {
63    #[default]
64    Standard,
65    Strict,
66    Permissive,
67}
68
69/// A concrete harness posture with policy knobs.
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
71#[serde(deny_unknown_fields)]
72pub struct HarnessPosture {
73    /// Named posture kind.
74    #[serde(default)]
75    pub kind: HarnessPostureKind,
76    /// Maximum number of concurrent sub-agents (0 = runtime default).
77    #[serde(default)]
78    pub max_subagents: usize,
79    /// Prefer search-based/on-demand context over always-on documentation.
80    #[serde(default)]
81    pub prefer_codebase_search: bool,
82    /// Compaction and prompt-cache strategy.
83    #[serde(default)]
84    pub compaction_strategy: HarnessCompactionStrategy,
85    /// Preferred tool catalog shape.
86    #[serde(default)]
87    pub tool_surface: HarnessToolSurface,
88    /// Safety posture for runtime consumers.
89    #[serde(default)]
90    pub safety_posture: HarnessSafetyPosture,
91}
92
93impl Default for HarnessPosture {
94    fn default() -> Self {
95        Self {
96            kind: HarnessPostureKind::Standard,
97            max_subagents: 0,
98            prefer_codebase_search: false,
99            compaction_strategy: HarnessCompactionStrategy::default(),
100            tool_surface: HarnessToolSurface::default(),
101            safety_posture: HarnessSafetyPosture::default(),
102        }
103    }
104}
105
106impl HarnessPosture {
107    /// A cache-heavy posture tuned for DeepSeek V4 / MiMo-style models.
108    #[must_use]
109    pub fn cache_heavy() -> Self {
110        Self {
111            kind: HarnessPostureKind::CacheHeavy,
112            max_subagents: 10,
113            prefer_codebase_search: false,
114            compaction_strategy: HarnessCompactionStrategy::PrefixCache,
115            tool_surface: HarnessToolSurface::Full,
116            safety_posture: HarnessSafetyPosture::Standard,
117        }
118    }
119
120    /// A lean posture for smaller-context or weaker tool-use models.
121    #[must_use]
122    pub fn lean() -> Self {
123        Self {
124            kind: HarnessPostureKind::Lean,
125            max_subagents: 20,
126            prefer_codebase_search: true,
127            compaction_strategy: HarnessCompactionStrategy::Aggressive,
128            tool_surface: HarnessToolSurface::Full,
129            safety_posture: HarnessSafetyPosture::Standard,
130        }
131    }
132}
133
134/// A harness profile binds a posture to a provider route and model pattern.
135#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
136#[serde(deny_unknown_fields)]
137pub struct HarnessProfile {
138    /// Provider route this profile applies to, e.g. "deepseek" or
139    /// "xiaomi-mimo".
140    pub provider_route: String,
141    /// Regex or glob pattern for model names, e.g. "deepseek-v4.*".
142    pub model_pattern: String,
143    /// The posture to apply.
144    #[serde(default)]
145    pub posture: HarnessPosture,
146}
147
148impl HarnessProfile {
149    /// Return true when this profile applies to the provider/model route.
150    ///
151    /// This is a pure config helper: matching a profile must not mutate runtime
152    /// provider selection, prompts, auth, tools, context, or persisted config.
153    #[must_use]
154    pub fn matches_route(&self, provider_route: &str, model: &str) -> bool {
155        provider_routes_equal(&self.provider_route, provider_route)
156            && wildcard_pattern_matches(&self.model_pattern, model)
157    }
158}
159
160/// Built-in profile seeds for common provider/model families.
161///
162/// User-configured profiles are always checked first; these seeds only provide
163/// a stable resolver result when config has no narrower match.
164#[must_use]
165pub fn built_in_harness_profiles() -> &'static [HarnessProfile] {
166    static PROFILES: OnceLock<Vec<HarnessProfile>> = OnceLock::new();
167    PROFILES.get_or_init(|| {
168        vec![
169            HarnessProfile {
170                provider_route: "deepseek".to_string(),
171                model_pattern: "deepseek-v4*".to_string(),
172                posture: HarnessPosture::cache_heavy(),
173            },
174            HarnessProfile {
175                provider_route: "xiaomi-mimo".to_string(),
176                model_pattern: "mimo-v2.5*".to_string(),
177                posture: HarnessPosture::cache_heavy(),
178            },
179            HarnessProfile {
180                provider_route: "arcee".to_string(),
181                model_pattern: "trinity-large-thinking".to_string(),
182                posture: HarnessPosture::cache_heavy(),
183            },
184            HarnessProfile {
185                provider_route: "huggingface".to_string(),
186                model_pattern: "*".to_string(),
187                posture: HarnessPosture::lean(),
188            },
189            HarnessProfile {
190                provider_route: "sglang".to_string(),
191                model_pattern: "*".to_string(),
192                posture: HarnessPosture::lean(),
193            },
194            HarnessProfile {
195                provider_route: "vllm".to_string(),
196                model_pattern: "*".to_string(),
197                posture: HarnessPosture::lean(),
198            },
199            HarnessProfile {
200                provider_route: "ollama".to_string(),
201                model_pattern: "*".to_string(),
202                posture: HarnessPosture::lean(),
203            },
204        ]
205    })
206}
207
208fn provider_routes_equal(expected: &str, actual: &str) -> bool {
209    match (ProviderKind::parse(expected), ProviderKind::parse(actual)) {
210        (Some(expected), Some(actual)) => expected == actual,
211        _ => expected.trim().eq_ignore_ascii_case(actual.trim()),
212    }
213}
214
215fn wildcard_pattern_matches(pattern: &str, value: &str) -> bool {
216    wildcard_chars_match(
217        &pattern.chars().collect::<Vec<_>>(),
218        &value.chars().collect::<Vec<_>>(),
219    )
220}
221
222fn wildcard_chars_match(pattern: &[char], value: &[char]) -> bool {
223    let (mut pattern_idx, mut value_idx) = (0, 0);
224    let mut star_idx: Option<usize> = None;
225    let mut star_value_idx = 0;
226
227    while value_idx < value.len() {
228        if pattern_idx < pattern.len()
229            && (pattern[pattern_idx] == '?' || pattern[pattern_idx] == value[value_idx])
230        {
231            pattern_idx += 1;
232            value_idx += 1;
233        } else if pattern_idx < pattern.len() && pattern[pattern_idx] == '*' {
234            star_idx = Some(pattern_idx);
235            pattern_idx += 1;
236            star_value_idx = value_idx;
237        } else if let Some(star) = star_idx {
238            pattern_idx = star + 1;
239            star_value_idx += 1;
240            value_idx = star_value_idx;
241        } else {
242            return false;
243        }
244    }
245
246    pattern[pattern_idx..].iter().all(|ch| *ch == '*')
247}