| 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 | |
| 10 | use std::sync::OnceLock; |
| 11 | |
| 12 | use serde::{Deserialize, Serialize}; |
| 13 | |
| 14 | use 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")] |
| 25 | pub 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")] |
| 42 | pub 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")] |
| 52 | pub 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")] |
| 62 | pub 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)] |
| 72 | pub 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 | |
| 93 | impl 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 | |
| 106 | impl 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)] |
| 137 | pub 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 | |
| 148 | impl 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] |
| 165 | pub 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 | |
| 208 | fn 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 | |
| 215 | fn 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 | |
| 222 | fn 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 | } |
| 248 |