Skip to main content

codetether_agent/tui/app/
okr_gate.rs

1//! OKR Approval Gate for /go command
2//!
3//! Provides the interactive OKR approval flow:
4//! /go <task> drafts an OKR via the model, shows it for approval (A/D keys),
5//! then starts autochat execution on approval.
6
7use std::sync::Arc;
8
9use serde::Deserialize;
10use uuid::Uuid;
11
12use crate::okr::{KeyResult, Okr, OkrRepository, OkrRun};
13use crate::tui::constants::{GO_SWAP_MODEL_GLM, GO_SWAP_MODEL_MINIMAX};
14use crate::tui::utils::helpers::truncate_with_ellipsis;
15
16/// Pending OKR approval gate state for PRD-gated relay commands.
17pub struct PendingOkrApproval {
18    /// The OKR being proposed
19    pub okr: Okr,
20    /// The OKR run being proposed
21    pub run: OkrRun,
22    /// Optional note when we had to fall back to a template draft
23    pub draft_note: Option<String>,
24    /// Original task that triggered the OKR
25    pub task: String,
26    /// Agent count for the relay
27    pub agent_count: usize,
28    /// Model to use
29    pub model: String,
30}
31
32impl PendingOkrApproval {
33    /// Create a new pending approval from a task using the default template.
34    pub fn new(task: String, agent_count: usize, model: String) -> Self {
35        let okr_id = Uuid::new_v4();
36        let okr = default_relay_okr_template(okr_id, &task);
37
38        let mut run = OkrRun::new(
39            okr_id,
40            format!("Run {}", chrono::Local::now().format("%Y-%m-%d %H:%M")),
41        );
42        let _ = run.submit_for_approval();
43
44        Self {
45            okr,
46            run,
47            draft_note: None,
48            task,
49            agent_count,
50            model,
51        }
52    }
53
54    /// Create a new pending approval by asking the configured model to draft the OKR.
55    /// Falls back to a safe template if providers are unavailable or the response can't be parsed.
56    pub async fn propose(task: String, agent_count: usize, model: String) -> Self {
57        let mut pending = Self::new(task, agent_count, model);
58        let okr_id = pending.okr.id;
59        let registry = crate::provider::ProviderRegistry::from_vault()
60            .await
61            .ok()
62            .map(Arc::new);
63
64        let task = pending.task.clone();
65        let agent_count = pending.agent_count;
66        let model = pending.model.clone();
67
68        let (okr, draft_note) = if let Some(registry) = &registry {
69            match plan_okr_draft_with_registry(&task, &model, agent_count, registry).await {
70                Some(planned) => (okr_from_planned_draft(okr_id, &task, planned), None),
71                None => (
72                    default_relay_okr_template(okr_id, &task),
73                    Some("(OKR: fallback template — model draft parse failed)".to_string()),
74                ),
75            }
76        } else {
77            (
78                default_relay_okr_template(okr_id, &task),
79                Some("(OKR: fallback template — provider unavailable)".to_string()),
80            )
81        };
82
83        pending.okr = okr;
84        pending.draft_note = draft_note;
85        pending
86    }
87
88    /// Get the approval prompt text
89    pub fn approval_prompt(&self) -> String {
90        let krs: Vec<String> = self
91            .okr
92            .key_results
93            .iter()
94            .map(|kr| format!("  • {} (target: {} {})", kr.title, kr.target_value, kr.unit))
95            .collect();
96
97        let note_line = self
98            .draft_note
99            .as_deref()
100            .map(|note| format!("{note}\n"))
101            .unwrap_or_default();
102
103        format!(
104            "⚠️  Relay OKR Draft\n\n\
105             Task: {task}\n\
106             Agents: {agents} | Model: {model}\n\n\
107             {note_line}\
108             Objective: {objective}\n\n\
109             Key Results:\n{key_results}\n\n\
110             Press [A] to approve or [D] to deny",
111            task = truncate_with_ellipsis(&self.task, 100),
112            agents = self.agent_count,
113            model = self.model,
114            note_line = note_line,
115            objective = self.okr.title,
116            key_results = krs.join("\n"),
117        )
118    }
119}
120
121fn default_relay_okr_template(okr_id: Uuid, task: &str) -> Okr {
122    let mut okr = Okr::new(
123        format!("Relay: {}", truncate_with_ellipsis(task, 60)),
124        format!("Execute relay task: {task}"),
125    );
126    okr.id = okr_id;
127
128    okr.add_key_result(KeyResult::new(
129        okr_id,
130        "Relay completes all rounds",
131        100.0,
132        "%",
133    ));
134    okr.add_key_result(KeyResult::new(
135        okr_id,
136        "Team produces actionable handoff",
137        1.0,
138        "count",
139    ));
140    okr.add_key_result(KeyResult::new(okr_id, "No critical errors", 0.0, "count"));
141
142    okr
143}
144
145#[derive(Debug, Clone, Deserialize)]
146struct PlannedOkrKeyResult {
147    #[serde(default)]
148    title: String,
149    #[serde(default)]
150    target_value: f64,
151    #[serde(default = "default_okr_unit")]
152    unit: String,
153}
154
155#[derive(Debug, Clone, Deserialize)]
156struct PlannedOkrDraft {
157    #[serde(default)]
158    title: String,
159    #[serde(default)]
160    description: String,
161    #[serde(default)]
162    key_results: Vec<PlannedOkrKeyResult>,
163}
164
165fn default_okr_unit() -> String {
166    "%".to_string()
167}
168
169fn okr_from_planned_draft(okr_id: Uuid, task: &str, planned: PlannedOkrDraft) -> Okr {
170    let title = if planned.title.trim().is_empty() {
171        format!("Relay: {}", truncate_with_ellipsis(task, 60))
172    } else {
173        planned.title.trim().to_string()
174    };
175
176    let description = if planned.description.trim().is_empty() {
177        format!("Execute relay task: {task}")
178    } else {
179        planned.description.trim().to_string()
180    };
181
182    let mut okr = Okr::new(title, description);
183    okr.id = okr_id;
184
185    for kr in planned.key_results.into_iter().take(7) {
186        if kr.title.trim().is_empty() {
187            continue;
188        }
189        let unit = if kr.unit.trim().is_empty() {
190            default_okr_unit()
191        } else {
192            kr.unit
193        };
194        okr.add_key_result(KeyResult::new(
195            okr_id,
196            kr.title.trim().to_string(),
197            kr.target_value.max(0.0),
198            unit,
199        ));
200    }
201
202    if okr.key_results.is_empty() {
203        default_relay_okr_template(okr_id, task)
204    } else {
205        okr
206    }
207}
208
209fn resolve_provider_for_model_autochat(
210    registry: &Arc<crate::provider::ProviderRegistry>,
211    model_ref: &str,
212) -> Option<(Arc<dyn crate::provider::Provider>, String)> {
213    crate::autochat::model_rotation::resolve_provider_for_model_autochat(registry, model_ref)
214}
215
216async fn plan_okr_draft_with_registry(
217    task: &str,
218    model_ref: &str,
219    agent_count: usize,
220    registry: &Arc<crate::provider::ProviderRegistry>,
221) -> Option<PlannedOkrDraft> {
222    let (provider, model_name) = resolve_provider_for_model_autochat(registry, model_ref)?;
223    let model_name_for_log = model_name.clone();
224
225    let request = crate::provider::CompletionRequest {
226        model: model_name,
227        messages: vec![
228            crate::provider::Message {
229                role: crate::provider::Role::System,
230                content: vec![crate::provider::ContentPart::Text {
231                    text: "You write OKRs for execution governance. Return ONLY valid JSON."
232                        .to_string(),
233                }],
234            },
235            crate::provider::Message {
236                role: crate::provider::Role::User,
237                content: vec![crate::provider::ContentPart::Text {
238                    text: format!(
239                        "Task:\n{task}\n\nTeam size: {agent_count}\n\n\
240                         Propose ONE objective and 3-7 measurable key results for executing this task via an AI relay.\n\
241                         Key results must be quantitative (numeric target_value + unit).\n\n\
242                         Return JSON ONLY (no markdown):\n\
243                         {{\n  \"title\": \"...\",\n  \"description\": \"...\",\n  \"key_results\": [\n    {{\"title\":\"...\",\"target_value\":123,\"unit\":\"%|count|tests|files|items\"}}\n  ]\n}}\n\n\
244                         Rules:\n\
245                         - Avoid vague KRs like 'do better'\n\
246                         - Prefer engineering outcomes (tests passing, endpoints implemented, docs updated, errors=0)\n\
247                         - If unsure about a unit, use 'count'"
248                    ),
249                }],
250            },
251        ],
252        tools: Vec::new(),
253        temperature: Some(0.4),
254        top_p: Some(0.9),
255        max_tokens: Some(900),
256        stop: Vec::new(),
257    };
258
259    let response = provider.complete(request).await.ok()?;
260    let text = response
261        .message
262        .content
263        .iter()
264        .filter_map(|part| match part {
265            crate::provider::ContentPart::Text { text }
266            | crate::provider::ContentPart::Thinking { text } => Some(text.as_str()),
267            _ => None,
268        })
269        .collect::<Vec<_>>()
270        .join("\n");
271
272    tracing::debug!(
273        model = %model_name_for_log,
274        response_len = text.len(),
275        response_preview = %text.chars().take(500).collect::<String>(),
276        "OKR draft model response"
277    );
278
279    extract_json_payload::<PlannedOkrDraft>(&text)
280}
281
282fn extract_json_payload<T: serde::de::DeserializeOwned>(text: &str) -> Option<T> {
283    let trimmed = text.trim();
284    if let Ok(value) = serde_json::from_str::<T>(trimmed) {
285        return Some(value);
286    }
287
288    if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}'))
289        && start < end
290        && let Ok(value) = serde_json::from_str::<T>(&trimmed[start..=end])
291    {
292        return Some(value);
293    }
294
295    if let (Some(start), Some(end)) = (trimmed.find('['), trimmed.rfind(']'))
296        && start < end
297        && let Ok(value) = serde_json::from_str::<T>(&trimmed[start..=end])
298    {
299        return Some(value);
300    }
301
302    None
303}
304
305/// Check whether the input is a `/go` or `/team` easy command.
306pub fn is_easy_go_command(input: &str) -> bool {
307    let command = input
308        .split_whitespace()
309        .next()
310        .unwrap_or("")
311        .to_ascii_lowercase();
312
313    matches!(command.as_str(), "/go" | "/team")
314}
315
316fn is_glm5_model(model: &str) -> bool {
317    let normalized = model.trim().to_ascii_lowercase();
318    matches!(
319        normalized.as_str(),
320        "zai/glm-5"
321            | "z-ai/glm-5"
322            | "openrouter/z-ai/glm-5"
323            | "glm5/glm-5-fp8"
324            | "glm5/glm-5"
325            | "glm5:glm-5-fp8"
326            | "glm5:glm-5"
327    )
328}
329
330fn is_minimax_m25_model(model: &str) -> bool {
331    let normalized = model.trim().to_ascii_lowercase();
332    matches!(
333        normalized.as_str(),
334        "minimax/minimax-m2.5"
335            | "minimax-m2.5"
336            | "minimax-credits/minimax-m2.5-highspeed"
337            | "minimax-m2.5-highspeed"
338    )
339}
340
341/// Rotate between available models for the /go command.
342/// Alternates between MiniMax and GLM-5 based on the current model.
343pub fn next_go_model(current_model: Option<&str>) -> String {
344    match current_model {
345        Some(model) if is_glm5_model(model) => GO_SWAP_MODEL_MINIMAX.to_string(),
346        Some(model) if is_minimax_m25_model(model) => GO_SWAP_MODEL_GLM.to_string(),
347        _ => GO_SWAP_MODEL_MINIMAX.to_string(),
348    }
349}
350
351/// Initialize the OKR repository on AppState if not already present.
352pub async fn ensure_okr_repository(repo: &mut Option<Arc<OkrRepository>>) {
353    if repo.is_none() {
354        if let Ok(new_repo) = OkrRepository::from_config().await {
355            *repo = Some(Arc::new(new_repo));
356        }
357    }
358}