1use 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
23fn 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 let config = Config::load().await.unwrap_or_default();
420
421 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 let (okr_id, okr_run_id) = if easy_go_requested {
452 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 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 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 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 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 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 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 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 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 let result = session.prompt(message).await?;
600
601 match args.format.as_str() {
603 "json" => {
604 println!("{}", serde_json::to_string_pretty(&result)?);
605 }
606 _ => {
607 println!("{}", result.text);
608 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) = ®istry {
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 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 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 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) = ®istry
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 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 for (kr_id, value) in &kr_progress {
1039 run.update_kr_progress(kr_id, *value);
1040 }
1041
1042 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 for (kr_id_str, value) in &kr_progress {
1058 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 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}