use std::sync::Arc;
use serde::Deserialize;
use uuid::Uuid;
use crate::okr::{KeyResult, Okr, OkrRepository, OkrRun};
use crate::tui::constants::{GO_SWAP_MODEL_GLM, GO_SWAP_MODEL_MINIMAX};
use crate::tui::utils::helpers::truncate_with_ellipsis;
pub struct PendingOkrApproval {
pub okr: Okr,
pub run: OkrRun,
pub draft_note: Option<String>,
pub task: String,
pub agent_count: usize,
pub model: String,
}
impl PendingOkrApproval {
pub fn new(task: String, agent_count: usize, model: String) -> Self {
let okr_id = Uuid::new_v4();
let okr = default_relay_okr_template(okr_id, &task);
let mut run = OkrRun::new(
okr_id,
format!("Run {}", chrono::Local::now().format("%Y-%m-%d %H:%M")),
);
let _ = run.submit_for_approval();
Self {
okr,
run,
draft_note: None,
task,
agent_count,
model,
}
}
pub async fn propose(task: String, agent_count: usize, model: String) -> Self {
let mut pending = Self::new(task, agent_count, model);
let okr_id = pending.okr.id;
let registry = crate::provider::ProviderRegistry::from_vault()
.await
.ok()
.map(Arc::new);
let task = pending.task.clone();
let agent_count = pending.agent_count;
let model = pending.model.clone();
let (okr, draft_note) = if let Some(registry) = ®istry {
match plan_okr_draft_with_registry(&task, &model, agent_count, registry).await {
Some(planned) => (okr_from_planned_draft(okr_id, &task, planned), None),
None => (
default_relay_okr_template(okr_id, &task),
Some("(OKR: fallback template — model draft parse failed)".to_string()),
),
}
} else {
(
default_relay_okr_template(okr_id, &task),
Some("(OKR: fallback template — provider unavailable)".to_string()),
)
};
pending.okr = okr;
pending.draft_note = draft_note;
pending
}
pub fn approval_prompt(&self) -> String {
let krs: Vec<String> = self
.okr
.key_results
.iter()
.map(|kr| format!(" • {} (target: {} {})", kr.title, kr.target_value, kr.unit))
.collect();
let note_line = self
.draft_note
.as_deref()
.map(|note| format!("{note}\n"))
.unwrap_or_default();
format!(
"⚠️ Relay OKR Draft\n\n\
Task: {task}\n\
Agents: {agents} | Model: {model}\n\n\
{note_line}\
Objective: {objective}\n\n\
Key Results:\n{key_results}\n\n\
Press [A] to approve or [D] to deny",
task = truncate_with_ellipsis(&self.task, 100),
agents = self.agent_count,
model = self.model,
note_line = note_line,
objective = self.okr.title,
key_results = krs.join("\n"),
)
}
}
fn default_relay_okr_template(okr_id: Uuid, task: &str) -> Okr {
let mut okr = Okr::new(
format!("Relay: {}", truncate_with_ellipsis(task, 60)),
format!("Execute relay task: {task}"),
);
okr.id = okr_id;
okr.add_key_result(KeyResult::new(
okr_id,
"Relay completes all rounds",
100.0,
"%",
));
okr.add_key_result(KeyResult::new(
okr_id,
"Team produces actionable handoff",
1.0,
"count",
));
okr.add_key_result(KeyResult::new(okr_id, "No critical errors", 0.0, "count"));
okr
}
#[derive(Debug, Clone, Deserialize)]
struct PlannedOkrKeyResult {
#[serde(default)]
title: String,
#[serde(default)]
target_value: f64,
#[serde(default = "default_okr_unit")]
unit: String,
}
#[derive(Debug, Clone, Deserialize)]
struct PlannedOkrDraft {
#[serde(default)]
title: String,
#[serde(default)]
description: String,
#[serde(default)]
key_results: Vec<PlannedOkrKeyResult>,
}
fn default_okr_unit() -> String {
"%".to_string()
}
fn okr_from_planned_draft(okr_id: Uuid, task: &str, planned: PlannedOkrDraft) -> Okr {
let title = if planned.title.trim().is_empty() {
format!("Relay: {}", truncate_with_ellipsis(task, 60))
} else {
planned.title.trim().to_string()
};
let description = if planned.description.trim().is_empty() {
format!("Execute relay task: {task}")
} else {
planned.description.trim().to_string()
};
let mut okr = Okr::new(title, description);
okr.id = okr_id;
for kr in planned.key_results.into_iter().take(7) {
if kr.title.trim().is_empty() {
continue;
}
let unit = if kr.unit.trim().is_empty() {
default_okr_unit()
} else {
kr.unit
};
okr.add_key_result(KeyResult::new(
okr_id,
kr.title.trim().to_string(),
kr.target_value.max(0.0),
unit,
));
}
if okr.key_results.is_empty() {
default_relay_okr_template(okr_id, task)
} else {
okr
}
}
fn resolve_provider_for_model_autochat(
registry: &Arc<crate::provider::ProviderRegistry>,
model_ref: &str,
) -> Option<(Arc<dyn crate::provider::Provider>, String)> {
crate::autochat::model_rotation::resolve_provider_for_model_autochat(registry, model_ref)
}
async fn plan_okr_draft_with_registry(
task: &str,
model_ref: &str,
agent_count: usize,
registry: &Arc<crate::provider::ProviderRegistry>,
) -> Option<PlannedOkrDraft> {
let (provider, model_name) = resolve_provider_for_model_autochat(registry, model_ref)?;
let model_name_for_log = model_name.clone();
let request = crate::provider::CompletionRequest {
model: model_name,
messages: vec![
crate::provider::Message {
role: crate::provider::Role::System,
content: vec![crate::provider::ContentPart::Text {
text: "You write OKRs for execution governance. Return ONLY valid JSON."
.to_string(),
}],
},
crate::provider::Message {
role: crate::provider::Role::User,
content: vec![crate::provider::ContentPart::Text {
text: format!(
"Task:\n{task}\n\nTeam size: {agent_count}\n\n\
Propose ONE objective and 3-7 measurable key results for executing this task via an AI relay.\n\
Key results must be quantitative (numeric target_value + unit).\n\n\
Return JSON ONLY (no markdown):\n\
{{\n \"title\": \"...\",\n \"description\": \"...\",\n \"key_results\": [\n {{\"title\":\"...\",\"target_value\":123,\"unit\":\"%|count|tests|files|items\"}}\n ]\n}}\n\n\
Rules:\n\
- Avoid vague KRs like 'do better'\n\
- Prefer engineering outcomes (tests passing, endpoints implemented, docs updated, errors=0)\n\
- If unsure about a unit, use 'count'"
),
}],
},
],
tools: Vec::new(),
temperature: Some(0.4),
top_p: Some(0.9),
max_tokens: Some(900),
stop: Vec::new(),
};
let response = provider.complete(request).await.ok()?;
let text = response
.message
.content
.iter()
.filter_map(|part| match part {
crate::provider::ContentPart::Text { text }
| crate::provider::ContentPart::Thinking { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
tracing::debug!(
model = %model_name_for_log,
response_len = text.len(),
response_preview = %text.chars().take(500).collect::<String>(),
"OKR draft model response"
);
extract_json_payload::<PlannedOkrDraft>(&text)
}
fn extract_json_payload<T: serde::de::DeserializeOwned>(text: &str) -> Option<T> {
let trimmed = text.trim();
if let Ok(value) = serde_json::from_str::<T>(trimmed) {
return Some(value);
}
if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}'))
&& start < end
&& let Ok(value) = serde_json::from_str::<T>(&trimmed[start..=end])
{
return Some(value);
}
if let (Some(start), Some(end)) = (trimmed.find('['), trimmed.rfind(']'))
&& start < end
&& let Ok(value) = serde_json::from_str::<T>(&trimmed[start..=end])
{
return Some(value);
}
None
}
pub fn is_easy_go_command(input: &str) -> bool {
let command = input
.split_whitespace()
.next()
.unwrap_or("")
.to_ascii_lowercase();
matches!(command.as_str(), "/go" | "/team")
}
fn is_glm5_model(model: &str) -> bool {
let normalized = model.trim().to_ascii_lowercase();
matches!(
normalized.as_str(),
"zai/glm-5"
| "z-ai/glm-5"
| "openrouter/z-ai/glm-5"
| "glm5/glm-5-fp8"
| "glm5/glm-5"
| "glm5:glm-5-fp8"
| "glm5:glm-5"
)
}
fn is_minimax_m25_model(model: &str) -> bool {
let normalized = model.trim().to_ascii_lowercase();
matches!(
normalized.as_str(),
"minimax/minimax-m2.5"
| "minimax-m2.5"
| "minimax-credits/minimax-m2.5-highspeed"
| "minimax-m2.5-highspeed"
)
}
pub fn next_go_model(current_model: Option<&str>) -> String {
match current_model {
Some(model) if is_glm5_model(model) => GO_SWAP_MODEL_MINIMAX.to_string(),
Some(model) if is_minimax_m25_model(model) => GO_SWAP_MODEL_GLM.to_string(),
_ => GO_SWAP_MODEL_MINIMAX.to_string(),
}
}
pub async fn ensure_okr_repository(repo: &mut Option<Arc<OkrRepository>>) {
if repo.is_none() {
if let Ok(new_repo) = OkrRepository::from_config().await {
*repo = Some(Arc::new(new_repo));
}
}
}