Skip to main content

codetether_agent/cli/
run.rs

1//! Non-interactive run command
2
3use super::RunArgs;
4use crate::bus::{AgentBus, relay::ProtocolRelayRuntime, relay::RelayAgentProfile};
5use crate::config::Config;
6use crate::okr::{KeyResult, Okr, OkrRepository, OkrRun, OkrRunStatus};
7use crate::provider::{ContentPart, Message, Role};
8use crate::session::Session;
9use anyhow::Result;
10use serde::{Deserialize, Serialize, de::DeserializeOwned};
11use std::collections::HashMap;
12use std::io::Write;
13use uuid::Uuid;
14
15const AUTOCHAT_MAX_AGENTS: usize = 8;
16const AUTOCHAT_DEFAULT_AGENTS: usize = 3;
17const AUTOCHAT_MAX_ROUNDS: usize = 3;
18const AUTOCHAT_MAX_DYNAMIC_SPAWNS: usize = 3;
19const AUTOCHAT_SPAWN_CHECK_MIN_CHARS: usize = 800;
20const AUTOCHAT_QUICK_DEMO_TASK: &str = "Self-organize into the right specialties for this task, then relay one concrete implementation plan with clear next handoffs.";
21const GO_DEFAULT_MODEL: &str = "minimax/MiniMax-M2.5";
22
23/// Guarded UUID parse that logs warnings on invalid input instead of returning NIL UUID.
24/// Returns None for invalid UUIDs, allowing callers to skip operations rather than corrupt data.
25fn parse_uuid_guarded(s: &str, context: &str) -> Option<Uuid> {
26    match s.parse::<Uuid>() {
27        Ok(uuid) => Some(uuid),
28        Err(e) => {
29            tracing::warn!(
30                context,
31                uuid_str = %s,
32                error = %e,
33                "Invalid UUID string - skipping operation"
34            );
35            None
36        }
37    }
38}
39
40#[derive(Debug, Clone)]
41struct RelayProfile {
42    name: String,
43    instructions: String,
44    capabilities: Vec<String>,
45}
46
47#[derive(Debug, Clone, Deserialize)]
48struct PlannedRelayProfile {
49    #[serde(default)]
50    name: String,
51    #[serde(default)]
52    specialty: String,
53    #[serde(default)]
54    mission: String,
55    #[serde(default)]
56    capabilities: Vec<String>,
57}
58
59#[derive(Debug, Clone, Deserialize)]
60struct PlannedRelayResponse {
61    #[serde(default)]
62    profiles: Vec<PlannedRelayProfile>,
63}
64
65#[derive(Debug, Clone, Deserialize)]
66struct RelaySpawnDecision {
67    #[serde(default)]
68    spawn: bool,
69    #[serde(default)]
70    reason: String,
71    #[serde(default)]
72    profile: Option<PlannedRelayProfile>,
73}
74
75#[derive(Debug, Serialize)]
76struct AutochatCliResult {
77    status: String,
78    relay_id: String,
79    model: String,
80    agent_count: usize,
81    turns: usize,
82    agents: Vec<String>,
83    final_handoff: String,
84    summary: String,
85    failure: Option<String>,
86}
87
88fn slugify_label(value: &str) -> String {
89    let mut out = String::with_capacity(value.len());
90    let mut last_dash = false;
91
92    for ch in value.chars() {
93        let ch = ch.to_ascii_lowercase();
94        if ch.is_ascii_alphanumeric() {
95            out.push(ch);
96            last_dash = false;
97        } else if !last_dash {
98            out.push('-');
99            last_dash = true;
100        }
101    }
102
103    out.trim_matches('-').to_string()
104}
105
106fn sanitize_relay_agent_name(value: &str) -> String {
107    let raw = slugify_label(value);
108    let base = if raw.is_empty() {
109        "auto-specialist".to_string()
110    } else if raw.starts_with("auto-") {
111        raw
112    } else {
113        format!("auto-{raw}")
114    };
115
116    truncate_with_ellipsis(&base, 48)
117        .trim_end_matches("...")
118        .to_string()
119}
120
121fn unique_relay_agent_name(base: &str, existing: &[String]) -> String {
122    if !existing.iter().any(|name| name == base) {
123        return base.to_string();
124    }
125
126    let mut suffix = 2usize;
127    loop {
128        let candidate = format!("{base}-{suffix}");
129        if !existing.iter().any(|name| name == &candidate) {
130            return candidate;
131        }
132        suffix += 1;
133    }
134}
135
136fn relay_instruction_from_plan(name: &str, specialty: &str, mission: &str) -> String {
137    format!(
138        "You are @{name}.\n\
139         Specialty: {specialty}.\n\
140         Mission: {mission}\n\n\
141         This is a protocol-first relay conversation. Treat incoming handoffs as authoritative context.\n\
142         Keep responses concise, concrete, and useful for the next specialist.\n\
143         Include one clear recommendation for what the next agent should do.\n\
144         If the task is too large for the current team, explicitly call out missing specialties and handoff boundaries.",
145    )
146}
147
148fn build_runtime_profile_from_plan(
149    profile: PlannedRelayProfile,
150    existing: &[String],
151) -> Option<RelayProfile> {
152    let specialty = if profile.specialty.trim().is_empty() {
153        "generalist".to_string()
154    } else {
155        profile.specialty.trim().to_string()
156    };
157
158    let mission = if profile.mission.trim().is_empty() {
159        "Advance the relay with concrete next actions and clear handoffs.".to_string()
160    } else {
161        profile.mission.trim().to_string()
162    };
163
164    let base_name = if profile.name.trim().is_empty() {
165        format!("auto-{}", slugify_label(&specialty))
166    } else {
167        profile.name.trim().to_string()
168    };
169
170    let sanitized = sanitize_relay_agent_name(&base_name);
171    let name = unique_relay_agent_name(&sanitized, existing);
172    if name.trim().is_empty() {
173        return None;
174    }
175
176    let mut capabilities: Vec<String> = Vec::new();
177    let specialty_cap = slugify_label(&specialty);
178    if !specialty_cap.is_empty() {
179        capabilities.push(specialty_cap);
180    }
181
182    for capability in profile.capabilities {
183        let normalized = slugify_label(&capability);
184        if !normalized.is_empty() && !capabilities.contains(&normalized) {
185            capabilities.push(normalized);
186        }
187    }
188
189    for required in ["relay", "context-handoff", "autochat"] {
190        if !capabilities.iter().any(|capability| capability == required) {
191            capabilities.push(required.to_string());
192        }
193    }
194
195    Some(RelayProfile {
196        name: name.clone(),
197        instructions: relay_instruction_from_plan(&name, &specialty, &mission),
198        capabilities,
199    })
200}
201
202fn extract_json_payload<T: DeserializeOwned>(text: &str) -> Option<T> {
203    let trimmed = text.trim();
204    if let Ok(value) = serde_json::from_str::<T>(trimmed) {
205        return Some(value);
206    }
207
208    if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}'))
209        && start < end
210        && let Ok(value) = serde_json::from_str::<T>(&trimmed[start..=end])
211    {
212        return Some(value);
213    }
214
215    if let (Some(start), Some(end)) = (trimmed.find('['), trimmed.rfind(']'))
216        && start < end
217        && let Ok(value) = serde_json::from_str::<T>(&trimmed[start..=end])
218    {
219        return Some(value);
220    }
221
222    None
223}
224
225fn resolve_provider_for_model_autochat(
226    registry: &std::sync::Arc<crate::provider::ProviderRegistry>,
227    model_ref: &str,
228) -> Option<(std::sync::Arc<dyn crate::provider::Provider>, String)> {
229    let (provider_name, model_name) = crate::provider::parse_model_string(model_ref);
230    if let Some(provider_name) = provider_name
231        && let Some(provider) = registry.get(provider_name)
232    {
233        return Some((provider, model_name.to_string()));
234    }
235
236    let fallbacks = [
237        "zai",
238        "openai",
239        "github-copilot",
240        "anthropic",
241        "openrouter",
242        "novita",
243        "moonshotai",
244        "google",
245    ];
246
247    for provider_name in fallbacks {
248        if let Some(provider) = registry.get(provider_name) {
249            return Some((provider, model_ref.to_string()));
250        }
251    }
252
253    registry
254        .list()
255        .first()
256        .copied()
257        .and_then(|name| registry.get(name))
258        .map(|provider| (provider, model_ref.to_string()))
259}
260
261async fn plan_relay_profiles_with_registry(
262    task: &str,
263    model_ref: &str,
264    requested_agents: usize,
265    registry: &std::sync::Arc<crate::provider::ProviderRegistry>,
266) -> Option<Vec<RelayProfile>> {
267    let (provider, model_name) = resolve_provider_for_model_autochat(registry, model_ref)?;
268    let requested_agents = requested_agents.clamp(2, AUTOCHAT_MAX_AGENTS);
269
270    let request = crate::provider::CompletionRequest {
271        model: model_name,
272        messages: vec![
273            crate::provider::Message {
274                role: crate::provider::Role::System,
275                content: vec![crate::provider::ContentPart::Text {
276                    text: "You are a relay-team architect. Return ONLY valid JSON.".to_string(),
277                }],
278            },
279            crate::provider::Message {
280                role: crate::provider::Role::User,
281                content: vec![crate::provider::ContentPart::Text {
282                    text: format!(
283                        "Task:\n{task}\n\nDesign a task-specific relay team.\n\
284                         Respond with JSON object only:\n\
285                         {{\n  \"profiles\": [\n    {{\"name\":\"auto-...\",\"specialty\":\"...\",\"mission\":\"...\",\"capabilities\":[\"...\"]}}\n  ]\n}}\n\
286                         Requirements:\n\
287                         - Return {} profiles\n\
288                         - Names must be short kebab-case\n\
289                         - Capabilities must be concise skill tags\n\
290                         - Missions should be concrete and handoff-friendly",
291                        requested_agents
292                    ),
293                }],
294            },
295        ],
296        tools: Vec::new(),
297        temperature: Some(1.0),
298        top_p: Some(0.9),
299        max_tokens: Some(1200),
300        stop: Vec::new(),
301    };
302
303    let response = provider.complete(request).await.ok()?;
304    let text = response
305        .message
306        .content
307        .iter()
308        .filter_map(|part| match part {
309            crate::provider::ContentPart::Text { text }
310            | crate::provider::ContentPart::Thinking { text } => Some(text.as_str()),
311            _ => None,
312        })
313        .collect::<Vec<_>>()
314        .join("\n");
315
316    let planned = extract_json_payload::<PlannedRelayResponse>(&text)?;
317    let mut existing = Vec::<String>::new();
318    let mut runtime = Vec::<RelayProfile>::new();
319
320    for profile in planned.profiles.into_iter().take(AUTOCHAT_MAX_AGENTS) {
321        if let Some(runtime_profile) = build_runtime_profile_from_plan(profile, &existing) {
322            existing.push(runtime_profile.name.clone());
323            runtime.push(runtime_profile);
324        }
325    }
326
327    if runtime.len() >= 2 {
328        Some(runtime)
329    } else {
330        None
331    }
332}
333
334async fn decide_dynamic_spawn_with_registry(
335    task: &str,
336    model_ref: &str,
337    latest_output: &str,
338    round: usize,
339    ordered_agents: &[String],
340    registry: &std::sync::Arc<crate::provider::ProviderRegistry>,
341) -> Option<(RelayProfile, String)> {
342    let (provider, model_name) = resolve_provider_for_model_autochat(registry, model_ref)?;
343    let team = ordered_agents
344        .iter()
345        .map(|name| format!("@{name}"))
346        .collect::<Vec<_>>()
347        .join(", ");
348    let output_excerpt = truncate_with_ellipsis(latest_output, 2200);
349
350    let request = crate::provider::CompletionRequest {
351        model: model_name,
352        messages: vec![
353            crate::provider::Message {
354                role: crate::provider::Role::System,
355                content: vec![crate::provider::ContentPart::Text {
356                    text: "You are a relay scaling controller. Return ONLY valid JSON.".to_string(),
357                }],
358            },
359            crate::provider::Message {
360                role: crate::provider::Role::User,
361                content: vec![crate::provider::ContentPart::Text {
362                    text: format!(
363                        "Task:\n{task}\n\nRound: {round}\nCurrent team: {team}\n\
364                         Latest handoff excerpt:\n{output_excerpt}\n\n\
365                         Decide whether the team needs one additional specialist right now.\n\
366                         Respond with JSON object only:\n\
367                         {{\n  \"spawn\": true|false,\n  \"reason\": \"...\",\n  \"profile\": {{\"name\":\"auto-...\",\"specialty\":\"...\",\"mission\":\"...\",\"capabilities\":[\"...\"]}}\n}}\n\
368                         If spawn=false, profile may be null or omitted."
369                    ),
370                }],
371            },
372        ],
373        tools: Vec::new(),
374        temperature: Some(1.0),
375        top_p: Some(0.9),
376        max_tokens: Some(420),
377        stop: Vec::new(),
378    };
379
380    let response = provider.complete(request).await.ok()?;
381    let text = response
382        .message
383        .content
384        .iter()
385        .filter_map(|part| match part {
386            crate::provider::ContentPart::Text { text }
387            | crate::provider::ContentPart::Thinking { text } => Some(text.as_str()),
388            _ => None,
389        })
390        .collect::<Vec<_>>()
391        .join("\n");
392
393    let decision = extract_json_payload::<RelaySpawnDecision>(&text)?;
394    if !decision.spawn {
395        return None;
396    }
397
398    let profile = decision.profile?;
399    let runtime_profile = build_runtime_profile_from_plan(profile, ordered_agents)?;
400    let reason = if decision.reason.trim().is_empty() {
401        "Model requested additional specialist for task scope.".to_string()
402    } else {
403        decision.reason.trim().to_string()
404    };
405
406    Some((runtime_profile, reason))
407}
408
409pub async fn execute(args: RunArgs) -> Result<()> {
410    let message = args.message.trim();
411
412    if message.is_empty() {
413        anyhow::bail!("You must provide a message");
414    }
415
416    tracing::info!("Running with message: {}", message);
417
418    // Load configuration
419    let config = Config::load().await.unwrap_or_default();
420
421    // Protocol-first relay aliases in CLI:
422    // - /go [count] <task>
423    // - /autochat [count] <task>
424    let easy_go_requested = is_easy_go_command(message);
425    let normalized = normalize_cli_go_command(message);
426    if let Some(rest) = command_with_optional_args(&normalized, "/autochat") {
427        let Some((agent_count, task)) = parse_autochat_args(rest) else {
428            anyhow::bail!(
429                "Usage: /autochat [count] <task>\nEasy mode: /go <task>\ncount range: 2-{} (default: {})",
430                AUTOCHAT_MAX_AGENTS,
431                AUTOCHAT_DEFAULT_AGENTS
432            );
433        };
434
435        if !(2..=AUTOCHAT_MAX_AGENTS).contains(&agent_count) {
436            anyhow::bail!(
437                "Invalid relay size {}. count must be between 2 and {}",
438                agent_count,
439                AUTOCHAT_MAX_AGENTS
440            );
441        }
442
443        let model = resolve_autochat_model(
444            args.model.as_deref(),
445            std::env::var("CODETETHER_DEFAULT_MODEL").ok().as_deref(),
446            config.default_model.as_deref(),
447            easy_go_requested,
448        );
449
450        // For /go commands (not /autochat), require OKR approval
451        let (okr_id, okr_run_id) = if easy_go_requested {
452            // Create OKR draft
453            let okr_id = Uuid::new_v4();
454            let mut okr = Okr::new(
455                format!("Relay: {}", truncate_with_ellipsis(&task, 60)),
456                format!("Execute relay task: {}", task),
457            );
458            okr.id = okr_id;
459
460            // Add default key results
461            let kr1 = KeyResult::new(okr_id, "Relay completes all rounds", 100.0, "%");
462            let kr2 = KeyResult::new(okr_id, "Team produces actionable handoff", 1.0, "count");
463            let kr3 = KeyResult::new(okr_id, "No critical errors", 0.0, "count");
464            okr.add_key_result(kr1);
465            okr.add_key_result(kr2);
466            okr.add_key_result(kr3);
467
468            // Create run
469            let mut run = OkrRun::new(
470                okr_id,
471                format!("Run {}", chrono::Local::now().format("%Y-%m-%d %H:%M")),
472            );
473            run.status = OkrRunStatus::PendingApproval;
474
475            // Show OKR draft
476            println!("\n⚠️  /go OKR Draft\n");
477            println!("Task: {}", truncate_with_ellipsis(&task, 80));
478            println!("Agents: {} | Model: {}", agent_count, model);
479            println!("\nObjective: {}", okr.title);
480            println!("\nKey Results:");
481            for kr in &okr.key_results {
482                println!("  • {} (target: {} {})", kr.title, kr.target_value, kr.unit);
483            }
484            println!("\n");
485
486            // Prompt for approval
487            print!("Approve OKR and start relay? [y/n]: ");
488            std::io::stdout().flush()?;
489            let mut input = String::new();
490            std::io::stdin().read_line(&mut input)?;
491
492            let input = input.trim().to_lowercase();
493            if input != "y" && input != "yes" {
494                println!("❌ OKR denied. Relay not started.");
495                println!("Use /autochat for tactical execution without OKR tracking.");
496                return Ok(());
497            }
498
499            println!("✅ OKR approved! Starting relay execution...\n");
500
501            // Save OKR and run
502            if let Ok(repo) = OkrRepository::from_config().await {
503                let _ = repo.create_okr(okr).await;
504                let mut approved_run = run;
505                approved_run.status = OkrRunStatus::Approved;
506                approved_run.correlation_id = Some(format!("relay-{}", Uuid::new_v4()));
507                let _ = repo.create_run(approved_run.clone()).await;
508                tracing::info!(okr_id = %okr_id, okr_run_id = %approved_run.id, "OKR run approved and saved");
509                (Some(okr_id), Some(approved_run.id))
510            } else {
511                (Some(okr_id), Some(run.id))
512            }
513        } else {
514            (None, None)
515        };
516
517        let relay_result =
518            run_protocol_first_relay(agent_count, task, &model, okr_id, okr_run_id).await?;
519        match args.format.as_str() {
520            "json" => println!("{}", serde_json::to_string_pretty(&relay_result)?),
521            _ => {
522                println!("{}", relay_result.summary);
523                if let Some(failure) = &relay_result.failure {
524                    eprintln!("\nFailure detail: {}", failure);
525                }
526                eprintln!(
527                    "\n[Relay: {} | Model: {}]",
528                    relay_result.relay_id, relay_result.model
529                );
530            }
531        }
532        return Ok(());
533    }
534
535    // Create or continue session.
536    let mut session = if let Some(session_id) = args.session.clone() {
537        tracing::info!("Continuing session: {}", session_id);
538        if let Some(oc_id) = session_id.strip_prefix("opencode_") {
539            if let Some(storage) = crate::opencode::OpenCodeStorage::new() {
540                Session::from_opencode(oc_id, &storage).await?
541            } else {
542                anyhow::bail!("OpenCode storage not available")
543            }
544        } else {
545            Session::load(&session_id).await?
546        }
547    } else if args.continue_session {
548        let workspace_dir = std::env::current_dir().unwrap_or_default();
549        match Session::last_for_directory(Some(&workspace_dir)).await {
550            Ok(s) => {
551                tracing::info!(
552                    session_id = %s.id,
553                    workspace = %workspace_dir.display(),
554                    "Continuing last workspace session"
555                );
556                s
557            }
558            Err(_) => {
559                // Fallback: try to resume from OpenCode session
560                match Session::last_opencode_for_directory(&workspace_dir).await {
561                    Ok(s) => {
562                        tracing::info!(
563                            session_id = %s.id,
564                            workspace = %workspace_dir.display(),
565                            "Resuming from OpenCode session"
566                        );
567                        s
568                    }
569                    Err(_) => {
570                        let s = Session::new().await?;
571                        tracing::info!(
572                            session_id = %s.id,
573                            workspace = %workspace_dir.display(),
574                            "No workspace session found; created new session"
575                        );
576                        s
577                    }
578                }
579            }
580        }
581    } else {
582        let s = Session::new().await?;
583        tracing::info!("Created new session: {}", s.id);
584        s
585    };
586
587    // Set model: CLI arg > env var > config default
588    let model = args
589        .model
590        .or_else(|| std::env::var("CODETETHER_DEFAULT_MODEL").ok())
591        .or(config.default_model);
592
593    if let Some(model) = model {
594        tracing::info!("Using model: {}", model);
595        session.metadata.model = Some(model);
596    }
597
598    // Execute the prompt
599    let result = session.prompt(message).await?;
600
601    // Output based on format
602    match args.format.as_str() {
603        "json" => {
604            println!("{}", serde_json::to_string_pretty(&result)?);
605        }
606        _ => {
607            println!("{}", result.text);
608            // Show session ID for continuation
609            eprintln!(
610                "\n[Session: {} | Continue with: codetether run -c \"...\"]",
611                session.id
612            );
613        }
614    }
615
616    Ok(())
617}
618
619fn command_with_optional_args<'a>(input: &'a str, command: &str) -> Option<&'a str> {
620    let trimmed = input.trim();
621    let rest = trimmed.strip_prefix(command)?;
622
623    if rest.is_empty() {
624        return Some("");
625    }
626
627    let first = rest.chars().next()?;
628    if first.is_whitespace() {
629        Some(rest.trim())
630    } else {
631        None
632    }
633}
634
635fn normalize_cli_go_command(input: &str) -> String {
636    let trimmed = input.trim();
637    if trimmed.is_empty() || !trimmed.starts_with('/') {
638        return trimmed.to_string();
639    }
640
641    let mut parts = trimmed.splitn(2, char::is_whitespace);
642    let command = parts.next().unwrap_or("");
643    let args = parts.next().unwrap_or("").trim();
644
645    match command.to_ascii_lowercase().as_str() {
646        "/go" | "/team" => {
647            if args.is_empty() {
648                format!(
649                    "/autochat {} {}",
650                    AUTOCHAT_DEFAULT_AGENTS, AUTOCHAT_QUICK_DEMO_TASK
651                )
652            } else {
653                let mut count_and_task = args.splitn(2, char::is_whitespace);
654                let first = count_and_task.next().unwrap_or("");
655                if let Ok(count) = first.parse::<usize>() {
656                    let task = count_and_task.next().unwrap_or("").trim();
657                    if task.is_empty() {
658                        format!("/autochat {count} {AUTOCHAT_QUICK_DEMO_TASK}")
659                    } else {
660                        format!("/autochat {count} {task}")
661                    }
662                } else {
663                    format!("/autochat {} {args}", AUTOCHAT_DEFAULT_AGENTS)
664                }
665            }
666        }
667        _ => trimmed.to_string(),
668    }
669}
670
671fn is_easy_go_command(input: &str) -> bool {
672    let command = input
673        .trim_start()
674        .split_whitespace()
675        .next()
676        .unwrap_or("")
677        .to_ascii_lowercase();
678
679    matches!(command.as_str(), "/go" | "/team")
680}
681
682fn parse_autochat_args(rest: &str) -> Option<(usize, &str)> {
683    let rest = rest.trim();
684    if rest.is_empty() {
685        return None;
686    }
687
688    let mut parts = rest.splitn(2, char::is_whitespace);
689    let first = parts.next().unwrap_or("").trim();
690    if first.is_empty() {
691        return None;
692    }
693
694    if let Ok(count) = first.parse::<usize>() {
695        let task = parts.next().unwrap_or("").trim();
696        if task.is_empty() {
697            Some((count, AUTOCHAT_QUICK_DEMO_TASK))
698        } else {
699            Some((count, task))
700        }
701    } else {
702        Some((AUTOCHAT_DEFAULT_AGENTS, rest))
703    }
704}
705
706fn resolve_autochat_model(
707    cli_model: Option<&str>,
708    env_model: Option<&str>,
709    config_model: Option<&str>,
710    easy_go_requested: bool,
711) -> String {
712    if let Some(model) = cli_model.filter(|value| !value.trim().is_empty()) {
713        return model.to_string();
714    }
715    if easy_go_requested {
716        return GO_DEFAULT_MODEL.to_string();
717    }
718    if let Some(model) = env_model.filter(|value| !value.trim().is_empty()) {
719        return model.to_string();
720    }
721    if let Some(model) = config_model.filter(|value| !value.trim().is_empty()) {
722        return model.to_string();
723    }
724    "zai/glm-5".to_string()
725}
726
727fn build_relay_profiles(count: usize) -> Vec<RelayProfile> {
728    let mut profiles = Vec::with_capacity(count);
729    for idx in 0..count {
730        let name = format!("auto-agent-{}", idx + 1);
731
732        let instructions = format!(
733            "You are @{name}.\n\
734             Role policy: self-organize from task context and current handoff instead of assuming a fixed persona.\n\
735             Mission: advance the relay with concrete, high-signal next actions and clear ownership boundaries.\n\n\
736             This is a protocol-first relay conversation. Treat the incoming handoff as authoritative context.\n\
737             Keep your response concise, concrete, and useful for the next specialist.\n\
738             Include one clear recommendation for what the next agent should do.\n\
739             If the task scope is too large, explicitly call out missing specialties and handoff boundaries.",
740        );
741        let capabilities = vec![
742            "generalist".to_string(),
743            "self-organizing".to_string(),
744            "relay".to_string(),
745            "context-handoff".to_string(),
746            "autochat".to_string(),
747        ];
748
749        profiles.push(RelayProfile {
750            name,
751            instructions,
752            capabilities,
753        });
754    }
755    profiles
756}
757
758fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
759    if max_chars == 0 {
760        return String::new();
761    }
762
763    let mut chars = value.chars();
764    let mut output = String::new();
765    for _ in 0..max_chars {
766        if let Some(ch) = chars.next() {
767            output.push(ch);
768        } else {
769            return value.to_string();
770        }
771    }
772
773    if chars.next().is_some() {
774        format!("{output}...")
775    } else {
776        output
777    }
778}
779
780fn normalize_for_convergence(text: &str) -> String {
781    let mut normalized = String::with_capacity(text.len().min(512));
782    let mut last_was_space = false;
783
784    for ch in text.chars() {
785        if ch.is_ascii_alphanumeric() {
786            normalized.push(ch.to_ascii_lowercase());
787            last_was_space = false;
788        } else if ch.is_whitespace() && !last_was_space {
789            normalized.push(' ');
790            last_was_space = true;
791        }
792
793        if normalized.len() >= 280 {
794            break;
795        }
796    }
797
798    normalized.trim().to_string()
799}
800
801async fn run_protocol_first_relay(
802    agent_count: usize,
803    task: &str,
804    model_ref: &str,
805    okr_id: Option<Uuid>,
806    okr_run_id: Option<Uuid>,
807) -> Result<AutochatCliResult> {
808    let bus = AgentBus::new().into_arc();
809    let relay = ProtocolRelayRuntime::new(bus);
810
811    let registry = crate::provider::ProviderRegistry::from_vault()
812        .await
813        .ok()
814        .map(std::sync::Arc::new);
815
816    let mut planner_used = false;
817    let profiles = if let Some(registry) = &registry {
818        if let Some(planned) =
819            plan_relay_profiles_with_registry(task, model_ref, agent_count, registry).await
820        {
821            planner_used = true;
822            planned
823        } else {
824            build_relay_profiles(agent_count)
825        }
826    } else {
827        build_relay_profiles(agent_count)
828    };
829
830    let relay_profiles: Vec<RelayAgentProfile> = profiles
831        .iter()
832        .map(|profile| RelayAgentProfile {
833            name: profile.name.clone(),
834            capabilities: profile.capabilities.clone(),
835        })
836        .collect();
837
838    let mut ordered_agents: Vec<String> = profiles
839        .iter()
840        .map(|profile| profile.name.clone())
841        .collect();
842    let mut sessions: HashMap<String, Session> = HashMap::new();
843
844    for profile in &profiles {
845        let mut session = Session::new().await?;
846        session.metadata.model = Some(model_ref.to_string());
847        session.agent = profile.name.clone();
848        session.add_message(Message {
849            role: Role::System,
850            content: vec![ContentPart::Text {
851                text: profile.instructions.clone(),
852            }],
853        });
854        sessions.insert(profile.name.clone(), session);
855    }
856
857    if ordered_agents.len() < 2 {
858        anyhow::bail!("Autochat needs at least 2 agents to relay.");
859    }
860
861    relay.register_agents(&relay_profiles);
862
863    // Load KR targets if OKR is associated
864    let kr_targets: std::collections::HashMap<String, f64> =
865        if let (Some(okr_id_val), Some(_run_id)) = (okr_id, okr_run_id) {
866            if let Ok(repo) = crate::okr::persistence::OkrRepository::from_config().await {
867                if let Ok(Some(okr)) = repo.get_okr(okr_id_val).await {
868                    okr.key_results
869                        .iter()
870                        .map(|kr| (kr.id.to_string(), kr.target_value))
871                        .collect()
872                } else {
873                    std::collections::HashMap::new()
874                }
875            } else {
876                std::collections::HashMap::new()
877            }
878        } else {
879            std::collections::HashMap::new()
880        };
881
882    let mut kr_progress: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
883
884    let mut baton = format!(
885        "Task:\n{task}\n\nStart by proposing an execution strategy and one immediate next step."
886    );
887    let mut previous_normalized: Option<String> = None;
888    let mut convergence_hits = 0usize;
889    let mut turns = 0usize;
890    let mut dynamic_spawn_count = 0usize;
891    let mut status = "max_rounds_reached".to_string();
892    let mut failure_note: Option<String> = None;
893
894    'relay_loop: for round in 1..=AUTOCHAT_MAX_ROUNDS {
895        let mut idx = 0usize;
896        while idx < ordered_agents.len() {
897            let to = ordered_agents[idx].clone();
898            let from = if idx == 0 {
899                if round == 1 {
900                    "user".to_string()
901                } else {
902                    ordered_agents[ordered_agents.len() - 1].clone()
903                }
904            } else {
905                ordered_agents[idx - 1].clone()
906            };
907
908            turns += 1;
909            relay.send_handoff(&from, &to, &baton);
910
911            let Some(mut session) = sessions.remove(&to) else {
912                status = "agent_error".to_string();
913                failure_note = Some(format!("Relay agent @{to} session was unavailable."));
914                break 'relay_loop;
915            };
916
917            let output = match session.prompt(&baton).await {
918                Ok(response) => response.text,
919                Err(err) => {
920                    status = "agent_error".to_string();
921                    failure_note = Some(format!("Relay agent @{to} failed: {err}"));
922                    sessions.insert(to, session);
923                    break 'relay_loop;
924                }
925            };
926
927            sessions.insert(to.clone(), session);
928
929            let normalized = normalize_for_convergence(&output);
930            if previous_normalized.as_deref() == Some(normalized.as_str()) {
931                convergence_hits += 1;
932            } else {
933                convergence_hits = 0;
934            }
935            previous_normalized = Some(normalized);
936
937            baton = format!(
938                "Relay task:\n{task}\n\nIncoming handoff from @{to}:\n{}\n\nContinue the work from this handoff. Keep your response focused and provide one concrete next-step instruction for the next agent.",
939                truncate_with_ellipsis(&output, 3_500)
940            );
941
942            // Update KR progress after each turn
943            if !kr_targets.is_empty() {
944                let max_turns = ordered_agents.len() * AUTOCHAT_MAX_ROUNDS;
945                let progress_ratio = (turns as f64 / max_turns as f64).min(1.0);
946
947                for (kr_id, target) in &kr_targets {
948                    let current = progress_ratio * target;
949                    let existing = kr_progress.get(kr_id).copied().unwrap_or(0.0);
950                    if current > existing {
951                        kr_progress.insert(kr_id.clone(), current);
952                    }
953                }
954
955                // Persist mid-run (best-effort)
956                if let Some(run_id) = okr_run_id {
957                    if let Ok(repo) = crate::okr::persistence::OkrRepository::from_config().await {
958                        if let Ok(Some(mut run)) = repo.get_run(run_id).await {
959                            run.iterations = turns as u32;
960                            for (kr_id, value) in &kr_progress {
961                                run.update_kr_progress(kr_id, *value);
962                            }
963                            run.status = crate::okr::OkrRunStatus::Running;
964                            let _ = repo.update_run(run).await;
965                        }
966                    }
967                }
968            }
969
970            let can_attempt_spawn = dynamic_spawn_count < AUTOCHAT_MAX_DYNAMIC_SPAWNS
971                && ordered_agents.len() < AUTOCHAT_MAX_AGENTS
972                && output.len() >= AUTOCHAT_SPAWN_CHECK_MIN_CHARS;
973
974            if can_attempt_spawn
975                && let Some(registry) = &registry
976                && let Some((profile, reason)) = decide_dynamic_spawn_with_registry(
977                    task,
978                    model_ref,
979                    &output,
980                    round,
981                    &ordered_agents,
982                    registry,
983                )
984                .await
985            {
986                match Session::new().await {
987                    Ok(mut spawned_session) => {
988                        spawned_session.metadata.model = Some(model_ref.to_string());
989                        spawned_session.agent = profile.name.clone();
990                        spawned_session.add_message(Message {
991                            role: Role::System,
992                            content: vec![ContentPart::Text {
993                                text: profile.instructions.clone(),
994                            }],
995                        });
996
997                        relay.register_agents(&[RelayAgentProfile {
998                            name: profile.name.clone(),
999                            capabilities: profile.capabilities.clone(),
1000                        }]);
1001
1002                        ordered_agents.insert(idx + 1, profile.name.clone());
1003                        sessions.insert(profile.name.clone(), spawned_session);
1004                        dynamic_spawn_count += 1;
1005
1006                        tracing::info!(
1007                            agent = %profile.name,
1008                            reason = %reason,
1009                            "Dynamic relay spawn accepted"
1010                        );
1011                    }
1012                    Err(err) => {
1013                        tracing::warn!(
1014                            agent = %profile.name,
1015                            error = %err,
1016                            "Dynamic relay spawn requested but failed"
1017                        );
1018                    }
1019                }
1020            }
1021
1022            if convergence_hits >= 2 {
1023                status = "converged".to_string();
1024                break 'relay_loop;
1025            }
1026
1027            idx += 1;
1028        }
1029    }
1030
1031    relay.shutdown_agents(&ordered_agents);
1032
1033    // Update OKR run with final progress if associated
1034    if let Some(run_id) = okr_run_id {
1035        if let Ok(repo) = crate::okr::persistence::OkrRepository::from_config().await {
1036            if let Ok(Some(mut run)) = repo.get_run(run_id).await {
1037                // Update KR progress from execution
1038                for (kr_id, value) in &kr_progress {
1039                    run.update_kr_progress(kr_id, *value);
1040                }
1041
1042                // Create outcomes per KR with progress (link to actual KR IDs)
1043                let base_evidence = vec![
1044                    format!("relay:{}", relay.relay_id()),
1045                    format!("turns:{}", turns),
1046                    format!("agents:{}", ordered_agents.len()),
1047                    format!("status:{}", status),
1048                ];
1049
1050                let outcome_type = if status == "converged" {
1051                    crate::okr::KrOutcomeType::FeatureDelivered
1052                } else {
1053                    crate::okr::KrOutcomeType::Evidence
1054                };
1055
1056                // Create one outcome per KR, linked to the actual KR ID
1057                for (kr_id_str, value) in &kr_progress {
1058                    // Parse KR ID with guardrail to prevent NIL UUID linkage
1059                    if let Some(kr_uuid) =
1060                        parse_uuid_guarded(kr_id_str, "cli_relay_outcome_kr_link")
1061                    {
1062                        let kr_description = format!(
1063                            "CLI relay outcome for KR {}: {} agents, {} turns, status={}",
1064                            kr_id_str,
1065                            ordered_agents.len(),
1066                            turns,
1067                            status
1068                        );
1069                        run.outcomes.push(crate::okr::KrOutcome {
1070                            id: uuid::Uuid::new_v4(),
1071                            kr_id: kr_uuid,
1072                            run_id: Some(run.id),
1073                            description: kr_description,
1074                            outcome_type,
1075                            value: Some(*value),
1076                            evidence: base_evidence.clone(),
1077                            source: "cli relay".to_string(),
1078                            created_at: chrono::Utc::now(),
1079                        });
1080                    }
1081                }
1082
1083                // Mark complete or update status based on execution result
1084                if status == "converged" {
1085                    run.complete();
1086                } else if status == "agent_error" {
1087                    run.status = crate::okr::OkrRunStatus::Failed;
1088                } else {
1089                    run.status = crate::okr::OkrRunStatus::Completed;
1090                }
1091                let _ = repo.update_run(run).await;
1092            }
1093        }
1094    }
1095
1096    let mut summary = format!(
1097        "Autochat complete ({status}) — relay {} with {} agents over {} turns.\n\nFinal relay handoff:\n{}",
1098        relay.relay_id(),
1099        ordered_agents.len(),
1100        turns,
1101        truncate_with_ellipsis(&baton, 4_000)
1102    );
1103    if let Some(note) = &failure_note {
1104        summary.push_str(&format!("\n\nFailure detail: {note}"));
1105    }
1106    if planner_used {
1107        summary.push_str("\n\nTeam planning: model-organized profiles.");
1108    } else {
1109        summary.push_str("\n\nTeam planning: fallback self-organizing profiles.");
1110    }
1111    if dynamic_spawn_count > 0 {
1112        summary.push_str(&format!("\nDynamic relay spawns: {dynamic_spawn_count}"));
1113    }
1114
1115    Ok(AutochatCliResult {
1116        status,
1117        relay_id: relay.relay_id().to_string(),
1118        model: model_ref.to_string(),
1119        agent_count: ordered_agents.len(),
1120        turns,
1121        agents: ordered_agents,
1122        final_handoff: baton,
1123        summary,
1124        failure: failure_note,
1125    })
1126}
1127
1128#[cfg(test)]
1129mod tests {
1130    use super::PlannedRelayProfile;
1131    use super::{
1132        AUTOCHAT_QUICK_DEMO_TASK, PlannedRelayResponse, build_runtime_profile_from_plan,
1133        command_with_optional_args, extract_json_payload, is_easy_go_command,
1134        normalize_cli_go_command, parse_autochat_args, resolve_autochat_model,
1135    };
1136
1137    #[test]
1138    fn normalize_go_maps_to_autochat_with_count_and_task() {
1139        assert_eq!(
1140            normalize_cli_go_command("/go 4 build protocol relay"),
1141            "/autochat 4 build protocol relay"
1142        );
1143    }
1144
1145    #[test]
1146    fn normalize_go_count_only_uses_demo_task() {
1147        assert_eq!(
1148            normalize_cli_go_command("/go 4"),
1149            format!("/autochat 4 {AUTOCHAT_QUICK_DEMO_TASK}")
1150        );
1151    }
1152
1153    #[test]
1154    fn parse_autochat_args_supports_default_count() {
1155        assert_eq!(
1156            parse_autochat_args("build a relay").expect("valid args"),
1157            (3, "build a relay")
1158        );
1159    }
1160
1161    #[test]
1162    fn parse_autochat_args_supports_explicit_count() {
1163        assert_eq!(
1164            parse_autochat_args("4 build a relay").expect("valid args"),
1165            (4, "build a relay")
1166        );
1167    }
1168
1169    #[test]
1170    fn command_with_optional_args_avoids_prefix_collision() {
1171        assert_eq!(command_with_optional_args("/autochatty", "/autochat"), None);
1172    }
1173
1174    #[test]
1175    fn easy_go_detection_handles_aliases() {
1176        assert!(is_easy_go_command("/go 4 task"));
1177        assert!(is_easy_go_command("/team 4 task"));
1178        assert!(!is_easy_go_command("/autochat 4 task"));
1179    }
1180
1181    #[test]
1182    fn easy_go_defaults_to_minimax_when_model_not_set() {
1183        assert_eq!(
1184            resolve_autochat_model(None, None, Some("zai/glm-5"), true),
1185            "minimax/MiniMax-M2.5"
1186        );
1187    }
1188
1189    #[test]
1190    fn explicit_model_wins_over_easy_go_default() {
1191        assert_eq!(
1192            resolve_autochat_model(Some("zai/glm-5"), None, None, true),
1193            "zai/glm-5"
1194        );
1195    }
1196
1197    #[test]
1198    fn extract_json_payload_parses_markdown_wrapped_json() {
1199        let wrapped = "Here is the plan:\n```json\n{\"profiles\":[{\"name\":\"auto-db\",\"specialty\":\"database\",\"mission\":\"Own schema and queries\",\"capabilities\":[\"sql\",\"indexing\"]}]}\n```";
1200        let parsed: PlannedRelayResponse =
1201            extract_json_payload(wrapped).expect("should parse wrapped JSON");
1202        assert_eq!(parsed.profiles.len(), 1);
1203        assert_eq!(parsed.profiles[0].name, "auto-db");
1204    }
1205
1206    #[test]
1207    fn build_runtime_profile_normalizes_and_deduplicates_name() {
1208        let planned = PlannedRelayProfile {
1209            name: "Data Specialist".to_string(),
1210            specialty: "data engineering".to_string(),
1211            mission: "Prepare datasets for downstream coding".to_string(),
1212            capabilities: vec!["ETL".to_string(), "sql".to_string()],
1213        };
1214
1215        let profile =
1216            build_runtime_profile_from_plan(planned, &["auto-data-specialist".to_string()])
1217                .expect("profile should be built");
1218
1219        assert_eq!(profile.name, "auto-data-specialist-2");
1220        assert!(profile.capabilities.iter().any(|cap| cap == "relay"));
1221        assert!(profile.capabilities.iter().any(|cap| cap == "autochat"));
1222    }
1223}