From e60eeb81629674dd919d5d627205b0cf16992c02 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sat, 6 Jun 2026 01:58:17 -0700 Subject: [PATCH] feat(config): add dormant harness profile resolver Add a pure HarnessProfile resolver for provider/model routes while keeping runtime provider/model routing, prompts, tools, auth, context, and persisted config unchanged.\n\nVerification:\n- cargo test -p codewhale-config harness_profile --locked\n- cargo fmt --all --check\n- git diff --check\n- cmp -s CHANGELOG.md crates/tui/CHANGELOG.md\n- ./scripts/release/check-versions.sh\n- ./scripts/release/check-ohos-deps.sh --- CHANGELOG.md | 5 +- config.example.toml | 10 +- crates/config/src/lib.rs | 161 ++++++++++++++++++++++++++++++ crates/tui/CHANGELOG.md | 5 +- docs/HARNESS_PROFILE_CUTLINE.md | 12 ++- docs/V0_9_0_RELEASE_ACCEPTANCE.md | 2 +- 6 files changed, 180 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ebb7d0..98685360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -305,8 +305,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The config crate now carries the v0.9 HarnessPosture data model: `HarnessPosture`, `HarnessProfile`, and typed posture/compaction/tool/safety enums. The schema rejects misspelled posture names or unknown profile keys - instead of silently falling back to `custom`; runtime provider/model posture - selection remains a follow-up (#2693, #2741). + instead of silently falling back to `custom`; a pure resolver can match + provider/model routes for tests and future status plumbing, while runtime + provider/model posture selection remains a follow-up (#2693, #2741, #2728). ### Fixed diff --git a/config.example.toml b/config.example.toml index 398adcbf..5a2782a5 100644 --- a/config.example.toml +++ b/config.example.toml @@ -606,12 +606,12 @@ deepseek_v4_flash_prior = 4.2 fallback_default_prior = 3.8 # ───────────────────────────────────────────────────────────────────────────────── -# Harness Profiles (preview schema; runtime resolver follows in later v0.9 slices) +# Harness Profiles (preview schema; runtime consumption follows later) # ───────────────────────────────────────────────────────────────────────────────── -# Harness profiles let future CodeWhale runtime slices select model-specific prompt, -# context, tool, and subagent posture. v0.9 currently parses and validates this -# schema, but normal Agent and WhaleFlow runs do not silently promote or mutate -# behavior from these profiles yet. +# Harness profiles let future CodeWhale runtime slices select model-specific +# prompt, context, tool, and subagent posture. v0.9 parses, validates, and can +# resolve profiles for tests/status plumbing, but normal Agent and WhaleFlow +# runs do not silently promote or mutate behavior from these profiles yet. # # [[harness_profiles]] # provider_route = "deepseek" diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index ae4609b3..bbad2352 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -475,6 +475,18 @@ pub struct HarnessProfile { pub posture: HarnessPosture, } +impl HarnessProfile { + /// Return true when this profile applies to the provider/model route. + /// + /// This is a pure config helper: matching a profile must not mutate runtime + /// provider selection, prompts, auth, tools, context, or persisted config. + #[must_use] + pub fn matches_route(&self, provider_route: &str, model: &str) -> bool { + provider_routes_equal(&self.provider_route, provider_route) + && wildcard_pattern_matches(&self.model_pattern, model) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ConfigToml { /// TUI-compatible DeepSeek API key. Kept at the root so both `deepseek` @@ -535,6 +547,65 @@ pub struct ConfigToml { pub extras: BTreeMap, } +impl ConfigToml { + /// Resolve the first configured harness profile for a provider/model route. + /// + /// This helper is deliberately dormant for v0.9: callers may display or + /// test the resolved profile, but runtime provider/model routing and prompt + /// shaping remain unchanged until a later, explicit integration slice. + #[must_use] + pub fn resolve_harness_profile( + &self, + provider_route: &str, + model: &str, + ) -> Option<&HarnessProfile> { + self.harness_profiles + .iter() + .find(|profile| profile.matches_route(provider_route, model)) + } +} + +fn provider_routes_equal(expected: &str, actual: &str) -> bool { + match (ProviderKind::parse(expected), ProviderKind::parse(actual)) { + (Some(expected), Some(actual)) => expected == actual, + _ => expected.trim().eq_ignore_ascii_case(actual.trim()), + } +} + +fn wildcard_pattern_matches(pattern: &str, value: &str) -> bool { + wildcard_chars_match( + &pattern.chars().collect::>(), + &value.chars().collect::>(), + ) +} + +fn wildcard_chars_match(pattern: &[char], value: &[char]) -> bool { + let (mut pattern_idx, mut value_idx) = (0, 0); + let mut star_idx: Option = None; + let mut star_value_idx = 0; + + while value_idx < value.len() { + if pattern_idx < pattern.len() + && (pattern[pattern_idx] == '?' || pattern[pattern_idx] == value[value_idx]) + { + pattern_idx += 1; + value_idx += 1; + } else if pattern_idx < pattern.len() && pattern[pattern_idx] == '*' { + star_idx = Some(pattern_idx); + pattern_idx += 1; + star_value_idx = value_idx; + } else if let Some(star) = star_idx { + pattern_idx = star + 1; + star_value_idx += 1; + value_idx = star_value_idx; + } else { + return false; + } + } + + pattern[pattern_idx..].iter().all(|ch| *ch == '*') +} + /// Ordered primary-plus-fallback provider list for future provider routing. /// /// The helper is intentionally dormant: constructing or parsing a chain does @@ -5909,6 +5980,96 @@ safety_posture = "strict" ); } + #[test] + fn harness_profile_matches_provider_alias_and_model_wildcard() { + let profile = HarnessProfile { + provider_route: "xiaomi-mimo".to_string(), + model_pattern: "mimo-v2.?-pro".to_string(), + posture: HarnessPosture::cache_heavy(), + }; + + assert!(profile.matches_route("mimo", "mimo-v2.5-pro")); + assert!(!profile.matches_route("mimo", "mimo-v2.50-pro")); + assert!(!profile.matches_route("deepseek", "mimo-v2.5-pro")); + } + + #[test] + fn resolve_harness_profile_returns_first_matching_profile() { + let config = ConfigToml { + harness_profiles: vec![ + HarnessProfile { + provider_route: "deepseek".to_string(), + model_pattern: "deepseek-v4-flash".to_string(), + posture: HarnessPosture::lean(), + }, + HarnessProfile { + provider_route: "deepseek".to_string(), + model_pattern: "deepseek-v4-*".to_string(), + posture: HarnessPosture::cache_heavy(), + }, + ], + ..ConfigToml::default() + }; + + let flash = config + .resolve_harness_profile("deepseek-cn", "deepseek-v4-flash") + .expect("exact profile should match first"); + assert_eq!(flash.posture.kind, HarnessPostureKind::Lean); + + let pro = config + .resolve_harness_profile("deepseek", "deepseek-v4-pro") + .expect("wildcard profile should match pro model"); + assert_eq!(pro.posture.kind, HarnessPostureKind::CacheHeavy); + } + + #[test] + fn resolve_harness_profile_returns_none_when_route_or_model_misses() { + let config = ConfigToml { + harness_profiles: vec![HarnessProfile { + provider_route: "huggingface".to_string(), + model_pattern: "deepseek-ai/*".to_string(), + posture: HarnessPosture::lean(), + }], + ..ConfigToml::default() + }; + + assert!( + config + .resolve_harness_profile("openrouter", "deepseek-ai/DeepSeek-V4-Pro") + .is_none() + ); + assert!( + config + .resolve_harness_profile("hf", "Qwen/Qwen3.6-Coder") + .is_none() + ); + } + + #[test] + fn resolving_harness_profile_does_not_change_runtime_options() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::Deepseek, + model: Some("deepseek-v4-pro".to_string()), + harness_profiles: vec![HarnessProfile { + provider_route: "deepseek".to_string(), + model_pattern: "deepseek-v4-*".to_string(), + posture: HarnessPosture::lean(), + }], + ..ConfigToml::default() + }; + + let profile = config + .resolve_harness_profile("deepseek", "deepseek-v4-pro") + .expect("profile should resolve for display/future runtime"); + assert_eq!(profile.posture.kind, HarnessPostureKind::Lean); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + assert_eq!(resolved.provider, ProviderKind::Deepseek); + assert_eq!(resolved.model, "deepseek-v4-pro"); + } + #[test] fn harness_posture_kind_rejects_unknown_values() { let err = toml::from_str::( diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 97ebb7d0..98685360 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -305,8 +305,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The config crate now carries the v0.9 HarnessPosture data model: `HarnessPosture`, `HarnessProfile`, and typed posture/compaction/tool/safety enums. The schema rejects misspelled posture names or unknown profile keys - instead of silently falling back to `custom`; runtime provider/model posture - selection remains a follow-up (#2693, #2741). + instead of silently falling back to `custom`; a pure resolver can match + provider/model routes for tests and future status plumbing, while runtime + provider/model posture selection remains a follow-up (#2693, #2741, #2728). ### Fixed diff --git a/docs/HARNESS_PROFILE_CUTLINE.md b/docs/HARNESS_PROFILE_CUTLINE.md index d4e31c60..507c53a3 100644 --- a/docs/HARNESS_PROFILE_CUTLINE.md +++ b/docs/HARNESS_PROFILE_CUTLINE.md @@ -59,10 +59,10 @@ inspiration only after the text distinguishes these stages: No v0.9.0 harness profile should be silently promoted, mutated, or written to a cached-main overlay by the schema/resolver/display lane. -## Smoke Evidence Needed Before Release +## Smoke Evidence -Before v0.9.0 ships with HarnessProfile behavior beyond schema parsing, the -acceptance matrix should record evidence for: +Before v0.9.0 ships with HarnessProfile runtime behavior beyond schema parsing +and pure resolver checks, the acceptance matrix should record evidence for: - DeepSeek V4 resolving to a cache-heavy profile; - Xiaomi MiMo resolving to a cache-heavy profile without sharing DeepSeek auth; @@ -73,5 +73,7 @@ acceptance matrix should record evidence for: constitution separately; - no automatic profile mutation during normal Agent or WhaleFlow runs. -Until that evidence exists, release notes should call HarnessProfile a typed -schema/config foundation rather than an automatic harness creator. +For v0.9.0, pure resolver tests may satisfy the profile-selection evidence, but +status display and runtime use remain deferred until separate PRs wire those +surfaces deliberately. Release notes should still call HarnessProfile a typed +schema/resolver foundation rather than an automatic harness creator. diff --git a/docs/V0_9_0_RELEASE_ACCEPTANCE.md b/docs/V0_9_0_RELEASE_ACCEPTANCE.md index f1e6ae9e..c63c138c 100644 --- a/docs/V0_9_0_RELEASE_ACCEPTANCE.md +++ b/docs/V0_9_0_RELEASE_ACCEPTANCE.md @@ -62,7 +62,7 @@ config source, result, and follow-up issue or PR. | WhaleFlow typed IR, mock executor, replay, TeacherReview, StudentReplay, and cutline docs are tested | WhaleFlow steward | ship | #2821/#2824/#2831/#2833/#2839/#2840/#2841 plus focused local `cargo test -p codewhale-whaleflow --locked`; #2670 closed after `cargo test -p codewhale-whaleflow starlark --locked` passed 7/7 on current stewardship head. | | Live `workflow_run`, worktree application, provider calls, and TraceStore writes are deferred until cancellation/replay/atomicity semantics pass | WhaleFlow steward | defer | #2669 and #2679 remain open for live runtime execution, provider calls, TraceStore writes, Arcee/student replay, and CLI/TUI workflow mode; current v0.9 branch ships mock executor/replay foundations only. | | Model Lab / Hugging Face MVP is included or deferred with release-note wording | model-lab steward | decide | | -| HarnessProfile runtime MVP is deferred; schema/config foundation ships with release-note wording | harness steward | ship foundation / defer runtime | #2844 (`efbcc681a`) documents the cutline; `HarnessPosture` / `HarnessProfile` config schema and strict validation are present; resolver, seed-profile runtime selection, telemetry, and status display remain follow-up work. | +| HarnessProfile runtime MVP is deferred; schema/resolver foundation ships with release-note wording | harness steward | ship foundation / defer runtime | #2844 (`efbcc681a`) documents the cutline; `HarnessPosture` / `HarnessProfile` config schema and strict validation are present; a pure resolver matches provider/model routes without changing runtime behavior; seed-profile runtime selection, telemetry, and status display remain follow-up work. | | `codebase_search` MVP is included or deferred with release-note wording | search steward | decide | | | External memory remains explicit/optional per `WHALEFLOW_EXTERNAL_MEMORY.md` | memory steward | ship | #2842 (`a7052751e`) added the external-memory cutline: optional/explicit workflow node/plugin only, visible state/owner/storage/scope, and no hidden default context substrate. |