Skip to main content

codetether_agent/cli/
run.rs

1//! Non-interactive run command
2
3use super::RunArgs;
4use crate::autochat::model_rotation::{RelayModelRotation, build_round_robin_model_rotation};
5use crate::autochat::shared_context::{
6    SharedRelayContext, compose_prompt_with_context, distill_context_delta_with_rlm,
7    drain_context_updates, publish_context_delta,
8};
9use crate::autochat::transport::{attach_handoff_receiver, consume_handoff_by_correlation};
10use crate::bus::{AgentBus, relay::ProtocolRelayRuntime, relay::RelayAgentProfile};
11use crate::config::Config;
12use crate::okr::{ApprovalDecision, KeyResult, Okr, OkrRepository, OkrRun};
13use crate::provider::{ContentPart, Message, Role};
14use crate::rlm::{FinalPayload, RlmExecutor};
15use crate::session::Session;
16use crate::session::import_codex_session_by_id;
17use anyhow::Result;
18use serde::{Deserialize, Serialize, de::DeserializeOwned};
19use std::collections::HashMap;
20use std::io::Write;
21use uuid::Uuid;
22
23const AUTOCHAT_MAX_AGENTS: usize = crate::autochat::AUTOCHAT_MAX_AGENTS;
24const AUTOCHAT_DEFAULT_AGENTS: usize = crate::autochat::AUTOCHAT_DEFAULT_AGENTS;
25const AUTOCHAT_MAX_ROUNDS: usize = crate::autochat::AUTOCHAT_MAX_ROUNDS;
26const AUTOCHAT_MAX_DYNAMIC_SPAWNS: usize = crate::autochat::AUTOCHAT_MAX_DYNAMIC_SPAWNS;
27const AUTOCHAT_SPAWN_CHECK_MIN_CHARS: usize = crate::autochat::AUTOCHAT_SPAWN_CHECK_MIN_CHARS;
28const AUTOCHAT_QUICK_DEMO_TASK: &str = crate::autochat::AUTOCHAT_QUICK_DEMO_TASK;
29const AUTOCHAT_RLM_THRESHOLD_CHARS: usize = crate::autochat::AUTOCHAT_RLM_THRESHOLD_CHARS;
30const AUTOCHAT_RLM_FALLBACK_CHARS: usize = crate::autochat::AUTOCHAT_RLM_FALLBACK_CHARS;
31const AUTOCHAT_RLM_HANDOFF_QUERY: &str = crate::autochat::AUTOCHAT_RLM_HANDOFF_QUERY;
32// Easy-go defaults should be fast and cheap. We use the dedicated
33// minimax-credits provider and the highspeed variant by default.
34const GO_DEFAULT_MODEL: &str = "minimax-credits/MiniMax-M2.5-highspeed";
35
36/// Guarded UUID parse that logs warnings on invalid input instead of returning NIL UUID.
37/// Returns None for invalid UUIDs, allowing callers to skip operations rather than corrupt data.
38fn parse_uuid_guarded(s: &str, context: &str) -> Option<Uuid> {
39    match s.parse::<Uuid>() {
40        Ok(uuid) => Some(uuid),
41        Err(e) => {
42            tracing::warn!(
43                context,
44                uuid_str = %s,
45                error = %e,
46                "Invalid UUID string - skipping operation"
47            );
48            None
49        }
50    }
51}
52
53#[derive(Debug, Clone)]
54struct RelayProfile {
55    name: String,
56    instructions: String,
57    capabilities: Vec<String>,
58}
59
60#[derive(Debug, Clone, Deserialize)]
61struct PlannedRelayProfile {
62    #[serde(default)]
63    name: String,
64    #[serde(default)]
65    specialty: String,
66    #[serde(default)]
67    mission: String,
68    #[serde(default)]
69    capabilities: Vec<String>,
70}
71
72#[derive(Debug, Clone, Deserialize)]
73struct PlannedRelayResponse {
74    #[serde(default)]
75    profiles: Vec<PlannedRelayProfile>,
76}
77
78#[derive(Debug, Clone, Deserialize)]
79struct RelaySpawnDecision {
80    #[serde(default)]
81    spawn: bool,
82    #[serde(default)]
83    reason: String,
84    #[serde(default)]
85    profile: Option<PlannedRelayProfile>,
86}
87
88#[derive(Debug, Clone, Deserialize)]
89struct PlannedOkrKeyResult {
90    #[serde(default)]
91    title: String,
92    #[serde(default)]
93    target_value: f64,
94    #[serde(default = "default_okr_unit")]
95    unit: String,
96}
97
98#[derive(Debug, Clone, Deserialize)]
99struct PlannedOkrDraft {
100    #[serde(default)]
101    title: String,
102    #[serde(default)]
103    description: String,
104    #[serde(default)]
105    key_results: Vec<PlannedOkrKeyResult>,
106}
107
108fn default_okr_unit() -> String {
109    "%".to_string()
110}
111
112#[derive(Debug, Serialize)]
113struct AutochatCliResult {
114    status: String,
115    relay_id: String,
116    model: String,
117    agent_count: usize,
118    turns: usize,
119    agents: Vec<String>,
120    final_handoff: String,
121    summary: String,
122    failure: Option<String>,
123    shared_context_items: usize,
124    rlm_context_count: usize,
125}
126
127fn slugify_label(value: &str) -> String {
128    let mut out = String::with_capacity(value.len());
129    let mut last_dash = false;
130
131    for ch in value.chars() {
132        let ch = ch.to_ascii_lowercase();
133        if ch.is_ascii_alphanumeric() {
134            out.push(ch);
135            last_dash = false;
136        } else if !last_dash {
137            out.push('-');
138            last_dash = true;
139        }
140    }
141
142    out.trim_matches('-').to_string()
143}
144
145fn sanitize_relay_agent_name(value: &str) -> String {
146    let raw = slugify_label(value);
147    let base = if raw.is_empty() {
148        "auto-specialist".to_string()
149    } else if raw.starts_with("auto-") {
150        raw
151    } else {
152        format!("auto-{raw}")
153    };
154
155    truncate_with_ellipsis(&base, 48)
156        .trim_end_matches("...")
157        .to_string()
158}
159
160fn unique_relay_agent_name(base: &str, existing: &[String]) -> String {
161    if !existing.iter().any(|name| name == base) {
162        return base.to_string();
163    }
164
165    let mut suffix = 2usize;
166    loop {
167        let candidate = format!("{base}-{suffix}");
168        if !existing.iter().any(|name| name == &candidate) {
169            return candidate;
170        }
171        suffix += 1;
172    }
173}
174
175fn relay_instruction_from_plan(name: &str, specialty: &str, mission: &str) -> String {
176    format!(
177        "You are @{name}.\n\
178         Specialty: {specialty}.\n\
179         Mission: {mission}\n\n\
180         This is a protocol-first relay conversation. Treat incoming handoffs as authoritative context.\n\
181         Keep responses concise, concrete, and useful for the next specialist.\n\
182         Include one clear recommendation for what the next agent should do.\n\
183         If the task is too large for the current team, explicitly call out missing specialties and handoff boundaries.",
184    )
185}
186
187fn build_runtime_profile_from_plan(
188    profile: PlannedRelayProfile,
189    existing: &[String],
190) -> Option<RelayProfile> {
191    let specialty = if profile.specialty.trim().is_empty() {
192        "generalist".to_string()
193    } else {
194        profile.specialty.trim().to_string()
195    };
196
197    let mission = if profile.mission.trim().is_empty() {
198        "Advance the relay with concrete next actions and clear handoffs.".to_string()
199    } else {
200        profile.mission.trim().to_string()
201    };
202
203    let base_name = if profile.name.trim().is_empty() {
204        format!("auto-{}", slugify_label(&specialty))
205    } else {
206        profile.name.trim().to_string()
207    };
208
209    let sanitized = sanitize_relay_agent_name(&base_name);
210    let name = unique_relay_agent_name(&sanitized, existing);
211    if name.trim().is_empty() {
212        return None;
213    }
214
215    let mut capabilities: Vec<String> = Vec::new();
216    let specialty_cap = slugify_label(&specialty);
217    if !specialty_cap.is_empty() {
218        capabilities.push(specialty_cap);
219    }
220
221    for capability in profile.capabilities {
222        let normalized = slugify_label(&capability);
223        if !normalized.is_empty() && !capabilities.contains(&normalized) {
224            capabilities.push(normalized);
225        }
226    }
227
228    crate::autochat::ensure_required_relay_capabilities(&mut capabilities);
229
230    Some(RelayProfile {
231        name: name.clone(),
232        instructions: relay_instruction_from_plan(&name, &specialty, &mission),
233        capabilities,
234    })
235}
236
237fn extract_json_payload<T: DeserializeOwned>(text: &str) -> Option<T> {
238    let trimmed = text.trim();
239    if let Ok(value) = serde_json::from_str::<T>(trimmed) {
240        return Some(value);
241    }
242
243    if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}'))
244        && start < end
245        && let Ok(value) = serde_json::from_str::<T>(&trimmed[start..=end])
246    {
247        return Some(value);
248    }
249
250    if let (Some(start), Some(end)) = (trimmed.find('['), trimmed.rfind(']'))
251        && start < end
252        && let Ok(value) = serde_json::from_str::<T>(&trimmed[start..=end])
253    {
254        return Some(value);
255    }
256
257    None
258}
259
260fn resolve_provider_for_model_autochat(
261    registry: &std::sync::Arc<crate::provider::ProviderRegistry>,
262    model_ref: &str,
263) -> Option<(std::sync::Arc<dyn crate::provider::Provider>, String)> {
264    crate::autochat::model_rotation::resolve_provider_for_model_autochat(registry, model_ref)
265}
266
267fn default_relay_okr_template(okr_id: Uuid, task: &str) -> Okr {
268    let mut okr = Okr::new(
269        format!("Relay: {}", truncate_with_ellipsis(task, 60)),
270        format!("Execute relay task: {}", task),
271    );
272    okr.id = okr_id;
273
274    // Default key results (fallback only)
275    okr.add_key_result(KeyResult::new(
276        okr_id,
277        "Relay completes all rounds",
278        100.0,
279        "%",
280    ));
281    okr.add_key_result(KeyResult::new(
282        okr_id,
283        "Team produces actionable handoff",
284        1.0,
285        "count",
286    ));
287    okr.add_key_result(KeyResult::new(okr_id, "No critical errors", 0.0, "count"));
288    okr
289}
290
291fn okr_from_planned_draft(okr_id: Uuid, task: &str, planned: PlannedOkrDraft) -> Okr {
292    let title = if planned.title.trim().is_empty() {
293        format!("Relay: {}", truncate_with_ellipsis(task, 60))
294    } else {
295        planned.title.trim().to_string()
296    };
297
298    let description = if planned.description.trim().is_empty() {
299        format!("Execute relay task: {}", task)
300    } else {
301        planned.description.trim().to_string()
302    };
303
304    let mut okr = Okr::new(title, description);
305    okr.id = okr_id;
306
307    // Clamp to a reasonable number of KRs to keep UX readable.
308    for kr in planned.key_results.into_iter().take(7) {
309        if kr.title.trim().is_empty() {
310            continue;
311        }
312        let unit = if kr.unit.trim().is_empty() {
313            default_okr_unit()
314        } else {
315            kr.unit
316        };
317        okr.add_key_result(KeyResult::new(
318            okr_id,
319            kr.title.trim().to_string(),
320            kr.target_value.max(0.0),
321            unit,
322        ));
323    }
324
325    if okr.key_results.is_empty() {
326        default_relay_okr_template(okr_id, task)
327    } else {
328        okr
329    }
330}
331
332async fn plan_okr_draft_with_registry(
333    task: &str,
334    model_ref: &str,
335    agent_count: usize,
336    registry: &std::sync::Arc<crate::provider::ProviderRegistry>,
337) -> Option<PlannedOkrDraft> {
338    let (provider, model_name) = resolve_provider_for_model_autochat(registry, model_ref)?;
339    let model_name_for_log = model_name.clone();
340
341    // Keep prompt short-ish: this happens *before* the approval gate.
342    let request = crate::provider::CompletionRequest {
343        model: model_name,
344        messages: vec![
345            crate::provider::Message {
346                role: crate::provider::Role::System,
347                content: vec![crate::provider::ContentPart::Text {
348                    text: "You write OKRs for execution governance. Return ONLY valid JSON."
349                        .to_string(),
350                }],
351            },
352            crate::provider::Message {
353                role: crate::provider::Role::User,
354                content: vec![crate::provider::ContentPart::Text {
355                    text: format!(
356                        "Task:\n{task}\n\nTeam size: {agent_count}\n\n\
357                         Propose ONE objective and 3-7 measurable key results for executing this task via an AI relay.\n\
358                         Key results must be quantitative (numeric target_value + unit).\n\n\
359                         Return JSON ONLY (no markdown):\n\
360                         {{\n  \"title\": \"...\",\n  \"description\": \"...\",\n  \"key_results\": [\n    {{\"title\":\"...\",\"target_value\":123,\"unit\":\"%|count|tests|files|items\"}}\n  ]\n}}\n\n\
361                         Rules:\n\
362                         - Avoid vague KRs like 'do better'\n\
363                         - Prefer engineering outcomes (tests passing, endpoints implemented, docs updated, errors=0)\n\
364                         - If unsure about a unit, use 'count'"
365                    ),
366                }],
367            },
368        ],
369        tools: Vec::new(),
370        temperature: Some(0.4),
371        top_p: Some(0.9),
372        max_tokens: Some(900),
373        stop: Vec::new(),
374    };
375
376    let response = provider.complete(request).await.ok()?;
377    let text = response
378        .message
379        .content
380        .iter()
381        .filter_map(|part| match part {
382            crate::provider::ContentPart::Text { text }
383            | crate::provider::ContentPart::Thinking { text } => Some(text.as_str()),
384            _ => None,
385        })
386        .collect::<Vec<_>>()
387        .join("\n");
388
389    tracing::debug!(
390        model = %model_name_for_log,
391        response_len = text.len(),
392        response_preview = %text.chars().take(500).collect::<String>(),
393        "OKR draft model response"
394    );
395
396    let parsed = extract_json_payload::<PlannedOkrDraft>(&text);
397    if parsed.is_none() {
398        tracing::warn!(
399            model = %model_name_for_log,
400            response_preview = %text.chars().take(500).collect::<String>(),
401            "Failed to parse OKR draft JSON from model response"
402        );
403    }
404    parsed
405}
406
407async fn plan_relay_profiles_with_registry(
408    task: &str,
409    model_ref: &str,
410    requested_agents: usize,
411    registry: &std::sync::Arc<crate::provider::ProviderRegistry>,
412) -> Option<Vec<RelayProfile>> {
413    let (provider, model_name) = resolve_provider_for_model_autochat(registry, model_ref)?;
414    let requested_agents = requested_agents.clamp(2, AUTOCHAT_MAX_AGENTS);
415
416    let request = crate::provider::CompletionRequest {
417        model: model_name,
418        messages: vec![
419            crate::provider::Message {
420                role: crate::provider::Role::System,
421                content: vec![crate::provider::ContentPart::Text {
422                    text: "You are a relay-team architect. Return ONLY valid JSON.".to_string(),
423                }],
424            },
425            crate::provider::Message {
426                role: crate::provider::Role::User,
427                content: vec![crate::provider::ContentPart::Text {
428                    text: format!(
429                        "Task:\n{task}\n\nDesign a task-specific relay team.\n\
430                         Respond with JSON object only:\n\
431                         {{\n  \"profiles\": [\n    {{\"name\":\"auto-...\",\"specialty\":\"...\",\"mission\":\"...\",\"capabilities\":[\"...\"]}}\n  ]\n}}\n\
432                         Requirements:\n\
433                         - Return {} profiles\n\
434                         - Names must be short kebab-case\n\
435                         - Capabilities must be concise skill tags\n\
436                         - Missions should be concrete and handoff-friendly",
437                        requested_agents
438                    ),
439                }],
440            },
441        ],
442        tools: Vec::new(),
443        temperature: Some(1.0),
444        top_p: Some(0.9),
445        max_tokens: Some(1200),
446        stop: Vec::new(),
447    };
448
449    let response = provider.complete(request).await.ok()?;
450    let text = response
451        .message
452        .content
453        .iter()
454        .filter_map(|part| match part {
455            crate::provider::ContentPart::Text { text }
456            | crate::provider::ContentPart::Thinking { text } => Some(text.as_str()),
457            _ => None,
458        })
459        .collect::<Vec<_>>()
460        .join("\n");
461
462    let planned = extract_json_payload::<PlannedRelayResponse>(&text)?;
463    let mut existing = Vec::<String>::new();
464    let mut runtime = Vec::<RelayProfile>::new();
465
466    for profile in planned.profiles.into_iter().take(AUTOCHAT_MAX_AGENTS) {
467        if let Some(runtime_profile) = build_runtime_profile_from_plan(profile, &existing) {
468            existing.push(runtime_profile.name.clone());
469            runtime.push(runtime_profile);
470        }
471    }
472
473    if runtime.len() >= 2 {
474        Some(runtime)
475    } else {
476        None
477    }
478}
479
480async fn decide_dynamic_spawn_with_registry(
481    task: &str,
482    model_ref: &str,
483    latest_output: &str,
484    round: usize,
485    ordered_agents: &[String],
486    registry: &std::sync::Arc<crate::provider::ProviderRegistry>,
487) -> Option<(RelayProfile, String)> {
488    let (provider, model_name) = resolve_provider_for_model_autochat(registry, model_ref)?;
489    let team = ordered_agents
490        .iter()
491        .map(|name| format!("@{name}"))
492        .collect::<Vec<_>>()
493        .join(", ");
494    let output_excerpt = truncate_with_ellipsis(latest_output, 2200);
495
496    let request = crate::provider::CompletionRequest {
497        model: model_name,
498        messages: vec![
499            crate::provider::Message {
500                role: crate::provider::Role::System,
501                content: vec![crate::provider::ContentPart::Text {
502                    text: "You are a relay scaling controller. Return ONLY valid JSON.".to_string(),
503                }],
504            },
505            crate::provider::Message {
506                role: crate::provider::Role::User,
507                content: vec![crate::provider::ContentPart::Text {
508                    text: format!(
509                        "Task:\n{task}\n\nRound: {round}\nCurrent team: {team}\n\
510                         Latest handoff excerpt:\n{output_excerpt}\n\n\
511                         Decide whether the team needs one additional specialist right now.\n\
512                         Respond with JSON object only:\n\
513                         {{\n  \"spawn\": true|false,\n  \"reason\": \"...\",\n  \"profile\": {{\"name\":\"auto-...\",\"specialty\":\"...\",\"mission\":\"...\",\"capabilities\":[\"...\"]}}\n}}\n\
514                         If spawn=false, profile may be null or omitted."
515                    ),
516                }],
517            },
518        ],
519        tools: Vec::new(),
520        temperature: Some(1.0),
521        top_p: Some(0.9),
522        max_tokens: Some(420),
523        stop: Vec::new(),
524    };
525
526    let response = provider.complete(request).await.ok()?;
527    let text = response
528        .message
529        .content
530        .iter()
531        .filter_map(|part| match part {
532            crate::provider::ContentPart::Text { text }
533            | crate::provider::ContentPart::Thinking { text } => Some(text.as_str()),
534            _ => None,
535        })
536        .collect::<Vec<_>>()
537        .join("\n");
538
539    let decision = extract_json_payload::<RelaySpawnDecision>(&text)?;
540    if !decision.spawn {
541        return None;
542    }
543
544    let profile = decision.profile?;
545    let runtime_profile = build_runtime_profile_from_plan(profile, ordered_agents)?;
546    let reason = if decision.reason.trim().is_empty() {
547        "Model requested additional specialist for task scope.".to_string()
548    } else {
549        decision.reason.trim().to_string()
550    };
551
552    Some((runtime_profile, reason))
553}
554
555pub async fn execute(args: RunArgs) -> Result<()> {
556    let message = args.message.trim();
557    super::run_checkpoint::validate_auto_continue(args.auto_continue_until)?;
558
559    if message.is_empty() {
560        anyhow::bail!("You must provide a message");
561    }
562
563    tracing::info!("Running with message: {}", message);
564
565    if args.branches > 1 {
566        let runner = crate::swarm::speculative::SpeculativeRunner::new(
567            args.branches,
568            args.strategies.clone(),
569        );
570        let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
571        let specs = runner.build_branches(&cwd, message)?;
572        tracing::info!(branches = %runner.branch_count, "Many-worlds speculative mode enabled");
573        for spec in &specs {
574            tracing::info!(
575                branch = %spec.branch_name,
576                strategy = %spec.strategy_prompt,
577                "Speculative branch assigned"
578            );
579        }
580        println!(
581            "[speculative] {} parallel branches queued (collapse controller will race + prune)",
582            runner.branch_count
583        );
584        // TODO: wire through SwarmExecutor when parallel dispatch is ready
585    }
586
587    // Load configuration
588    let config = Config::load().await.unwrap_or_default();
589    let workspace_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
590    let knowledge_snapshot =
591        match crate::indexer::refresh_workspace_knowledge_snapshot(&workspace_dir).await {
592            Ok(path) => {
593                tracing::info!(
594                    workspace = %workspace_dir.display(),
595                    output = %path.display(),
596                    "Refreshed workspace knowledge snapshot for run"
597                );
598                Some(path)
599            }
600            Err(e) => {
601                tracing::warn!(
602                    workspace = %workspace_dir.display(),
603                    error = %e,
604                    "Failed to refresh workspace knowledge snapshot"
605                );
606                None
607            }
608        };
609
610    // Protocol-first relay aliases in CLI:
611    // - /go [count] <task>
612    // - /autochat [count] <task>
613    let easy_go_requested = is_easy_go_command(message);
614    let normalized = normalize_cli_go_command(message);
615    if let Some(rest) = command_with_optional_args(&normalized, "/autochat") {
616        let Some(parsed) = crate::autochat::parse_autochat_request(
617            rest,
618            AUTOCHAT_DEFAULT_AGENTS,
619            AUTOCHAT_QUICK_DEMO_TASK,
620        ) else {
621            anyhow::bail!(
622                "Usage: /autochat [count] [--no-prd] <task>\nEasy mode: /go <task>\ncount range: 2-{} (default: {})",
623                AUTOCHAT_MAX_AGENTS,
624                AUTOCHAT_DEFAULT_AGENTS
625            );
626        };
627        let agent_count = parsed.agent_count;
628        let parsed_task = parsed.task;
629        let task = if easy_go_requested {
630            validate_easy_go_task(&parsed_task)?
631        } else {
632            normalize_go_task_input(&parsed_task)
633        };
634        let require_prd = easy_go_requested || !parsed.bypass_prd;
635
636        if !(2..=AUTOCHAT_MAX_AGENTS).contains(&agent_count) {
637            anyhow::bail!(
638                "Invalid relay size {}. count must be between 2 and {}",
639                agent_count,
640                AUTOCHAT_MAX_AGENTS
641            );
642        }
643
644        let model = resolve_autochat_model(
645            args.model.as_deref(),
646            std::env::var("CODETETHER_DEFAULT_MODEL").ok().as_deref(),
647            config.default_model.as_deref(),
648            easy_go_requested,
649        );
650
651        // PRD-gated execution path (mandatory for /go and default for /autochat)
652        if require_prd {
653            // For /go, default to max concurrency (run all stories in parallel) unless
654            // the user explicitly specified a count. /autochat keeps the requested count.
655            let max_concurrent = if easy_go_requested && !parsed.explicit_count {
656                AUTOCHAT_MAX_AGENTS
657            } else {
658                agent_count
659            };
660            // Create OKR draft (LLM-proposed, with safe fallback)
661            let okr_id = Uuid::new_v4();
662            let registry_for_draft = crate::provider::ProviderRegistry::from_vault()
663                .await
664                .ok()
665                .map(std::sync::Arc::new);
666
667            let (mut okr, draft_note) = if let Some(registry) = &registry_for_draft {
668                match plan_okr_draft_with_registry(&task, &model, agent_count, registry).await {
669                    Some(planned) => (okr_from_planned_draft(okr_id, &task, planned), None),
670                    None => (
671                        default_relay_okr_template(okr_id, &task),
672                        Some("(OKR: fallback template — model draft parse failed)".to_string()),
673                    ),
674                }
675            } else {
676                (
677                    default_relay_okr_template(okr_id, &task),
678                    Some("(OKR: fallback template — provider unavailable)".to_string()),
679                )
680            };
681
682            // Create run
683            let mut run = OkrRun::new(
684                okr_id,
685                format!("Run {}", chrono::Local::now().format("%Y-%m-%d %H:%M")),
686            );
687            let _ = run.submit_for_approval();
688
689            // Show OKR draft
690            let command_label = if easy_go_requested {
691                "/go"
692            } else {
693                "/autochat"
694            };
695            println!("\n⚠️  {command_label} OKR Draft\n");
696            println!("Task: {}", truncate_with_ellipsis(&task, 80));
697            println!("Agents: {} | Model: {}", agent_count, model);
698            if let Some(note) = draft_note {
699                println!("{}", note);
700            }
701            println!("\nObjective: {}", okr.title);
702            println!("\nKey Results:");
703            for kr in &okr.key_results {
704                println!("  • {} (target: {} {})", kr.title, kr.target_value, kr.unit);
705            }
706            println!("\n");
707
708            // Prompt for approval
709            print!("Approve OKR and start relay? [y/n]: ");
710            std::io::stdout().flush()?;
711            let mut input = String::new();
712            std::io::stdin().read_line(&mut input)?;
713
714            let input = input.trim().to_lowercase();
715            if input != "y" && input != "yes" {
716                run.record_decision(ApprovalDecision::deny(run.id, "User denied via CLI"));
717                println!("❌ OKR denied. Relay not started.");
718                println!("Use /autochat --no-prd for tactical execution without OKR/PRD tracking.");
719                return Ok(());
720            }
721
722            println!("✅ OKR approved! Starting Ralph PRD execution...\n");
723
724            // Save OKR and run
725            let mut approved_run = run;
726            if let Ok(repo) = OkrRepository::from_config().await {
727                let _ = repo.create_okr(okr.clone()).await;
728                approved_run.record_decision(ApprovalDecision::approve(
729                    approved_run.id,
730                    "User approved via CLI",
731                ));
732                approved_run.correlation_id = Some(format!("ralph-{}", Uuid::new_v4()));
733                let _ = repo.create_run(approved_run.clone()).await;
734                tracing::info!(okr_id = %okr_id, okr_run_id = %approved_run.id, "OKR run approved and saved");
735            }
736
737            // Load provider for Ralph execution
738            let registry = if let Some(registry) = registry_for_draft {
739                registry
740            } else {
741                std::sync::Arc::new(crate::provider::ProviderRegistry::from_vault().await?)
742            };
743            let (provider, resolved_model) = resolve_provider_for_model_autochat(&registry, &model)
744                .ok_or_else(|| anyhow::anyhow!("No provider available for model '{model}'"))?;
745
746            // Wire bus for training data capture
747            let bus = crate::bus::AgentBus::new().into_arc();
748            crate::bus::s3_sink::spawn_bus_s3_sink(bus.clone());
749
750            // Execute via Ralph PRD loop — use max_concurrent as concurrency
751            let ralph_result = super::go_ralph::execute_go_ralph(
752                &task,
753                &mut okr,
754                &mut approved_run,
755                provider,
756                &resolved_model,
757                10,                     // max iterations
758                Some(bus),              // bus for training data
759                max_concurrent,         // max concurrent stories
760                Some(registry.clone()), // relay registry
761            )
762            .await?;
763
764            // Persist final run state
765            if let Ok(repo) = OkrRepository::from_config().await {
766                let _ = repo.update_run(approved_run).await;
767            }
768
769            // Display results
770            match args.format.as_str() {
771                "json" => println!(
772                    "{}",
773                    serde_json::to_string_pretty(&serde_json::json!({
774                        "passed": ralph_result.passed,
775                        "total": ralph_result.total,
776                        "all_passed": ralph_result.all_passed,
777                        "iterations": ralph_result.iterations,
778                        "feature_branch": ralph_result.feature_branch,
779                        "prd_path": ralph_result.prd_path.display().to_string(),
780                        "status": format!("{:?}", ralph_result.status),
781                        "stories": ralph_result.stories.iter().map(|s| serde_json::json!({
782                            "id": s.id,
783                            "title": s.title,
784                            "passed": s.passed,
785                        })).collect::<Vec<_>>(),
786                    }))?
787                ),
788                _ => {
789                    println!(
790                        "{}",
791                        super::go_ralph::format_go_ralph_result(&ralph_result, &task)
792                    );
793                }
794            }
795            return Ok(());
796        }
797
798        // Explicit tactical /autochat path (requires --no-prd)
799        let relay_result = run_protocol_first_relay(agent_count, &task, &model, None, None).await?;
800        match args.format.as_str() {
801            "json" => println!("{}", serde_json::to_string_pretty(&relay_result)?),
802            _ => {
803                println!("{}", relay_result.summary);
804                if let Some(failure) = &relay_result.failure {
805                    eprintln!("\nFailure detail: {}", failure);
806                }
807                eprintln!(
808                    "\n[Relay: {} | Model: {}]",
809                    relay_result.relay_id, relay_result.model
810                );
811            }
812        }
813        return Ok(());
814    }
815
816    // Create or continue session.
817    let mut session = if let Some(codex_id) = args.codex_session.clone() {
818        tracing::info!("Importing Codex session: {}", codex_id);
819        import_codex_session_by_id(&codex_id).await?
820    } else if let Some(session_id) = args.session.clone() {
821        tracing::info!("Continuing session: {}", session_id);
822        Session::load(&session_id).await?
823    } else if args.continue_session {
824        match Session::last_for_directory(Some(&workspace_dir)).await {
825            Ok(s) => {
826                tracing::info!(
827                    session_id = %s.id,
828                    workspace = %workspace_dir.display(),
829                    "Continuing last workspace session"
830                );
831                s
832            }
833            Err(err) => {
834                let s = Session::new().await?;
835                tracing::warn!(
836                    error = %err,
837                    session_id = %s.id,
838                    workspace = %workspace_dir.display(),
839                    "Session lookup failed; created new session"
840                );
841                s
842            }
843        }
844    } else {
845        let s = Session::new().await?;
846        tracing::info!("Created new session: {}", s.id);
847        s
848    };
849
850    // Set model: CLI arg > env var > config default
851    let model = args
852        .model
853        .or_else(|| std::env::var("CODETETHER_DEFAULT_MODEL").ok())
854        .or(config.default_model);
855
856    if let Some(model) = model {
857        tracing::info!("Using model: {}", model);
858        session.metadata.model = Some(model);
859    }
860
861    // Load project memory palace
862    let beliefs = crate::memory::palace::load_project_beliefs(&workspace_dir);
863    if !beliefs.is_empty() {
864        let ctx = crate::memory::palace::belief_context(&beliefs);
865        if !ctx.is_empty() {
866            tracing::info!(beliefs = beliefs.len(), "Loaded project memory palace");
867        }
868    }
869
870    session.metadata.knowledge_snapshot = knowledge_snapshot;
871    if let Some(0) = args.max_steps {
872        anyhow::bail!("--max-steps must be at least 1");
873    }
874    session.max_steps = args.max_steps;
875
876    // Wire bus for thinking capture + S3 training data
877    let bus = AgentBus::new().into_arc();
878    crate::bus::set_global(bus.clone());
879    crate::bus::s3_sink::spawn_bus_s3_sink(bus.clone());
880    session.bus = Some(bus);
881
882    let result = super::run_loop::execute_prompt_with_resume(
883        &mut session,
884        message,
885        args.max_steps,
886        args.auto_continue_until,
887        &workspace_dir,
888    )
889    .await?;
890
891    // Output based on format
892    match args.format.as_str() {
893        "json" => {
894            println!("{}", serde_json::to_string_pretty(&result)?);
895        }
896        _ => {
897            println!("{}", result.text);
898            // Show session ID for continuation
899            eprintln!(
900                "\n[Session: {} | Continue with: codetether run -c \"...\"]",
901                session.id
902            );
903        }
904    }
905
906    Ok(())
907}
908
909fn command_with_optional_args<'a>(input: &'a str, command: &str) -> Option<&'a str> {
910    let trimmed = input.trim();
911    let rest = trimmed.strip_prefix(command)?;
912
913    if rest.is_empty() {
914        return Some("");
915    }
916
917    let first = rest.chars().next()?;
918    if first.is_whitespace() {
919        Some(rest.trim())
920    } else {
921        None
922    }
923}
924
925fn normalize_cli_go_command(input: &str) -> String {
926    let trimmed = input.trim();
927    if trimmed.is_empty() || !trimmed.starts_with('/') {
928        return trimmed.to_string();
929    }
930
931    let mut parts = trimmed.splitn(2, char::is_whitespace);
932    let command = parts.next().unwrap_or("");
933    let args = parts.next().unwrap_or("").trim();
934
935    match command.to_ascii_lowercase().as_str() {
936        "/go" | "/team" => {
937            if args.is_empty() {
938                format!(
939                    "/autochat {} {}",
940                    AUTOCHAT_DEFAULT_AGENTS, AUTOCHAT_QUICK_DEMO_TASK
941                )
942            } else {
943                let mut count_and_task = args.splitn(2, char::is_whitespace);
944                let first = count_and_task.next().unwrap_or("");
945                if let Ok(count) = first.parse::<usize>() {
946                    let task = count_and_task.next().unwrap_or("").trim();
947                    if task.is_empty() {
948                        format!("/autochat {count} {AUTOCHAT_QUICK_DEMO_TASK}")
949                    } else {
950                        format!("/autochat {count} {task}")
951                    }
952                } else {
953                    format!("/autochat {} {args}", AUTOCHAT_DEFAULT_AGENTS)
954                }
955            }
956        }
957        _ => trimmed.to_string(),
958    }
959}
960
961fn is_easy_go_command(input: &str) -> bool {
962    let command = input
963        .split_whitespace()
964        .next()
965        .unwrap_or("")
966        .to_ascii_lowercase();
967
968    matches!(command.as_str(), "/go" | "/team")
969}
970
971fn normalize_go_task_input(task: &str) -> String {
972    task.split_whitespace().collect::<Vec<_>>().join(" ")
973}
974
975fn looks_like_pasted_go_run_output(task: &str) -> bool {
976    let lower = task.to_ascii_lowercase();
977    let markers = [
978        "progress:",
979        "iterations:",
980        "feature branch:",
981        "stories:",
982        "incomplete stories:",
983        "next steps:",
984        "assessment is done and documented",
985    ];
986
987    let marker_hits = markers.iter().filter(|m| lower.contains(**m)).count();
988    marker_hits >= 2 || (task.len() > 400 && lower.contains("next steps:"))
989}
990
991fn validate_easy_go_task(task: &str) -> Result<String> {
992    let normalized = normalize_go_task_input(task);
993    if normalized.is_empty() {
994        anyhow::bail!(
995            "`/go` requires a task. Example: /go implement /v1/agent compatibility routes"
996        );
997    }
998
999    if looks_like_pasted_go_run_output(&normalized) {
1000        anyhow::bail!(
1001            "`/go` received text that looks like prior run output/logs. \
1002Use a concise objective sentence instead, e.g. `/go implement /v1/agent/* compatibility routes`."
1003        );
1004    }
1005
1006    Ok(normalized)
1007}
1008
1009fn resolve_autochat_model(
1010    cli_model: Option<&str>,
1011    env_model: Option<&str>,
1012    config_model: Option<&str>,
1013    easy_go_requested: bool,
1014) -> String {
1015    if let Some(model) = cli_model.filter(|value| !value.trim().is_empty()) {
1016        return model.to_string();
1017    }
1018    if easy_go_requested {
1019        return GO_DEFAULT_MODEL.to_string();
1020    }
1021    if let Some(model) = env_model.filter(|value| !value.trim().is_empty()) {
1022        return model.to_string();
1023    }
1024    if let Some(model) = config_model.filter(|value| !value.trim().is_empty()) {
1025        return model.to_string();
1026    }
1027    "zai/glm-5".to_string()
1028}
1029
1030fn build_relay_profiles(count: usize) -> Vec<RelayProfile> {
1031    let mut profiles = Vec::with_capacity(count);
1032    for idx in 0..count {
1033        let name = format!("auto-agent-{}", idx + 1);
1034
1035        let instructions = format!(
1036            "You are @{name}.\n\
1037             Role policy: self-organize from task context and current handoff instead of assuming a fixed persona.\n\
1038             Mission: advance the relay with concrete, high-signal next actions and clear ownership boundaries.\n\n\
1039             This is a protocol-first relay conversation. Treat the incoming handoff as authoritative context.\n\
1040             Keep your response concise, concrete, and useful for the next specialist.\n\
1041             Include one clear recommendation for what the next agent should do.\n\
1042             If the task scope is too large, explicitly call out missing specialties and handoff boundaries.",
1043        );
1044        let mut capabilities = vec!["generalist".to_string(), "self-organizing".to_string()];
1045        crate::autochat::ensure_required_relay_capabilities(&mut capabilities);
1046
1047        profiles.push(RelayProfile {
1048            name,
1049            instructions,
1050            capabilities,
1051        });
1052    }
1053    profiles
1054}
1055
1056fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
1057    if max_chars == 0 {
1058        return String::new();
1059    }
1060
1061    let mut chars = value.chars();
1062    let mut output = String::new();
1063    for _ in 0..max_chars {
1064        if let Some(ch) = chars.next() {
1065            output.push(ch);
1066        } else {
1067            return value.to_string();
1068        }
1069    }
1070
1071    if chars.next().is_some() {
1072        format!("{output}...")
1073    } else {
1074        output
1075    }
1076}
1077
1078fn normalize_for_convergence(text: &str) -> String {
1079    crate::autochat::normalize_for_convergence(text, 280)
1080}
1081
1082fn extract_semantic_handoff_from_rlm(answer: &str) -> String {
1083    match FinalPayload::parse(answer) {
1084        FinalPayload::Semantic(payload) => payload.answer,
1085        _ => answer.trim().to_string(),
1086    }
1087}
1088
1089async fn prepare_autochat_handoff_with_registry(
1090    task: &str,
1091    from_agent: &str,
1092    output: &str,
1093    model_ref: &str,
1094    registry: Option<&std::sync::Arc<crate::provider::ProviderRegistry>>,
1095) -> (String, bool) {
1096    let mut used_rlm = false;
1097    let mut relay_payload = if output.len() > AUTOCHAT_RLM_THRESHOLD_CHARS {
1098        truncate_with_ellipsis(output, AUTOCHAT_RLM_FALLBACK_CHARS)
1099    } else {
1100        output.to_string()
1101    };
1102
1103    if let Some(registry) = registry
1104        && let Some((provider, model_name)) =
1105            resolve_provider_for_model_autochat(registry, model_ref)
1106    {
1107        let mut executor =
1108            RlmExecutor::new(output.to_string(), provider, model_name).with_max_iterations(2);
1109        match executor.analyze(AUTOCHAT_RLM_HANDOFF_QUERY).await {
1110            Ok(result) => {
1111                let normalized = extract_semantic_handoff_from_rlm(&result.answer);
1112                if !normalized.is_empty() {
1113                    relay_payload = normalized;
1114                    used_rlm = true;
1115                }
1116            }
1117            Err(err) => {
1118                tracing::warn!(
1119                    error = %err,
1120                    "CLI RLM handoff normalization failed; using fallback payload"
1121                );
1122            }
1123        }
1124    }
1125
1126    (
1127        format!(
1128            "Relay task:\n{task}\n\nIncoming handoff from @{from_agent}:\n{relay_payload}\n\n\
1129             Continue the work from this handoff. Keep your response focused and provide one concrete next-step instruction for the next agent."
1130        ),
1131        used_rlm,
1132    )
1133}
1134
1135async fn run_protocol_first_relay(
1136    agent_count: usize,
1137    task: &str,
1138    model_ref: &str,
1139    okr_id: Option<Uuid>,
1140    okr_run_id: Option<Uuid>,
1141) -> Result<AutochatCliResult> {
1142    let bus = AgentBus::new().into_arc();
1143
1144    // Auto-start S3 sink if MinIO is configured
1145    crate::bus::s3_sink::spawn_bus_s3_sink(bus.clone());
1146
1147    let relay = ProtocolRelayRuntime::new(bus.clone());
1148
1149    let registry = crate::provider::ProviderRegistry::from_vault()
1150        .await
1151        .ok()
1152        .map(std::sync::Arc::new);
1153
1154    let mut planner_used = false;
1155    let profiles = if let Some(registry) = &registry {
1156        if let Some(planned) =
1157            plan_relay_profiles_with_registry(task, model_ref, agent_count, registry).await
1158        {
1159            planner_used = true;
1160            planned
1161        } else {
1162            build_relay_profiles(agent_count)
1163        }
1164    } else {
1165        build_relay_profiles(agent_count)
1166    };
1167
1168    let relay_profiles: Vec<RelayAgentProfile> = profiles
1169        .iter()
1170        .map(|profile| RelayAgentProfile {
1171            name: profile.name.clone(),
1172            capabilities: profile.capabilities.clone(),
1173        })
1174        .collect();
1175
1176    let mut ordered_agents: Vec<String> = profiles
1177        .iter()
1178        .map(|profile| profile.name.clone())
1179        .collect();
1180    let mut sessions: HashMap<String, Session> = HashMap::new();
1181    let mut relay_receivers: HashMap<String, crate::bus::BusHandle> = HashMap::new();
1182    let mut agent_models: HashMap<String, String> = HashMap::new();
1183    let mut model_rotation = if let Some(registry) = &registry {
1184        build_round_robin_model_rotation(registry, model_ref).await
1185    } else {
1186        RelayModelRotation::fallback(model_ref)
1187    };
1188
1189    for profile in &profiles {
1190        let assigned_model_ref = model_rotation.next_model_ref(model_ref);
1191        let mut session = Session::new().await?;
1192        session.metadata.model = Some(assigned_model_ref.clone());
1193        session.set_agent_name(profile.name.clone());
1194        session.bus = Some(bus.clone());
1195        session.add_message(Message {
1196            role: Role::System,
1197            content: vec![ContentPart::Text {
1198                text: profile.instructions.clone(),
1199            }],
1200        });
1201        agent_models.insert(profile.name.clone(), assigned_model_ref);
1202        sessions.insert(profile.name.clone(), session);
1203        attach_handoff_receiver(&mut relay_receivers, bus.clone(), &profile.name);
1204    }
1205
1206    if ordered_agents.len() < 2 {
1207        anyhow::bail!("Autochat needs at least 2 agents to relay.");
1208    }
1209
1210    relay.register_agents(&relay_profiles);
1211    let mut context_receiver = bus.handle(format!("relay-context-{}", relay.relay_id()));
1212    let mut shared_context = SharedRelayContext::default();
1213
1214    // Load KR targets if OKR is associated
1215    let kr_targets: std::collections::HashMap<String, f64> =
1216        if let (Some(okr_id_val), Some(_run_id)) = (okr_id, okr_run_id) {
1217            if let Ok(repo) = crate::okr::persistence::OkrRepository::from_config().await {
1218                if let Ok(Some(okr)) = repo.get_okr(okr_id_val).await {
1219                    okr.key_results
1220                        .iter()
1221                        .map(|kr| (kr.id.to_string(), kr.target_value))
1222                        .collect()
1223                } else {
1224                    std::collections::HashMap::new()
1225                }
1226            } else {
1227                std::collections::HashMap::new()
1228            }
1229        } else {
1230            std::collections::HashMap::new()
1231        };
1232
1233    let mut kr_progress: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
1234
1235    let mut baton = format!(
1236        "Task:\n{task}\n\nStart by proposing an execution strategy and one immediate next step."
1237    );
1238    let mut previous_normalized: Option<String> = None;
1239    let mut convergence_hits = 0usize;
1240    let mut turns = 0usize;
1241    let mut dynamic_spawn_count = 0usize;
1242    let mut rlm_handoff_count = 0usize;
1243    let mut rlm_context_count = 0usize;
1244    let mut status = crate::autochat::AUTOCHAT_STATUS_MAX_ROUNDS_REACHED.to_string();
1245    let mut failure_note: Option<String> = None;
1246
1247    'relay_loop: for round in 1..=AUTOCHAT_MAX_ROUNDS {
1248        let mut idx = 0usize;
1249        while idx < ordered_agents.len() {
1250            let to = ordered_agents[idx].clone();
1251            let from = if idx == 0 {
1252                if round == 1 {
1253                    "user".to_string()
1254                } else {
1255                    ordered_agents[ordered_agents.len() - 1].clone()
1256                }
1257            } else {
1258                ordered_agents[idx - 1].clone()
1259            };
1260
1261            turns += 1;
1262            let _ =
1263                drain_context_updates(&mut context_receiver, relay.relay_id(), &mut shared_context);
1264            let correlation_id = relay.send_handoff(&from, &to, &baton);
1265            let consumed_handoff = match consume_handoff_by_correlation(
1266                &mut relay_receivers,
1267                &to,
1268                &correlation_id,
1269            )
1270            .await
1271            {
1272                Ok(handoff) => handoff,
1273                Err(err) => {
1274                    status = "bus_error".to_string();
1275                    failure_note = Some(format!(
1276                        "Failed to consume handoff for @{to} (correlation={correlation_id}): {err}"
1277                    ));
1278                    break 'relay_loop;
1279                }
1280            };
1281            let prompt_input = compose_prompt_with_context(&consumed_handoff, &shared_context);
1282
1283            let Some(mut session) = sessions.remove(&to) else {
1284                status = "agent_error".to_string();
1285                failure_note = Some(format!("Relay agent @{to} session was unavailable."));
1286                break 'relay_loop;
1287            };
1288
1289            let output = match session.prompt(&prompt_input).await {
1290                Ok(response) => response.text,
1291                Err(err) => {
1292                    status = "agent_error".to_string();
1293                    failure_note = Some(format!("Relay agent @{to} failed: {err}"));
1294                    sessions.insert(to, session);
1295                    break 'relay_loop;
1296                }
1297            };
1298
1299            sessions.insert(to.clone(), session);
1300
1301            let normalized = normalize_for_convergence(&output);
1302            if previous_normalized.as_deref() == Some(normalized.as_str()) {
1303                convergence_hits += 1;
1304            } else {
1305                convergence_hits = 0;
1306            }
1307            previous_normalized = Some(normalized);
1308
1309            let turn_model_ref = agent_models
1310                .get(&to)
1311                .map(String::as_str)
1312                .unwrap_or(model_ref);
1313            let (next_handoff, used_rlm) = prepare_autochat_handoff_with_registry(
1314                task,
1315                &to,
1316                &output,
1317                turn_model_ref,
1318                registry.as_ref(),
1319            )
1320            .await;
1321            if used_rlm {
1322                rlm_handoff_count += 1;
1323            }
1324            let turn_context_provider = registry
1325                .as_ref()
1326                .and_then(|r| resolve_provider_for_model_autochat(r, turn_model_ref));
1327            let (context_delta, used_context_rlm) =
1328                distill_context_delta_with_rlm(&output, task, &to, turn_context_provider).await;
1329            if used_context_rlm {
1330                rlm_context_count += 1;
1331            }
1332            shared_context.merge_delta(&context_delta);
1333            let publisher = bus.handle(to.clone());
1334            publish_context_delta(
1335                &publisher,
1336                relay.relay_id(),
1337                &to,
1338                round,
1339                turns,
1340                &context_delta,
1341            );
1342            baton = next_handoff;
1343
1344            // Update KR progress after each turn
1345            if !kr_targets.is_empty() {
1346                let max_turns = ordered_agents.len() * AUTOCHAT_MAX_ROUNDS;
1347                let progress_ratio = (turns as f64 / max_turns as f64).min(1.0);
1348
1349                for (kr_id, target) in &kr_targets {
1350                    let current = progress_ratio * target;
1351                    let existing = kr_progress.get(kr_id).copied().unwrap_or(0.0);
1352                    if current > existing {
1353                        kr_progress.insert(kr_id.clone(), current);
1354                    }
1355                }
1356
1357                // Persist mid-run (best-effort)
1358                if let Some(run_id) = okr_run_id
1359                    && let Ok(repo) = crate::okr::persistence::OkrRepository::from_config().await
1360                    && let Ok(Some(mut run)) = repo.get_run(run_id).await
1361                    && run.is_resumable()
1362                {
1363                    run.iterations = turns as u32;
1364                    for (kr_id, value) in &kr_progress {
1365                        run.update_kr_progress(kr_id, *value);
1366                    }
1367                    run.status = crate::okr::OkrRunStatus::Running;
1368                    let _ = repo.update_run(run).await;
1369                }
1370            }
1371
1372            let can_attempt_spawn = dynamic_spawn_count < AUTOCHAT_MAX_DYNAMIC_SPAWNS
1373                && ordered_agents.len() < AUTOCHAT_MAX_AGENTS
1374                && output.len() >= AUTOCHAT_SPAWN_CHECK_MIN_CHARS;
1375
1376            if can_attempt_spawn
1377                && let Some(registry) = &registry
1378                && let Some((profile, reason)) = decide_dynamic_spawn_with_registry(
1379                    task,
1380                    model_ref,
1381                    &output,
1382                    round,
1383                    &ordered_agents,
1384                    registry,
1385                )
1386                .await
1387            {
1388                match Session::new().await {
1389                    Ok(mut spawned_session) => {
1390                        let spawned_model_ref = model_rotation.next_model_ref(model_ref);
1391                        spawned_session.metadata.model = Some(spawned_model_ref.clone());
1392                        spawned_session.set_agent_name(profile.name.clone());
1393                        spawned_session.bus = Some(bus.clone());
1394                        spawned_session.add_message(Message {
1395                            role: Role::System,
1396                            content: vec![ContentPart::Text {
1397                                text: profile.instructions.clone(),
1398                            }],
1399                        });
1400
1401                        relay.register_agents(&[RelayAgentProfile {
1402                            name: profile.name.clone(),
1403                            capabilities: profile.capabilities.clone(),
1404                        }]);
1405                        attach_handoff_receiver(&mut relay_receivers, bus.clone(), &profile.name);
1406
1407                        ordered_agents.insert(idx + 1, profile.name.clone());
1408                        agent_models.insert(profile.name.clone(), spawned_model_ref);
1409                        sessions.insert(profile.name.clone(), spawned_session);
1410                        dynamic_spawn_count += 1;
1411
1412                        tracing::info!(
1413                            agent = %profile.name,
1414                            reason = %reason,
1415                            "Dynamic relay spawn accepted"
1416                        );
1417                    }
1418                    Err(err) => {
1419                        tracing::warn!(
1420                            agent = %profile.name,
1421                            error = %err,
1422                            "Dynamic relay spawn requested but failed"
1423                        );
1424                    }
1425                }
1426            }
1427
1428            if convergence_hits >= 2 {
1429                status = "converged".to_string();
1430                break 'relay_loop;
1431            }
1432
1433            idx += 1;
1434        }
1435    }
1436
1437    relay.shutdown_agents(&ordered_agents);
1438
1439    // Update OKR run with final progress if associated
1440    if let Some(run_id) = okr_run_id
1441        && let Ok(repo) = crate::okr::persistence::OkrRepository::from_config().await
1442        && let Ok(Some(mut run)) = repo.get_run(run_id).await
1443    {
1444        // Update KR progress from execution
1445        for (kr_id, value) in &kr_progress {
1446            run.update_kr_progress(kr_id, *value);
1447        }
1448
1449        // Create outcomes per KR with progress (link to actual KR IDs)
1450        let base_evidence = vec![
1451            format!("relay:{}", relay.relay_id()),
1452            format!("turns:{}", turns),
1453            format!("agents:{}", ordered_agents.len()),
1454            format!("status:{}", status),
1455            format!("rlm_handoffs:{}", rlm_handoff_count),
1456            format!("dynamic_spawns:{}", dynamic_spawn_count),
1457        ];
1458
1459        let outcome_type = if status == "converged" {
1460            crate::okr::KrOutcomeType::FeatureDelivered
1461        } else {
1462            crate::okr::KrOutcomeType::Evidence
1463        };
1464
1465        // Create one outcome per KR, linked to the actual KR ID
1466        for (kr_id_str, value) in &kr_progress {
1467            // Parse KR ID with guardrail to prevent NIL UUID linkage
1468            if let Some(kr_uuid) = parse_uuid_guarded(kr_id_str, "cli_relay_outcome_kr_link") {
1469                let kr_description = format!(
1470                    "CLI relay outcome for KR {}: {} agents, {} turns, status={}",
1471                    kr_id_str,
1472                    ordered_agents.len(),
1473                    turns,
1474                    status
1475                );
1476                run.outcomes.push({
1477                    let mut outcome =
1478                        crate::okr::KrOutcome::new(kr_uuid, kr_description).with_value(*value);
1479                    outcome.run_id = Some(run.id);
1480                    outcome.outcome_type = outcome_type;
1481                    outcome.evidence = base_evidence.clone();
1482                    outcome.source = "cli relay".to_string();
1483                    outcome
1484                });
1485            }
1486        }
1487
1488        // Mark complete or update status based on execution result
1489        if status == "converged" {
1490            run.complete();
1491        } else if status == "agent_error" || status == "bus_error" {
1492            run.status = crate::okr::OkrRunStatus::Failed;
1493        } else {
1494            run.status = crate::okr::OkrRunStatus::Completed;
1495        }
1496        let _ = repo.update_run(run).await;
1497    }
1498
1499    let mut summary = format!(
1500        "Autochat complete ({status}) — relay {} with {} agents over {} turns.\n\nFinal relay handoff:\n{}",
1501        relay.relay_id(),
1502        ordered_agents.len(),
1503        turns,
1504        truncate_with_ellipsis(&baton, 4_000)
1505    );
1506    if let Some(note) = &failure_note {
1507        summary.push_str(&format!("\n\nFailure detail: {note}"));
1508    }
1509    if planner_used {
1510        summary.push_str("\n\nTeam planning: model-organized profiles.");
1511    } else {
1512        summary.push_str("\n\nTeam planning: fallback self-organizing profiles.");
1513    }
1514    if rlm_handoff_count > 0 {
1515        summary.push_str(&format!("\nRLM-normalized handoffs: {rlm_handoff_count}"));
1516    }
1517    if rlm_context_count > 0 {
1518        summary.push_str(&format!("\nRLM context deltas: {rlm_context_count}"));
1519    }
1520    if shared_context.item_count() > 0 {
1521        summary.push_str(&format!(
1522            "\nShared context items: {}",
1523            shared_context.item_count()
1524        ));
1525    }
1526    if dynamic_spawn_count > 0 {
1527        summary.push_str(&format!("\nDynamic relay spawns: {dynamic_spawn_count}"));
1528    }
1529
1530    Ok(AutochatCliResult {
1531        status,
1532        relay_id: relay.relay_id().to_string(),
1533        model: model_ref.to_string(),
1534        agent_count: ordered_agents.len(),
1535        turns,
1536        agents: ordered_agents,
1537        final_handoff: baton,
1538        summary,
1539        failure: failure_note,
1540        shared_context_items: shared_context.item_count(),
1541        rlm_context_count,
1542    })
1543}
1544
1545#[cfg(test)]
1546mod tests {
1547    use super::PlannedRelayProfile;
1548    use super::{
1549        AUTOCHAT_QUICK_DEMO_TASK, PlannedRelayResponse, build_runtime_profile_from_plan,
1550        command_with_optional_args, extract_json_payload, is_easy_go_command,
1551        normalize_cli_go_command, normalize_go_task_input, resolve_autochat_model,
1552        validate_easy_go_task,
1553    };
1554
1555    #[test]
1556    fn normalize_go_maps_to_autochat_with_count_and_task() {
1557        assert_eq!(
1558            normalize_cli_go_command("/go 4 build protocol relay"),
1559            "/autochat 4 build protocol relay"
1560        );
1561    }
1562
1563    #[test]
1564    fn normalize_go_count_only_uses_demo_task() {
1565        assert_eq!(
1566            normalize_cli_go_command("/go 4"),
1567            format!("/autochat 4 {AUTOCHAT_QUICK_DEMO_TASK}")
1568        );
1569    }
1570
1571    #[test]
1572    fn parse_autochat_args_supports_default_count() {
1573        let parsed =
1574            crate::autochat::parse_autochat_request("build a relay", 3, AUTOCHAT_QUICK_DEMO_TASK)
1575                .expect("valid args");
1576        assert_eq!(
1577            (parsed.agent_count, parsed.task.as_str()),
1578            (3, "build a relay"),
1579        );
1580    }
1581
1582    #[test]
1583    fn parse_autochat_args_supports_explicit_count() {
1584        let parsed =
1585            crate::autochat::parse_autochat_request("4 build a relay", 3, AUTOCHAT_QUICK_DEMO_TASK)
1586                .expect("valid args");
1587        assert_eq!(
1588            (parsed.agent_count, parsed.task.as_str()),
1589            (4, "build a relay"),
1590        );
1591    }
1592
1593    #[test]
1594    fn normalize_go_task_collapses_whitespace() {
1595        assert_eq!(
1596            normalize_go_task_input(" implement   api\ncompat routes\twith tests "),
1597            "implement api compat routes with tests"
1598        );
1599    }
1600
1601    #[test]
1602    fn validate_go_task_rejects_pasted_run_output() {
1603        let pasted =
1604            "Task: foo Progress: 0/7 stories Iterations: 7/10 Incomplete stories: ... Next steps:";
1605        assert!(validate_easy_go_task(pasted).is_err());
1606    }
1607
1608    #[test]
1609    fn command_with_optional_args_avoids_prefix_collision() {
1610        assert_eq!(command_with_optional_args("/autochatty", "/autochat"), None);
1611    }
1612
1613    #[test]
1614    fn easy_go_detection_handles_aliases() {
1615        assert!(is_easy_go_command("/go 4 task"));
1616        assert!(is_easy_go_command("/team 4 task"));
1617        assert!(!is_easy_go_command("/autochat 4 task"));
1618    }
1619
1620    #[test]
1621    fn easy_go_defaults_to_minimax_when_model_not_set() {
1622        assert_eq!(
1623            resolve_autochat_model(None, None, Some("zai/glm-5"), true),
1624            "minimax-credits/MiniMax-M2.5-highspeed"
1625        );
1626    }
1627
1628    #[test]
1629    fn explicit_model_wins_over_easy_go_default() {
1630        assert_eq!(
1631            resolve_autochat_model(Some("zai/glm-5"), None, None, true),
1632            "zai/glm-5"
1633        );
1634    }
1635
1636    #[test]
1637    fn extract_json_payload_parses_markdown_wrapped_json() {
1638        let wrapped = "Here is the plan:\n```json\n{\"profiles\":[{\"name\":\"auto-db\",\"specialty\":\"database\",\"mission\":\"Own schema and queries\",\"capabilities\":[\"sql\",\"indexing\"]}]}\n```";
1639        let parsed: PlannedRelayResponse =
1640            extract_json_payload(wrapped).expect("should parse wrapped JSON");
1641        assert_eq!(parsed.profiles.len(), 1);
1642        assert_eq!(parsed.profiles[0].name, "auto-db");
1643    }
1644
1645    #[test]
1646    fn build_runtime_profile_normalizes_and_deduplicates_name() {
1647        let planned = PlannedRelayProfile {
1648            name: "Data Specialist".to_string(),
1649            specialty: "data engineering".to_string(),
1650            mission: "Prepare datasets for downstream coding".to_string(),
1651            capabilities: vec!["ETL".to_string(), "sql".to_string()],
1652        };
1653
1654        let profile =
1655            build_runtime_profile_from_plan(planned, &["auto-data-specialist".to_string()])
1656                .expect("profile should be built");
1657
1658        assert_eq!(profile.name, "auto-data-specialist-2");
1659        assert!(profile.capabilities.iter().any(|cap| cap == "relay"));
1660        assert!(profile.capabilities.iter().any(|cap| cap == "autochat"));
1661    }
1662}