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
558    if message.is_empty() {
559        anyhow::bail!("You must provide a message");
560    }
561
562    tracing::info!("Running with message: {}", message);
563
564    if args.branches > 1 {
565        let runner = crate::swarm::speculative::SpeculativeRunner::new(
566            args.branches,
567            args.strategies.clone(),
568        );
569        let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
570        let specs = runner.build_branches(&cwd, message)?;
571        tracing::info!(branches = %runner.branch_count, "Many-worlds speculative mode enabled");
572        for spec in &specs {
573            tracing::info!(
574                branch = %spec.branch_name,
575                strategy = %spec.strategy_prompt,
576                "Speculative branch assigned"
577            );
578        }
579        println!(
580            "[speculative] {} parallel branches queued (collapse controller will race + prune)",
581            runner.branch_count
582        );
583        // TODO: wire through SwarmExecutor when parallel dispatch is ready
584    }
585
586    // Load configuration
587    let config = Config::load().await.unwrap_or_default();
588    let workspace_dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
589    let knowledge_snapshot =
590        match crate::indexer::refresh_workspace_knowledge_snapshot(&workspace_dir).await {
591            Ok(path) => {
592                tracing::info!(
593                    workspace = %workspace_dir.display(),
594                    output = %path.display(),
595                    "Refreshed workspace knowledge snapshot for run"
596                );
597                Some(path)
598            }
599            Err(e) => {
600                tracing::warn!(
601                    workspace = %workspace_dir.display(),
602                    error = %e,
603                    "Failed to refresh workspace knowledge snapshot"
604                );
605                None
606            }
607        };
608
609    // Protocol-first relay aliases in CLI:
610    // - /go [count] <task>
611    // - /autochat [count] <task>
612    let easy_go_requested = is_easy_go_command(message);
613    let normalized = normalize_cli_go_command(message);
614    if let Some(rest) = command_with_optional_args(&normalized, "/autochat") {
615        let Some(parsed) = crate::autochat::parse_autochat_request(
616            rest,
617            AUTOCHAT_DEFAULT_AGENTS,
618            AUTOCHAT_QUICK_DEMO_TASK,
619        ) else {
620            anyhow::bail!(
621                "Usage: /autochat [count] [--no-prd] <task>\nEasy mode: /go <task>\ncount range: 2-{} (default: {})",
622                AUTOCHAT_MAX_AGENTS,
623                AUTOCHAT_DEFAULT_AGENTS
624            );
625        };
626        let agent_count = parsed.agent_count;
627        let parsed_task = parsed.task;
628        let task = if easy_go_requested {
629            validate_easy_go_task(&parsed_task)?
630        } else {
631            normalize_go_task_input(&parsed_task)
632        };
633        let require_prd = easy_go_requested || !parsed.bypass_prd;
634
635        if !(2..=AUTOCHAT_MAX_AGENTS).contains(&agent_count) {
636            anyhow::bail!(
637                "Invalid relay size {}. count must be between 2 and {}",
638                agent_count,
639                AUTOCHAT_MAX_AGENTS
640            );
641        }
642
643        let model = resolve_autochat_model(
644            args.model.as_deref(),
645            std::env::var("CODETETHER_DEFAULT_MODEL").ok().as_deref(),
646            config.default_model.as_deref(),
647            easy_go_requested,
648        );
649
650        // PRD-gated execution path (mandatory for /go and default for /autochat)
651        if require_prd {
652            // For /go, default to max concurrency (run all stories in parallel) unless
653            // the user explicitly specified a count. /autochat keeps the requested count.
654            let max_concurrent = if easy_go_requested && !parsed.explicit_count {
655                AUTOCHAT_MAX_AGENTS
656            } else {
657                agent_count
658            };
659            // Create OKR draft (LLM-proposed, with safe fallback)
660            let okr_id = Uuid::new_v4();
661            let registry_for_draft = crate::provider::ProviderRegistry::from_vault()
662                .await
663                .ok()
664                .map(std::sync::Arc::new);
665
666            let (mut okr, draft_note) = if let Some(registry) = &registry_for_draft {
667                match plan_okr_draft_with_registry(&task, &model, agent_count, registry).await {
668                    Some(planned) => (okr_from_planned_draft(okr_id, &task, planned), None),
669                    None => (
670                        default_relay_okr_template(okr_id, &task),
671                        Some("(OKR: fallback template — model draft parse failed)".to_string()),
672                    ),
673                }
674            } else {
675                (
676                    default_relay_okr_template(okr_id, &task),
677                    Some("(OKR: fallback template — provider unavailable)".to_string()),
678                )
679            };
680
681            // Create run
682            let mut run = OkrRun::new(
683                okr_id,
684                format!("Run {}", chrono::Local::now().format("%Y-%m-%d %H:%M")),
685            );
686            let _ = run.submit_for_approval();
687
688            // Show OKR draft
689            let command_label = if easy_go_requested {
690                "/go"
691            } else {
692                "/autochat"
693            };
694            println!("\n⚠️  {command_label} OKR Draft\n");
695            println!("Task: {}", truncate_with_ellipsis(&task, 80));
696            println!("Agents: {} | Model: {}", agent_count, model);
697            if let Some(note) = draft_note {
698                println!("{}", note);
699            }
700            println!("\nObjective: {}", okr.title);
701            println!("\nKey Results:");
702            for kr in &okr.key_results {
703                println!("  • {} (target: {} {})", kr.title, kr.target_value, kr.unit);
704            }
705            println!("\n");
706
707            // Prompt for approval
708            print!("Approve OKR and start relay? [y/n]: ");
709            std::io::stdout().flush()?;
710            let mut input = String::new();
711            std::io::stdin().read_line(&mut input)?;
712
713            let input = input.trim().to_lowercase();
714            if input != "y" && input != "yes" {
715                run.record_decision(ApprovalDecision::deny(run.id, "User denied via CLI"));
716                println!("❌ OKR denied. Relay not started.");
717                println!("Use /autochat --no-prd for tactical execution without OKR/PRD tracking.");
718                return Ok(());
719            }
720
721            println!("✅ OKR approved! Starting Ralph PRD execution...\n");
722
723            // Save OKR and run
724            let mut approved_run = run;
725            if let Ok(repo) = OkrRepository::from_config().await {
726                let _ = repo.create_okr(okr.clone()).await;
727                approved_run.record_decision(ApprovalDecision::approve(
728                    approved_run.id,
729                    "User approved via CLI",
730                ));
731                approved_run.correlation_id = Some(format!("ralph-{}", Uuid::new_v4()));
732                let _ = repo.create_run(approved_run.clone()).await;
733                tracing::info!(okr_id = %okr_id, okr_run_id = %approved_run.id, "OKR run approved and saved");
734            }
735
736            // Load provider for Ralph execution
737            let registry = if let Some(registry) = registry_for_draft {
738                registry
739            } else {
740                std::sync::Arc::new(crate::provider::ProviderRegistry::from_vault().await?)
741            };
742            let (provider, resolved_model) = resolve_provider_for_model_autochat(&registry, &model)
743                .ok_or_else(|| anyhow::anyhow!("No provider available for model '{model}'"))?;
744
745            // Wire bus for training data capture
746            let bus = crate::bus::AgentBus::new().into_arc();
747            crate::bus::s3_sink::spawn_bus_s3_sink(bus.clone());
748
749            // Execute via Ralph PRD loop — use max_concurrent as concurrency
750            let ralph_result = super::go_ralph::execute_go_ralph(
751                &task,
752                &mut okr,
753                &mut approved_run,
754                provider,
755                &resolved_model,
756                10,                     // max iterations
757                Some(bus),              // bus for training data
758                max_concurrent,         // max concurrent stories
759                Some(registry.clone()), // relay registry
760            )
761            .await?;
762
763            // Persist final run state
764            if let Ok(repo) = OkrRepository::from_config().await {
765                let _ = repo.update_run(approved_run).await;
766            }
767
768            // Display results
769            match args.format.as_str() {
770                "json" => println!(
771                    "{}",
772                    serde_json::to_string_pretty(&serde_json::json!({
773                        "passed": ralph_result.passed,
774                        "total": ralph_result.total,
775                        "all_passed": ralph_result.all_passed,
776                        "iterations": ralph_result.iterations,
777                        "feature_branch": ralph_result.feature_branch,
778                        "prd_path": ralph_result.prd_path.display().to_string(),
779                        "status": format!("{:?}", ralph_result.status),
780                        "stories": ralph_result.stories.iter().map(|s| serde_json::json!({
781                            "id": s.id,
782                            "title": s.title,
783                            "passed": s.passed,
784                        })).collect::<Vec<_>>(),
785                    }))?
786                ),
787                _ => {
788                    println!(
789                        "{}",
790                        super::go_ralph::format_go_ralph_result(&ralph_result, &task)
791                    );
792                }
793            }
794            return Ok(());
795        }
796
797        // Explicit tactical /autochat path (requires --no-prd)
798        let relay_result = run_protocol_first_relay(agent_count, &task, &model, None, None).await?;
799        match args.format.as_str() {
800            "json" => println!("{}", serde_json::to_string_pretty(&relay_result)?),
801            _ => {
802                println!("{}", relay_result.summary);
803                if let Some(failure) = &relay_result.failure {
804                    eprintln!("\nFailure detail: {}", failure);
805                }
806                eprintln!(
807                    "\n[Relay: {} | Model: {}]",
808                    relay_result.relay_id, relay_result.model
809                );
810            }
811        }
812        return Ok(());
813    }
814
815    // Create or continue session.
816    let mut session = if let Some(codex_id) = args.codex_session.clone() {
817        tracing::info!("Importing Codex session: {}", codex_id);
818        import_codex_session_by_id(&codex_id).await?
819    } else if let Some(session_id) = args.session.clone() {
820        tracing::info!("Continuing session: {}", session_id);
821        Session::load(&session_id).await?
822    } else if args.continue_session {
823        match Session::last_for_directory(Some(&workspace_dir)).await {
824            Ok(s) => {
825                tracing::info!(
826                    session_id = %s.id,
827                    workspace = %workspace_dir.display(),
828                    "Continuing last workspace session"
829                );
830                s
831            }
832            Err(err) => {
833                let s = Session::new().await?;
834                tracing::warn!(
835                    error = %err,
836                    session_id = %s.id,
837                    workspace = %workspace_dir.display(),
838                    "Session lookup failed; created new session"
839                );
840                s
841            }
842        }
843    } else {
844        let s = Session::new().await?;
845        tracing::info!("Created new session: {}", s.id);
846        s
847    };
848
849    // Set model: CLI arg > env var > config default
850    let model = args
851        .model
852        .or_else(|| std::env::var("CODETETHER_DEFAULT_MODEL").ok())
853        .or(config.default_model);
854
855    if let Some(model) = model {
856        tracing::info!("Using model: {}", model);
857        session.metadata.model = Some(model);
858    }
859
860    // Load project memory palace
861    let beliefs = crate::memory::palace::load_project_beliefs(&workspace_dir);
862    if !beliefs.is_empty() {
863        let ctx = crate::memory::palace::belief_context(&beliefs);
864        if !ctx.is_empty() {
865            tracing::info!(beliefs = beliefs.len(), "Loaded project memory palace");
866        }
867    }
868
869    session.metadata.knowledge_snapshot = knowledge_snapshot;
870    if let Some(0) = args.max_steps {
871        anyhow::bail!("--max-steps must be at least 1");
872    }
873    session.max_steps = args.max_steps;
874
875    // Wire bus for thinking capture + S3 training data
876    let bus = AgentBus::new().into_arc();
877    crate::bus::set_global(bus.clone());
878    crate::bus::s3_sink::spawn_bus_s3_sink(bus.clone());
879    session.bus = Some(bus);
880
881    // Execute the prompt
882    let result = session.prompt(message).await?;
883
884    // Output based on format
885    match args.format.as_str() {
886        "json" => {
887            println!("{}", serde_json::to_string_pretty(&result)?);
888        }
889        _ => {
890            println!("{}", result.text);
891            // Show session ID for continuation
892            eprintln!(
893                "\n[Session: {} | Continue with: codetether run -c \"...\"]",
894                session.id
895            );
896        }
897    }
898
899    Ok(())
900}
901
902fn command_with_optional_args<'a>(input: &'a str, command: &str) -> Option<&'a str> {
903    let trimmed = input.trim();
904    let rest = trimmed.strip_prefix(command)?;
905
906    if rest.is_empty() {
907        return Some("");
908    }
909
910    let first = rest.chars().next()?;
911    if first.is_whitespace() {
912        Some(rest.trim())
913    } else {
914        None
915    }
916}
917
918fn normalize_cli_go_command(input: &str) -> String {
919    let trimmed = input.trim();
920    if trimmed.is_empty() || !trimmed.starts_with('/') {
921        return trimmed.to_string();
922    }
923
924    let mut parts = trimmed.splitn(2, char::is_whitespace);
925    let command = parts.next().unwrap_or("");
926    let args = parts.next().unwrap_or("").trim();
927
928    match command.to_ascii_lowercase().as_str() {
929        "/go" | "/team" => {
930            if args.is_empty() {
931                format!(
932                    "/autochat {} {}",
933                    AUTOCHAT_DEFAULT_AGENTS, AUTOCHAT_QUICK_DEMO_TASK
934                )
935            } else {
936                let mut count_and_task = args.splitn(2, char::is_whitespace);
937                let first = count_and_task.next().unwrap_or("");
938                if let Ok(count) = first.parse::<usize>() {
939                    let task = count_and_task.next().unwrap_or("").trim();
940                    if task.is_empty() {
941                        format!("/autochat {count} {AUTOCHAT_QUICK_DEMO_TASK}")
942                    } else {
943                        format!("/autochat {count} {task}")
944                    }
945                } else {
946                    format!("/autochat {} {args}", AUTOCHAT_DEFAULT_AGENTS)
947                }
948            }
949        }
950        _ => trimmed.to_string(),
951    }
952}
953
954fn is_easy_go_command(input: &str) -> bool {
955    let command = input
956        .split_whitespace()
957        .next()
958        .unwrap_or("")
959        .to_ascii_lowercase();
960
961    matches!(command.as_str(), "/go" | "/team")
962}
963
964fn normalize_go_task_input(task: &str) -> String {
965    task.split_whitespace().collect::<Vec<_>>().join(" ")
966}
967
968fn looks_like_pasted_go_run_output(task: &str) -> bool {
969    let lower = task.to_ascii_lowercase();
970    let markers = [
971        "progress:",
972        "iterations:",
973        "feature branch:",
974        "stories:",
975        "incomplete stories:",
976        "next steps:",
977        "assessment is done and documented",
978    ];
979
980    let marker_hits = markers.iter().filter(|m| lower.contains(**m)).count();
981    marker_hits >= 2 || (task.len() > 400 && lower.contains("next steps:"))
982}
983
984fn validate_easy_go_task(task: &str) -> Result<String> {
985    let normalized = normalize_go_task_input(task);
986    if normalized.is_empty() {
987        anyhow::bail!(
988            "`/go` requires a task. Example: /go implement /v1/agent compatibility routes"
989        );
990    }
991
992    if looks_like_pasted_go_run_output(&normalized) {
993        anyhow::bail!(
994            "`/go` received text that looks like prior run output/logs. \
995Use a concise objective sentence instead, e.g. `/go implement /v1/agent/* compatibility routes`."
996        );
997    }
998
999    Ok(normalized)
1000}
1001
1002fn resolve_autochat_model(
1003    cli_model: Option<&str>,
1004    env_model: Option<&str>,
1005    config_model: Option<&str>,
1006    easy_go_requested: bool,
1007) -> String {
1008    if let Some(model) = cli_model.filter(|value| !value.trim().is_empty()) {
1009        return model.to_string();
1010    }
1011    if easy_go_requested {
1012        return GO_DEFAULT_MODEL.to_string();
1013    }
1014    if let Some(model) = env_model.filter(|value| !value.trim().is_empty()) {
1015        return model.to_string();
1016    }
1017    if let Some(model) = config_model.filter(|value| !value.trim().is_empty()) {
1018        return model.to_string();
1019    }
1020    "zai/glm-5".to_string()
1021}
1022
1023fn build_relay_profiles(count: usize) -> Vec<RelayProfile> {
1024    let mut profiles = Vec::with_capacity(count);
1025    for idx in 0..count {
1026        let name = format!("auto-agent-{}", idx + 1);
1027
1028        let instructions = format!(
1029            "You are @{name}.\n\
1030             Role policy: self-organize from task context and current handoff instead of assuming a fixed persona.\n\
1031             Mission: advance the relay with concrete, high-signal next actions and clear ownership boundaries.\n\n\
1032             This is a protocol-first relay conversation. Treat the incoming handoff as authoritative context.\n\
1033             Keep your response concise, concrete, and useful for the next specialist.\n\
1034             Include one clear recommendation for what the next agent should do.\n\
1035             If the task scope is too large, explicitly call out missing specialties and handoff boundaries.",
1036        );
1037        let mut capabilities = vec!["generalist".to_string(), "self-organizing".to_string()];
1038        crate::autochat::ensure_required_relay_capabilities(&mut capabilities);
1039
1040        profiles.push(RelayProfile {
1041            name,
1042            instructions,
1043            capabilities,
1044        });
1045    }
1046    profiles
1047}
1048
1049fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
1050    if max_chars == 0 {
1051        return String::new();
1052    }
1053
1054    let mut chars = value.chars();
1055    let mut output = String::new();
1056    for _ in 0..max_chars {
1057        if let Some(ch) = chars.next() {
1058            output.push(ch);
1059        } else {
1060            return value.to_string();
1061        }
1062    }
1063
1064    if chars.next().is_some() {
1065        format!("{output}...")
1066    } else {
1067        output
1068    }
1069}
1070
1071fn normalize_for_convergence(text: &str) -> String {
1072    crate::autochat::normalize_for_convergence(text, 280)
1073}
1074
1075fn extract_semantic_handoff_from_rlm(answer: &str) -> String {
1076    match FinalPayload::parse(answer) {
1077        FinalPayload::Semantic(payload) => payload.answer,
1078        _ => answer.trim().to_string(),
1079    }
1080}
1081
1082async fn prepare_autochat_handoff_with_registry(
1083    task: &str,
1084    from_agent: &str,
1085    output: &str,
1086    model_ref: &str,
1087    registry: Option<&std::sync::Arc<crate::provider::ProviderRegistry>>,
1088) -> (String, bool) {
1089    let mut used_rlm = false;
1090    let mut relay_payload = if output.len() > AUTOCHAT_RLM_THRESHOLD_CHARS {
1091        truncate_with_ellipsis(output, AUTOCHAT_RLM_FALLBACK_CHARS)
1092    } else {
1093        output.to_string()
1094    };
1095
1096    if let Some(registry) = registry
1097        && let Some((provider, model_name)) =
1098            resolve_provider_for_model_autochat(registry, model_ref)
1099    {
1100        let mut executor =
1101            RlmExecutor::new(output.to_string(), provider, model_name).with_max_iterations(2);
1102        match executor.analyze(AUTOCHAT_RLM_HANDOFF_QUERY).await {
1103            Ok(result) => {
1104                let normalized = extract_semantic_handoff_from_rlm(&result.answer);
1105                if !normalized.is_empty() {
1106                    relay_payload = normalized;
1107                    used_rlm = true;
1108                }
1109            }
1110            Err(err) => {
1111                tracing::warn!(
1112                    error = %err,
1113                    "CLI RLM handoff normalization failed; using fallback payload"
1114                );
1115            }
1116        }
1117    }
1118
1119    (
1120        format!(
1121            "Relay task:\n{task}\n\nIncoming handoff from @{from_agent}:\n{relay_payload}\n\n\
1122             Continue the work from this handoff. Keep your response focused and provide one concrete next-step instruction for the next agent."
1123        ),
1124        used_rlm,
1125    )
1126}
1127
1128async fn run_protocol_first_relay(
1129    agent_count: usize,
1130    task: &str,
1131    model_ref: &str,
1132    okr_id: Option<Uuid>,
1133    okr_run_id: Option<Uuid>,
1134) -> Result<AutochatCliResult> {
1135    let bus = AgentBus::new().into_arc();
1136
1137    // Auto-start S3 sink if MinIO is configured
1138    crate::bus::s3_sink::spawn_bus_s3_sink(bus.clone());
1139
1140    let relay = ProtocolRelayRuntime::new(bus.clone());
1141
1142    let registry = crate::provider::ProviderRegistry::from_vault()
1143        .await
1144        .ok()
1145        .map(std::sync::Arc::new);
1146
1147    let mut planner_used = false;
1148    let profiles = if let Some(registry) = &registry {
1149        if let Some(planned) =
1150            plan_relay_profiles_with_registry(task, model_ref, agent_count, registry).await
1151        {
1152            planner_used = true;
1153            planned
1154        } else {
1155            build_relay_profiles(agent_count)
1156        }
1157    } else {
1158        build_relay_profiles(agent_count)
1159    };
1160
1161    let relay_profiles: Vec<RelayAgentProfile> = profiles
1162        .iter()
1163        .map(|profile| RelayAgentProfile {
1164            name: profile.name.clone(),
1165            capabilities: profile.capabilities.clone(),
1166        })
1167        .collect();
1168
1169    let mut ordered_agents: Vec<String> = profiles
1170        .iter()
1171        .map(|profile| profile.name.clone())
1172        .collect();
1173    let mut sessions: HashMap<String, Session> = HashMap::new();
1174    let mut relay_receivers: HashMap<String, crate::bus::BusHandle> = HashMap::new();
1175    let mut agent_models: HashMap<String, String> = HashMap::new();
1176    let mut model_rotation = if let Some(registry) = &registry {
1177        build_round_robin_model_rotation(registry, model_ref).await
1178    } else {
1179        RelayModelRotation::fallback(model_ref)
1180    };
1181
1182    for profile in &profiles {
1183        let assigned_model_ref = model_rotation.next_model_ref(model_ref);
1184        let mut session = Session::new().await?;
1185        session.metadata.model = Some(assigned_model_ref.clone());
1186        session.set_agent_name(profile.name.clone());
1187        session.bus = Some(bus.clone());
1188        session.add_message(Message {
1189            role: Role::System,
1190            content: vec![ContentPart::Text {
1191                text: profile.instructions.clone(),
1192            }],
1193        });
1194        agent_models.insert(profile.name.clone(), assigned_model_ref);
1195        sessions.insert(profile.name.clone(), session);
1196        attach_handoff_receiver(&mut relay_receivers, bus.clone(), &profile.name);
1197    }
1198
1199    if ordered_agents.len() < 2 {
1200        anyhow::bail!("Autochat needs at least 2 agents to relay.");
1201    }
1202
1203    relay.register_agents(&relay_profiles);
1204    let mut context_receiver = bus.handle(format!("relay-context-{}", relay.relay_id()));
1205    let mut shared_context = SharedRelayContext::default();
1206
1207    // Load KR targets if OKR is associated
1208    let kr_targets: std::collections::HashMap<String, f64> =
1209        if let (Some(okr_id_val), Some(_run_id)) = (okr_id, okr_run_id) {
1210            if let Ok(repo) = crate::okr::persistence::OkrRepository::from_config().await {
1211                if let Ok(Some(okr)) = repo.get_okr(okr_id_val).await {
1212                    okr.key_results
1213                        .iter()
1214                        .map(|kr| (kr.id.to_string(), kr.target_value))
1215                        .collect()
1216                } else {
1217                    std::collections::HashMap::new()
1218                }
1219            } else {
1220                std::collections::HashMap::new()
1221            }
1222        } else {
1223            std::collections::HashMap::new()
1224        };
1225
1226    let mut kr_progress: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
1227
1228    let mut baton = format!(
1229        "Task:\n{task}\n\nStart by proposing an execution strategy and one immediate next step."
1230    );
1231    let mut previous_normalized: Option<String> = None;
1232    let mut convergence_hits = 0usize;
1233    let mut turns = 0usize;
1234    let mut dynamic_spawn_count = 0usize;
1235    let mut rlm_handoff_count = 0usize;
1236    let mut rlm_context_count = 0usize;
1237    let mut status = crate::autochat::AUTOCHAT_STATUS_MAX_ROUNDS_REACHED.to_string();
1238    let mut failure_note: Option<String> = None;
1239
1240    'relay_loop: for round in 1..=AUTOCHAT_MAX_ROUNDS {
1241        let mut idx = 0usize;
1242        while idx < ordered_agents.len() {
1243            let to = ordered_agents[idx].clone();
1244            let from = if idx == 0 {
1245                if round == 1 {
1246                    "user".to_string()
1247                } else {
1248                    ordered_agents[ordered_agents.len() - 1].clone()
1249                }
1250            } else {
1251                ordered_agents[idx - 1].clone()
1252            };
1253
1254            turns += 1;
1255            let _ =
1256                drain_context_updates(&mut context_receiver, relay.relay_id(), &mut shared_context);
1257            let correlation_id = relay.send_handoff(&from, &to, &baton);
1258            let consumed_handoff = match consume_handoff_by_correlation(
1259                &mut relay_receivers,
1260                &to,
1261                &correlation_id,
1262            )
1263            .await
1264            {
1265                Ok(handoff) => handoff,
1266                Err(err) => {
1267                    status = "bus_error".to_string();
1268                    failure_note = Some(format!(
1269                        "Failed to consume handoff for @{to} (correlation={correlation_id}): {err}"
1270                    ));
1271                    break 'relay_loop;
1272                }
1273            };
1274            let prompt_input = compose_prompt_with_context(&consumed_handoff, &shared_context);
1275
1276            let Some(mut session) = sessions.remove(&to) else {
1277                status = "agent_error".to_string();
1278                failure_note = Some(format!("Relay agent @{to} session was unavailable."));
1279                break 'relay_loop;
1280            };
1281
1282            let output = match session.prompt(&prompt_input).await {
1283                Ok(response) => response.text,
1284                Err(err) => {
1285                    status = "agent_error".to_string();
1286                    failure_note = Some(format!("Relay agent @{to} failed: {err}"));
1287                    sessions.insert(to, session);
1288                    break 'relay_loop;
1289                }
1290            };
1291
1292            sessions.insert(to.clone(), session);
1293
1294            let normalized = normalize_for_convergence(&output);
1295            if previous_normalized.as_deref() == Some(normalized.as_str()) {
1296                convergence_hits += 1;
1297            } else {
1298                convergence_hits = 0;
1299            }
1300            previous_normalized = Some(normalized);
1301
1302            let turn_model_ref = agent_models
1303                .get(&to)
1304                .map(String::as_str)
1305                .unwrap_or(model_ref);
1306            let (next_handoff, used_rlm) = prepare_autochat_handoff_with_registry(
1307                task,
1308                &to,
1309                &output,
1310                turn_model_ref,
1311                registry.as_ref(),
1312            )
1313            .await;
1314            if used_rlm {
1315                rlm_handoff_count += 1;
1316            }
1317            let turn_context_provider = registry
1318                .as_ref()
1319                .and_then(|r| resolve_provider_for_model_autochat(r, turn_model_ref));
1320            let (context_delta, used_context_rlm) =
1321                distill_context_delta_with_rlm(&output, task, &to, turn_context_provider).await;
1322            if used_context_rlm {
1323                rlm_context_count += 1;
1324            }
1325            shared_context.merge_delta(&context_delta);
1326            let publisher = bus.handle(to.clone());
1327            publish_context_delta(
1328                &publisher,
1329                relay.relay_id(),
1330                &to,
1331                round,
1332                turns,
1333                &context_delta,
1334            );
1335            baton = next_handoff;
1336
1337            // Update KR progress after each turn
1338            if !kr_targets.is_empty() {
1339                let max_turns = ordered_agents.len() * AUTOCHAT_MAX_ROUNDS;
1340                let progress_ratio = (turns as f64 / max_turns as f64).min(1.0);
1341
1342                for (kr_id, target) in &kr_targets {
1343                    let current = progress_ratio * target;
1344                    let existing = kr_progress.get(kr_id).copied().unwrap_or(0.0);
1345                    if current > existing {
1346                        kr_progress.insert(kr_id.clone(), current);
1347                    }
1348                }
1349
1350                // Persist mid-run (best-effort)
1351                if let Some(run_id) = okr_run_id
1352                    && let Ok(repo) = crate::okr::persistence::OkrRepository::from_config().await
1353                    && let Ok(Some(mut run)) = repo.get_run(run_id).await
1354                    && run.is_resumable()
1355                {
1356                    run.iterations = turns as u32;
1357                    for (kr_id, value) in &kr_progress {
1358                        run.update_kr_progress(kr_id, *value);
1359                    }
1360                    run.status = crate::okr::OkrRunStatus::Running;
1361                    let _ = repo.update_run(run).await;
1362                }
1363            }
1364
1365            let can_attempt_spawn = dynamic_spawn_count < AUTOCHAT_MAX_DYNAMIC_SPAWNS
1366                && ordered_agents.len() < AUTOCHAT_MAX_AGENTS
1367                && output.len() >= AUTOCHAT_SPAWN_CHECK_MIN_CHARS;
1368
1369            if can_attempt_spawn
1370                && let Some(registry) = &registry
1371                && let Some((profile, reason)) = decide_dynamic_spawn_with_registry(
1372                    task,
1373                    model_ref,
1374                    &output,
1375                    round,
1376                    &ordered_agents,
1377                    registry,
1378                )
1379                .await
1380            {
1381                match Session::new().await {
1382                    Ok(mut spawned_session) => {
1383                        let spawned_model_ref = model_rotation.next_model_ref(model_ref);
1384                        spawned_session.metadata.model = Some(spawned_model_ref.clone());
1385                        spawned_session.set_agent_name(profile.name.clone());
1386                        spawned_session.bus = Some(bus.clone());
1387                        spawned_session.add_message(Message {
1388                            role: Role::System,
1389                            content: vec![ContentPart::Text {
1390                                text: profile.instructions.clone(),
1391                            }],
1392                        });
1393
1394                        relay.register_agents(&[RelayAgentProfile {
1395                            name: profile.name.clone(),
1396                            capabilities: profile.capabilities.clone(),
1397                        }]);
1398                        attach_handoff_receiver(&mut relay_receivers, bus.clone(), &profile.name);
1399
1400                        ordered_agents.insert(idx + 1, profile.name.clone());
1401                        agent_models.insert(profile.name.clone(), spawned_model_ref);
1402                        sessions.insert(profile.name.clone(), spawned_session);
1403                        dynamic_spawn_count += 1;
1404
1405                        tracing::info!(
1406                            agent = %profile.name,
1407                            reason = %reason,
1408                            "Dynamic relay spawn accepted"
1409                        );
1410                    }
1411                    Err(err) => {
1412                        tracing::warn!(
1413                            agent = %profile.name,
1414                            error = %err,
1415                            "Dynamic relay spawn requested but failed"
1416                        );
1417                    }
1418                }
1419            }
1420
1421            if convergence_hits >= 2 {
1422                status = "converged".to_string();
1423                break 'relay_loop;
1424            }
1425
1426            idx += 1;
1427        }
1428    }
1429
1430    relay.shutdown_agents(&ordered_agents);
1431
1432    // Update OKR run with final progress if associated
1433    if let Some(run_id) = okr_run_id
1434        && let Ok(repo) = crate::okr::persistence::OkrRepository::from_config().await
1435        && let Ok(Some(mut run)) = repo.get_run(run_id).await
1436    {
1437        // Update KR progress from execution
1438        for (kr_id, value) in &kr_progress {
1439            run.update_kr_progress(kr_id, *value);
1440        }
1441
1442        // Create outcomes per KR with progress (link to actual KR IDs)
1443        let base_evidence = vec![
1444            format!("relay:{}", relay.relay_id()),
1445            format!("turns:{}", turns),
1446            format!("agents:{}", ordered_agents.len()),
1447            format!("status:{}", status),
1448            format!("rlm_handoffs:{}", rlm_handoff_count),
1449            format!("dynamic_spawns:{}", dynamic_spawn_count),
1450        ];
1451
1452        let outcome_type = if status == "converged" {
1453            crate::okr::KrOutcomeType::FeatureDelivered
1454        } else {
1455            crate::okr::KrOutcomeType::Evidence
1456        };
1457
1458        // Create one outcome per KR, linked to the actual KR ID
1459        for (kr_id_str, value) in &kr_progress {
1460            // Parse KR ID with guardrail to prevent NIL UUID linkage
1461            if let Some(kr_uuid) = parse_uuid_guarded(kr_id_str, "cli_relay_outcome_kr_link") {
1462                let kr_description = format!(
1463                    "CLI relay outcome for KR {}: {} agents, {} turns, status={}",
1464                    kr_id_str,
1465                    ordered_agents.len(),
1466                    turns,
1467                    status
1468                );
1469                run.outcomes.push({
1470                    let mut outcome =
1471                        crate::okr::KrOutcome::new(kr_uuid, kr_description).with_value(*value);
1472                    outcome.run_id = Some(run.id);
1473                    outcome.outcome_type = outcome_type;
1474                    outcome.evidence = base_evidence.clone();
1475                    outcome.source = "cli relay".to_string();
1476                    outcome
1477                });
1478            }
1479        }
1480
1481        // Mark complete or update status based on execution result
1482        if status == "converged" {
1483            run.complete();
1484        } else if status == "agent_error" || status == "bus_error" {
1485            run.status = crate::okr::OkrRunStatus::Failed;
1486        } else {
1487            run.status = crate::okr::OkrRunStatus::Completed;
1488        }
1489        let _ = repo.update_run(run).await;
1490    }
1491
1492    let mut summary = format!(
1493        "Autochat complete ({status}) — relay {} with {} agents over {} turns.\n\nFinal relay handoff:\n{}",
1494        relay.relay_id(),
1495        ordered_agents.len(),
1496        turns,
1497        truncate_with_ellipsis(&baton, 4_000)
1498    );
1499    if let Some(note) = &failure_note {
1500        summary.push_str(&format!("\n\nFailure detail: {note}"));
1501    }
1502    if planner_used {
1503        summary.push_str("\n\nTeam planning: model-organized profiles.");
1504    } else {
1505        summary.push_str("\n\nTeam planning: fallback self-organizing profiles.");
1506    }
1507    if rlm_handoff_count > 0 {
1508        summary.push_str(&format!("\nRLM-normalized handoffs: {rlm_handoff_count}"));
1509    }
1510    if rlm_context_count > 0 {
1511        summary.push_str(&format!("\nRLM context deltas: {rlm_context_count}"));
1512    }
1513    if shared_context.item_count() > 0 {
1514        summary.push_str(&format!(
1515            "\nShared context items: {}",
1516            shared_context.item_count()
1517        ));
1518    }
1519    if dynamic_spawn_count > 0 {
1520        summary.push_str(&format!("\nDynamic relay spawns: {dynamic_spawn_count}"));
1521    }
1522
1523    Ok(AutochatCliResult {
1524        status,
1525        relay_id: relay.relay_id().to_string(),
1526        model: model_ref.to_string(),
1527        agent_count: ordered_agents.len(),
1528        turns,
1529        agents: ordered_agents,
1530        final_handoff: baton,
1531        summary,
1532        failure: failure_note,
1533        shared_context_items: shared_context.item_count(),
1534        rlm_context_count,
1535    })
1536}
1537
1538#[cfg(test)]
1539mod tests {
1540    use super::PlannedRelayProfile;
1541    use super::{
1542        AUTOCHAT_QUICK_DEMO_TASK, PlannedRelayResponse, build_runtime_profile_from_plan,
1543        command_with_optional_args, extract_json_payload, is_easy_go_command,
1544        normalize_cli_go_command, normalize_go_task_input, resolve_autochat_model,
1545        validate_easy_go_task,
1546    };
1547
1548    #[test]
1549    fn normalize_go_maps_to_autochat_with_count_and_task() {
1550        assert_eq!(
1551            normalize_cli_go_command("/go 4 build protocol relay"),
1552            "/autochat 4 build protocol relay"
1553        );
1554    }
1555
1556    #[test]
1557    fn normalize_go_count_only_uses_demo_task() {
1558        assert_eq!(
1559            normalize_cli_go_command("/go 4"),
1560            format!("/autochat 4 {AUTOCHAT_QUICK_DEMO_TASK}")
1561        );
1562    }
1563
1564    #[test]
1565    fn parse_autochat_args_supports_default_count() {
1566        let parsed =
1567            crate::autochat::parse_autochat_request("build a relay", 3, AUTOCHAT_QUICK_DEMO_TASK)
1568                .expect("valid args");
1569        assert_eq!(
1570            (parsed.agent_count, parsed.task.as_str()),
1571            (3, "build a relay"),
1572        );
1573    }
1574
1575    #[test]
1576    fn parse_autochat_args_supports_explicit_count() {
1577        let parsed =
1578            crate::autochat::parse_autochat_request("4 build a relay", 3, AUTOCHAT_QUICK_DEMO_TASK)
1579                .expect("valid args");
1580        assert_eq!(
1581            (parsed.agent_count, parsed.task.as_str()),
1582            (4, "build a relay"),
1583        );
1584    }
1585
1586    #[test]
1587    fn normalize_go_task_collapses_whitespace() {
1588        assert_eq!(
1589            normalize_go_task_input(" implement   api\ncompat routes\twith tests "),
1590            "implement api compat routes with tests"
1591        );
1592    }
1593
1594    #[test]
1595    fn validate_go_task_rejects_pasted_run_output() {
1596        let pasted =
1597            "Task: foo Progress: 0/7 stories Iterations: 7/10 Incomplete stories: ... Next steps:";
1598        assert!(validate_easy_go_task(pasted).is_err());
1599    }
1600
1601    #[test]
1602    fn command_with_optional_args_avoids_prefix_collision() {
1603        assert_eq!(command_with_optional_args("/autochatty", "/autochat"), None);
1604    }
1605
1606    #[test]
1607    fn easy_go_detection_handles_aliases() {
1608        assert!(is_easy_go_command("/go 4 task"));
1609        assert!(is_easy_go_command("/team 4 task"));
1610        assert!(!is_easy_go_command("/autochat 4 task"));
1611    }
1612
1613    #[test]
1614    fn easy_go_defaults_to_minimax_when_model_not_set() {
1615        assert_eq!(
1616            resolve_autochat_model(None, None, Some("zai/glm-5"), true),
1617            "minimax-credits/MiniMax-M2.5-highspeed"
1618        );
1619    }
1620
1621    #[test]
1622    fn explicit_model_wins_over_easy_go_default() {
1623        assert_eq!(
1624            resolve_autochat_model(Some("zai/glm-5"), None, None, true),
1625            "zai/glm-5"
1626        );
1627    }
1628
1629    #[test]
1630    fn extract_json_payload_parses_markdown_wrapped_json() {
1631        let wrapped = "Here is the plan:\n```json\n{\"profiles\":[{\"name\":\"auto-db\",\"specialty\":\"database\",\"mission\":\"Own schema and queries\",\"capabilities\":[\"sql\",\"indexing\"]}]}\n```";
1632        let parsed: PlannedRelayResponse =
1633            extract_json_payload(wrapped).expect("should parse wrapped JSON");
1634        assert_eq!(parsed.profiles.len(), 1);
1635        assert_eq!(parsed.profiles[0].name, "auto-db");
1636    }
1637
1638    #[test]
1639    fn build_runtime_profile_normalizes_and_deduplicates_name() {
1640        let planned = PlannedRelayProfile {
1641            name: "Data Specialist".to_string(),
1642            specialty: "data engineering".to_string(),
1643            mission: "Prepare datasets for downstream coding".to_string(),
1644            capabilities: vec!["ETL".to_string(), "sql".to_string()],
1645        };
1646
1647        let profile =
1648            build_runtime_profile_from_plan(planned, &["auto-data-specialist".to_string()])
1649                .expect("profile should be built");
1650
1651        assert_eq!(profile.name, "auto-data-specialist-2");
1652        assert!(profile.capabilities.iter().any(|cap| cap == "relay"));
1653        assert!(profile.capabilities.iter().any(|cap| cap == "autochat"));
1654    }
1655}