Skip to main content

vfa_tui/models/
model_policy.rs

1//! Model-policy data model: the resolved per-agent/per-harness model and
2//! reasoning-effort assignments generated by `scripts/model-policy.mjs`
3//! (`catalog/model-assignments.json`), plus the command builder for mutating
4//! the policy through that script.
5//!
6//! The TUI never resolves policy precedence itself — the Node script is the
7//! single source of truth. The TUI reads the generated assignments index for
8//! display and shells out to the script (dry-run first) for every mutation,
9//! mirroring the export flow.
10
11use std::collections::HashMap;
12
13use serde::{Deserialize, Serialize};
14
15use crate::error::TuiError;
16use crate::security::validate::validate_argument;
17
18/// Which policy fields a harness's executable format can express.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct HarnessCapability {
22    pub model: bool,
23    pub reasoning_effort: bool,
24}
25
26/// One resolved agent x harness assignment. `None` means "auto" — the managed
27/// field is absent and the harness runtime default applies.
28///
29/// `model_provider` is codex-only: the `catalog/model-registry.json`
30/// namespace that classified `model` (e.g. "ollama", "openrouter"), or
31/// `None` for the default provider (OpenAI first-party) or non-codex
32/// harnesses. `reasoning_effort` is projected as codex.toml
33/// `model_reasoning_effort` or, for claude-code, the subagent frontmatter
34/// `effort:` key.
35///
36/// `model_fallback_from` / `model_warning` surface provider lifecycle
37/// (`status: "retiring"|"retired"` in `catalog/model-registry.json`):
38/// `model_fallback_from` is the originally-pinned model id when
39/// `scripts/model-policy.mjs` substituted a "retired" model's documented
40/// successor (chain-followed) in `model`, else `None`. `model_warning` is
41/// the fully-composed, ready-to-display warning text for a "retiring" pin or
42/// a "retired" substitution, else `None`. Both arrive pre-composed from the
43/// Node policy engine — the TUI never derives lifecycle logic itself, it
44/// only renders what the engine already decided.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct ModelAssignment {
48    pub agent_id: String,
49    pub harness: String,
50    pub model: Option<String>,
51    #[serde(default)]
52    pub model_provider: Option<String>,
53    #[serde(default)]
54    pub model_fallback_from: Option<String>,
55    #[serde(default)]
56    pub model_warning: Option<String>,
57    pub reasoning_effort: Option<String>,
58    pub model_source: String,
59    pub reasoning_source: String,
60}
61
62/// Top-level structure of catalog/model-assignments.json.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct ModelAssignments {
66    pub manifest_version: u32,
67    pub generated_by: String,
68    pub policy_sha256: String,
69    pub capabilities: HashMap<String, HarnessCapability>,
70    pub assignments: Vec<ModelAssignment>,
71}
72
73impl ModelAssignments {
74    /// All assignments for one agent, in stable harness order.
75    pub fn for_agent(&self, agent_id: &str) -> Vec<&ModelAssignment> {
76        self.assignments
77            .iter()
78            .filter(|a| a.agent_id == agent_id)
79            .collect()
80    }
81}
82
83/// Harnesses the policy engine can project model config into today.
84/// Mirrors HARNESS_CAPABILITIES in scripts/model-policy.mjs — the script is
85/// authoritative; this list only drives UI cycling order.
86pub const CAPABLE_HARNESSES: &[&str] = &["codex", "claude-code", "cursor"];
87
88/// Reasoning-effort UI cycle. "auto" clears the field (harness default).
89/// Union vocabulary across harnesses (codex, claude-code); the verified
90/// per-harness/per-model subset lives in catalog/model-registry.json and is
91/// enforced by scripts/model-policy.mjs, not here.
92pub const REASONING_EFFORTS: &[&str] = &[
93    "auto", "none", "minimal", "low", "medium", "high", "xhigh", "max",
94];
95
96/// Targeting scope for a model-policy batch operation.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum ModelScope {
99    /// Every agent in the catalog.
100    All,
101    /// Every agent of one provider.
102    Provider(String),
103    /// Every agent of one install role.
104    Role(String),
105    /// A single agent.
106    Agent(String),
107}
108
109impl ModelScope {
110    /// Render the `--scope` argument value understood by model-policy.mjs.
111    pub fn to_flag(&self) -> String {
112        match self {
113            ModelScope::All => "all".to_string(),
114            ModelScope::Provider(id) => format!("provider={id}"),
115            ModelScope::Role(id) => format!("role={id}"),
116            ModelScope::Agent(id) => format!("agent={id}"),
117        }
118    }
119
120    /// Human-readable label for the builder form.
121    pub fn display(&self) -> String {
122        match self {
123            ModelScope::All => "All agents".to_string(),
124            ModelScope::Provider(id) => format!("Provider: {id}"),
125            ModelScope::Role(id) => format!("Role: {id}"),
126            ModelScope::Agent(id) => format!("Agent: {id}"),
127        }
128    }
129}
130
131/// A model-policy mutation to be executed as a subprocess:
132/// `node scripts/model-policy.mjs set <args>`.
133#[derive(Debug, Clone)]
134pub struct ModelPolicyCommand {
135    pub scope: ModelScope,
136    pub harness: String,
137    /// Model name or "auto"; `None` leaves the model field of the rule untouched.
138    pub model: Option<String>,
139    /// Reasoning effort or "auto"; `None` leaves the field untouched.
140    pub reasoning: Option<String>,
141    pub dry_run: bool,
142}
143
144impl ModelPolicyCommand {
145    /// Validate all user-provided arguments against shell metacharacters.
146    /// Semantic validation (per-harness model patterns, capability checks)
147    /// stays in scripts/model-policy.mjs — the single source of truth.
148    pub fn validate(&self) -> Result<(), TuiError> {
149        match &self.scope {
150            ModelScope::All => {}
151            ModelScope::Provider(id) | ModelScope::Role(id) | ModelScope::Agent(id) => {
152                validate_argument(id)?;
153            }
154        }
155        validate_argument(&self.harness)?;
156        if let Some(model) = &self.model {
157            validate_argument(model)?;
158        }
159        if let Some(reasoning) = &self.reasoning {
160            validate_argument(reasoning)?;
161        }
162        if self.model.is_none() && self.reasoning.is_none() {
163            return Err(TuiError::ValidationRejected {
164                value: "<empty>".to_string(),
165                rule: "set a model and/or a reasoning effort".to_string(),
166            });
167        }
168        Ok(())
169    }
170
171    /// Build the argument array for subprocess invocation (after the script path).
172    pub fn to_args(&self) -> Vec<String> {
173        let mut args = vec![
174            "set".to_string(),
175            "--scope".to_string(),
176            self.scope.to_flag(),
177            "--harness".to_string(),
178            self.harness.clone(),
179        ];
180        if let Some(model) = &self.model {
181            args.push("--model".to_string());
182            args.push(model.clone());
183        }
184        if let Some(reasoning) = &self.reasoning {
185            args.push("--reasoning".to_string());
186            args.push(reasoning.clone());
187        }
188        if self.dry_run {
189            args.push("--dry-run".to_string());
190        }
191        args
192    }
193
194    /// Render the full command string for display/preview.
195    pub fn display_command(&self) -> String {
196        format!("node scripts/model-policy.mjs {}", self.to_args().join(" "))
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    fn cmd(scope: ModelScope) -> ModelPolicyCommand {
205        ModelPolicyCommand {
206            scope,
207            harness: "codex".to_string(),
208            model: Some("gpt-5.4".to_string()),
209            reasoning: Some("high".to_string()),
210            dry_run: true,
211        }
212    }
213
214    #[test]
215    fn to_args_all_scope() {
216        let args = cmd(ModelScope::All).to_args();
217        assert_eq!(
218            args,
219            vec![
220                "set",
221                "--scope",
222                "all",
223                "--harness",
224                "codex",
225                "--model",
226                "gpt-5.4",
227                "--reasoning",
228                "high",
229                "--dry-run",
230            ]
231        );
232    }
233
234    #[test]
235    fn to_args_role_scope_without_dry_run() {
236        let mut c = cmd(ModelScope::Role("cloud-security-engineer".to_string()));
237        c.dry_run = false;
238        c.reasoning = None;
239        let args = c.to_args();
240        assert!(args.contains(&"role=cloud-security-engineer".to_string()));
241        assert!(!args.contains(&"--dry-run".to_string()));
242        assert!(!args.contains(&"--reasoning".to_string()));
243    }
244
245    #[test]
246    fn to_args_provider_and_agent_scopes() {
247        assert!(cmd(ModelScope::Provider("gcp".to_string()))
248            .to_args()
249            .contains(&"provider=gcp".to_string()));
250        assert!(cmd(ModelScope::Agent(
251            "gcp-firebase-developer-agent".to_string()
252        ))
253        .to_args()
254        .contains(&"agent=gcp-firebase-developer-agent".to_string()));
255    }
256
257    #[test]
258    fn validate_accepts_safe_command() {
259        assert!(cmd(ModelScope::All).validate().is_ok());
260    }
261
262    #[test]
263    fn validate_rejects_metachar_in_model() {
264        let mut c = cmd(ModelScope::All);
265        c.model = Some("gpt-5.4; rm -rf /".to_string());
266        assert!(c.validate().is_err());
267    }
268
269    #[test]
270    fn validate_rejects_metachar_in_scope_id() {
271        let c = cmd(ModelScope::Role("role$(cmd)".to_string()));
272        assert!(c.validate().is_err());
273    }
274
275    #[test]
276    fn validate_rejects_empty_field_set() {
277        let mut c = cmd(ModelScope::All);
278        c.model = None;
279        c.reasoning = None;
280        assert!(c.validate().is_err());
281    }
282
283    #[test]
284    fn display_command_format() {
285        let display = cmd(ModelScope::All).display_command();
286        assert!(display.starts_with("node scripts/model-policy.mjs set"));
287        assert!(display.contains("--scope all"));
288    }
289
290    #[test]
291    fn assignments_for_agent_filters() {
292        let doc = ModelAssignments {
293            manifest_version: 1,
294            generated_by: "test".to_string(),
295            policy_sha256: "0".repeat(64),
296            capabilities: HashMap::new(),
297            assignments: vec![
298                ModelAssignment {
299                    agent_id: "a".to_string(),
300                    harness: "codex".to_string(),
301                    model: Some("gpt-5.4".to_string()),
302                    model_provider: None,
303                    model_fallback_from: None,
304                    model_warning: None,
305                    reasoning_effort: Some("high".to_string()),
306                    model_source: "all".to_string(),
307                    reasoning_source: "all".to_string(),
308                },
309                ModelAssignment {
310                    agent_id: "b".to_string(),
311                    harness: "codex".to_string(),
312                    model: None,
313                    model_provider: None,
314                    model_fallback_from: None,
315                    model_warning: None,
316                    reasoning_effort: None,
317                    model_source: "default".to_string(),
318                    reasoning_source: "default".to_string(),
319                },
320            ],
321        };
322        let for_a = doc.for_agent("a");
323        assert_eq!(for_a.len(), 1);
324        assert_eq!(for_a[0].model.as_deref(), Some("gpt-5.4"));
325        assert!(doc.for_agent("missing").is_empty());
326    }
327
328    #[test]
329    fn parses_generated_assignments_shape() {
330        let json = r#"{
331            "manifest_version": 1,
332            "generated_by": "scripts/model-policy.mjs",
333            "policy_sha256": "abc",
334            "capabilities": {
335                "codex": { "model": true, "reasoning_effort": true }
336            },
337            "assignments": [
338                {
339                    "agent_id": "x",
340                    "harness": "codex",
341                    "model": null,
342                    "model_provider": null,
343                    "reasoning_effort": "high",
344                    "model_source": "default",
345                    "reasoning_source": "all"
346                }
347            ]
348        }"#;
349        let parsed: ModelAssignments = serde_json::from_str(json).expect("parse");
350        assert_eq!(parsed.assignments.len(), 1);
351        assert!(parsed.assignments[0].model.is_none());
352        // model_fallback_from / model_warning are absent in this fixture
353        // (pre-lifecycle assignments index shape); #[serde(default)] must
354        // still parse it cleanly as None rather than failing.
355        assert!(parsed.assignments[0].model_fallback_from.is_none());
356        assert!(parsed.assignments[0].model_warning.is_none());
357    }
358
359    #[test]
360    fn parses_assignment_with_lifecycle_warning() {
361        let json = r#"{
362            "manifest_version": 1,
363            "generated_by": "scripts/model-policy.mjs",
364            "policy_sha256": "abc",
365            "capabilities": {
366                "codex": { "model": true, "reasoning_effort": true }
367            },
368            "assignments": [
369                {
370                    "agent_id": "x",
371                    "harness": "codex",
372                    "model": "gpt-5.5",
373                    "model_provider": null,
374                    "model_fallback_from": "gpt-5-2025-08-07",
375                    "model_warning": "model \"gpt-5-2025-08-07\" was retired by the provider — projecting documented successor \"gpt-5.5\"; migrate the policy rule",
376                    "reasoning_effort": "high",
377                    "model_source": "agent:x",
378                    "reasoning_source": "agent:x"
379                }
380            ]
381        }"#;
382        let parsed: ModelAssignments = serde_json::from_str(json).expect("parse");
383        let a = &parsed.assignments[0];
384        assert_eq!(a.model.as_deref(), Some("gpt-5.5"));
385        assert_eq!(a.model_fallback_from.as_deref(), Some("gpt-5-2025-08-07"));
386        assert!(a.model_warning.as_deref().unwrap().contains("was retired"));
387    }
388}