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