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