codetether_agent/tui/app/
okr_gate.rs1use 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
16pub struct PendingOkrApproval {
18 pub okr: Okr,
20 pub run: OkrRun,
22 pub draft_note: Option<String>,
24 pub task: String,
26 pub agent_count: usize,
28 pub model: String,
30}
31
32impl PendingOkrApproval {
33 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 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) = ®istry {
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 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
305pub 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
341pub 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
351pub 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}