1use crate::core::a2a::message::{MessagePriority, PrivacyLevel};
2use crate::core::a2a::task::TaskStore;
3use crate::core::agents::{AgentDiary, AgentRegistry, AgentStatus, DiaryEntryType};
4use crate::core::evidence_ledger::EvidenceLedgerV1;
5
6#[allow(clippy::too_many_arguments)]
7pub fn handle(
8 action: &str,
9 agent_type: Option<&str>,
10 role: Option<&str>,
11 project_root: &str,
12 current_agent_id: Option<&str>,
13 message: Option<&str>,
14 category: Option<&str>,
15 to_agent: Option<&str>,
16 status: Option<&str>,
17 privacy: Option<&str>,
18 priority: Option<&str>,
19 _ttl_hours: Option<u64>,
20 format: Option<&str>,
21 write: bool,
22 filename: Option<&str>,
23) -> String {
24 match action {
25 "register" => {
26 let atype = agent_type.unwrap_or("unknown");
27 let mut registry = AgentRegistry::load_or_create();
28 registry.cleanup_stale(24);
29 let agent_id = registry.register(atype, role, project_root);
30 match registry.save() {
31 Ok(()) => format!(
32 "Agent registered: {agent_id} (type: {atype}, role: {})",
33 role.unwrap_or("none")
34 ),
35 Err(e) => format!("Registered as {agent_id} but save failed: {e}"),
36 }
37 }
38
39 "list" => {
40 let mut registry = AgentRegistry::load_or_create();
41 registry.cleanup_stale(24);
42 let _ = registry.save();
43
44 let agents = registry.list_active(Some(project_root));
45 if agents.is_empty() {
46 return "No active agents for this project.".to_string();
47 }
48
49 let mut out = format!("Active agents ({}):\n", agents.len());
50 for a in agents {
51 let role_str = a.role.as_deref().unwrap_or("-");
52 let status_msg = a
53 .status_message
54 .as_deref()
55 .map(|m| format!(" — {m}"))
56 .unwrap_or_default();
57 let age = (chrono::Utc::now() - a.last_active).num_minutes();
58 out.push_str(&format!(
59 " {} [{}] role={} status={}{} (last active: {}m ago, pid: {})\n",
60 a.agent_id, a.agent_type, role_str, a.status, status_msg, age, a.pid
61 ));
62 }
63 out
64 }
65
66 "post" => {
67 let Some(msg) = message else { return "Error: message is required for post".to_string() };
68 let cat = category.unwrap_or("status");
69 let from = current_agent_id.unwrap_or("anonymous");
70 let _privacy = privacy
71 .map_or(PrivacyLevel::Team, PrivacyLevel::parse_str);
72 let _priority = priority
73 .map_or(MessagePriority::Normal, MessagePriority::parse_str);
74 let mut registry = AgentRegistry::load_or_create();
75 let msg_id = registry.post_message(from, to_agent, cat, msg);
76 match registry.save() {
77 Ok(()) => {
78 let target = to_agent.unwrap_or("all agents (broadcast)");
79 format!("Posted [{cat}] to {target}: {msg} (id: {msg_id})")
80 }
81 Err(e) => format!("Posted but save failed: {e}"),
82 }
83 }
84
85 "read" => {
86 let Some(agent_id) = current_agent_id else {
87 return "Error: agent must be registered first (use action=register)"
88 .to_string()
89 };
90 let mut registry = AgentRegistry::load_or_create();
91 let messages = registry.read_unread(agent_id);
92
93 if messages.is_empty() {
94 let _ = registry.save();
95 return "No new messages.".to_string();
96 }
97
98 let mut out = format!("New messages ({}):\n", messages.len());
99 for m in &messages {
100 let age = (chrono::Utc::now() - m.timestamp).num_minutes();
101 out.push_str(&format!(
102 " [{}] from {} ({}m ago): {}\n",
103 m.category, m.from_agent, age, m.message
104 ));
105 }
106 let _ = registry.save();
107 out
108 }
109
110 "status" => {
111 let Some(agent_id) = current_agent_id else { return "Error: agent must be registered first".to_string() };
112 let new_status = match status {
113 Some("active") => AgentStatus::Active,
114 Some("idle") => AgentStatus::Idle,
115 Some("finished") => AgentStatus::Finished,
116 Some(other) => {
117 return format!("Unknown status: {other}. Use: active, idle, finished")
118 }
119 None => return "Error: status value is required".to_string(),
120 };
121 let status_msg = message;
122
123 let mut registry = AgentRegistry::load_or_create();
124 registry.set_status(agent_id, new_status.clone(), status_msg);
125 match registry.save() {
126 Ok(()) => format!(
127 "Status updated: {} → {}{}",
128 agent_id,
129 new_status,
130 status_msg.map(|m| format!(" ({m})")).unwrap_or_default()
131 ),
132 Err(e) => format!("Status set but save failed: {e}"),
133 }
134 }
135
136 "info" => {
137 let registry = AgentRegistry::load_or_create();
138 let total = registry.agents.len();
139 let active = registry
140 .agents
141 .iter()
142 .filter(|a| a.status == AgentStatus::Active)
143 .count();
144 let messages = registry.scratchpad.len();
145 format!(
146 "Agent Registry: {total} total, {active} active, {messages} scratchpad entries\nLast updated: {}",
147 registry.updated_at.format("%Y-%m-%d %H:%M UTC")
148 )
149 }
150
151 "handoff" => {
152 let Some(from) = current_agent_id else { return "Error: agent must be registered first".to_string() };
153 let Some(target) = to_agent else { return "Error: to_agent is required for handoff".to_string() };
154 let summary = message.unwrap_or("(no summary provided)");
155
156 let mut registry = AgentRegistry::load_or_create();
157
158 registry.post_message(
159 from,
160 Some(target),
161 "handoff",
162 &format!("HANDOFF from {from}: {summary}"),
163 );
164
165 registry.set_status(from, AgentStatus::Finished, Some("handed off"));
166 let _ = registry.save();
167
168 format!("Handoff complete: {from} → {target}\nSummary: {summary}")
169 }
170
171 "sync" => {
172 let registry = AgentRegistry::load_or_create();
173 let pending_count = current_agent_id.map_or(0, |id| {
174 registry
175 .scratchpad
176 .iter()
177 .filter(|e| {
178 !e.read_by.contains(&id.to_string())
179 && e.from_agent != id
180 && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(id))
181 })
182 .count()
183 });
184 let agents: Vec<&crate::core::agents::AgentEntry> = registry
185 .agents
186 .iter()
187 .filter(|a| a.status != AgentStatus::Finished && a.project_root == project_root)
188 .collect();
189
190 if agents.is_empty() {
191 return "No active agents to sync with.".to_string();
192 }
193
194 let shared_dir = crate::core::data_dir::lean_ctx_data_dir()
195 .unwrap_or_default()
196 .join("agents")
197 .join("shared");
198
199 let shared_count = if shared_dir.exists() {
200 std::fs::read_dir(&shared_dir)
201 .map_or(0, std::iter::Iterator::count)
202 } else {
203 0
204 };
205
206 let mut out = "Multi-Agent Sync Status:\n".to_string();
207 out.push_str(&format!(" Active agents: {}\n", agents.len()));
208 for a in &agents {
209 let role = a.role.as_deref().unwrap_or("-");
210 let age = (chrono::Utc::now() - a.last_active).num_minutes();
211 out.push_str(&format!(
212 " {} [{}] role={} ({}m ago)\n",
213 a.agent_id, a.agent_type, role, age
214 ));
215 }
216 out.push_str(&format!(" Pending messages: {pending_count}\n"));
217 out.push_str(&format!(" Shared contexts: {shared_count}\n"));
218 out
219 }
220
221 "export" => {
222 let Some(agent_id) = current_agent_id else {
223 return "Error: agent must be registered first (use action=register)".to_string();
224 };
225
226 fn privacy_label(p: &PrivacyLevel) -> &'static str {
227 match p {
228 PrivacyLevel::Public => "public",
229 PrivacyLevel::Team => "team",
230 PrivacyLevel::Private => "private",
231 }
232 }
233
234 fn priority_label(p: &MessagePriority) -> &'static str {
235 match p {
236 MessagePriority::Low => "low",
237 MessagePriority::Normal => "normal",
238 MessagePriority::High => "high",
239 MessagePriority::Critical => "critical",
240 }
241 }
242
243 fn maybe_redact(s: &str, should_redact: bool) -> String {
244 if should_redact {
245 crate::core::redaction::redact_text(s)
246 } else {
247 s.to_string()
248 }
249 }
250
251 #[derive(serde::Serialize)]
252 struct ExportAgentV1 {
253 agent_id: String,
254 agent_type: String,
255 role: Option<String>,
256 status: String,
257 status_message: Option<String>,
258 started_at: String,
259 last_active: String,
260 pid: u32,
261 }
262
263 #[derive(serde::Serialize)]
264 struct ExportMessageV1 {
265 id: String,
266 from_agent: String,
267 to_agent: Option<String>,
268 category: String,
269 privacy: String,
270 priority: String,
271 message: String,
272 metadata: std::collections::BTreeMap<String, String>,
273 timestamp: String,
274 expires_at: Option<String>,
275 read_by_count: usize,
276 }
277
278 #[derive(serde::Serialize)]
279 struct ExportTaskV1 {
280 id: String,
281 from_agent: String,
282 to_agent: String,
283 state: String,
284 description: String,
285 created_at: String,
286 updated_at: String,
287 messages: usize,
288 artifacts: usize,
289 transitions: usize,
290 }
291
292 #[derive(serde::Serialize)]
293 struct ExportDiaryEntryV1 {
294 entry_type: String,
295 content: String,
296 context: Option<String>,
297 timestamp: String,
298 }
299
300 #[derive(serde::Serialize)]
301 struct ExportDiaryV1 {
302 agent_id: String,
303 agent_type: String,
304 project_root: String,
305 updated_at: String,
306 entries: Vec<ExportDiaryEntryV1>,
307 }
308
309 #[derive(serde::Serialize)]
310 struct A2ASnapshotV1 {
311 schema_version: u32,
312 created_at: String,
313 project_root: String,
314 agent_id: String,
315 agents: Vec<ExportAgentV1>,
316 messages: Vec<ExportMessageV1>,
317 tasks: Vec<ExportTaskV1>,
318 diary: Option<ExportDiaryV1>,
319 }
320
321 let privacy_mode = privacy.unwrap_or("redacted");
322 let allow_full = privacy_mode == "full"
323 && !crate::core::redaction::redaction_enabled_for_active_role();
324 let should_redact = !allow_full;
325
326 let now = chrono::Utc::now();
327 let mut registry = AgentRegistry::load_or_create();
328 registry.cleanup_stale(24);
329
330 let mut agents: Vec<ExportAgentV1> = registry
331 .list_active(Some(project_root))
332 .into_iter()
333 .map(|a| ExportAgentV1 {
334 agent_id: a.agent_id.clone(),
335 agent_type: a.agent_type.clone(),
336 role: a.role.clone(),
337 status: a.status.to_string(),
338 status_message: a.status_message.clone(),
339 started_at: a.started_at.to_rfc3339(),
340 last_active: a.last_active.to_rfc3339(),
341 pid: a.pid,
342 })
343 .collect();
344 agents.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
345
346 let mut messages: Vec<ExportMessageV1> = registry
347 .scratchpad
348 .iter()
349 .filter(|e| {
350 e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id)
351 })
352 .take(200)
353 .map(|m| ExportMessageV1 {
354 id: m.id.clone(),
355 from_agent: m.from_agent.clone(),
356 to_agent: m.to_agent.clone(),
357 category: m.category.clone(),
358 privacy: privacy_label(&m.privacy).to_string(),
359 priority: priority_label(&m.priority).to_string(),
360 message: maybe_redact(&m.message, should_redact),
361 metadata: m
362 .metadata
363 .iter()
364 .map(|(k, v)| (k.clone(), maybe_redact(v, should_redact)))
365 .collect(),
366 timestamp: m.timestamp.to_rfc3339(),
367 expires_at: m.expires_at.map(|t| t.to_rfc3339()),
368 read_by_count: m.read_by.len(),
369 })
370 .collect();
371 messages.sort_by(|a, b| {
372 a.timestamp
373 .cmp(&b.timestamp)
374 .then_with(|| a.id.cmp(&b.id))
375 });
376
377 let mut task_store = TaskStore::load();
378 task_store.cleanup_old(72);
379 let mut tasks: Vec<ExportTaskV1> = task_store
380 .tasks_for_agent(agent_id)
381 .into_iter()
382 .take(200)
383 .map(|t| ExportTaskV1 {
384 id: t.id.clone(),
385 from_agent: t.from_agent.clone(),
386 to_agent: t.to_agent.clone(),
387 state: t.state.to_string(),
388 description: maybe_redact(&t.description, should_redact),
389 created_at: t.created_at.to_rfc3339(),
390 updated_at: t.updated_at.to_rfc3339(),
391 messages: t.messages.len(),
392 artifacts: t.artifacts.len(),
393 transitions: t.history.len(),
394 })
395 .collect();
396 tasks.sort_by(|a, b| {
397 b.updated_at
398 .cmp(&a.updated_at)
399 .then_with(|| a.id.cmp(&b.id))
400 });
401
402 let diary = AgentDiary::load(agent_id).map(|d| ExportDiaryV1 {
403 agent_id: d.agent_id,
404 agent_type: d.agent_type,
405 project_root: d.project_root,
406 updated_at: d.updated_at.to_rfc3339(),
407 entries: d
408 .entries
409 .iter()
410 .rev()
411 .take(25)
412 .rev()
413 .map(|e| ExportDiaryEntryV1 {
414 entry_type: e.entry_type.to_string(),
415 content: maybe_redact(&e.content, should_redact),
416 context: e.context.as_deref().map(|c| maybe_redact(c, should_redact)),
417 timestamp: e.timestamp.to_rfc3339(),
418 })
419 .collect(),
420 });
421
422 let payload = A2ASnapshotV1 {
423 schema_version: crate::core::contracts::A2A_SNAPSHOT_V1_SCHEMA_VERSION,
424 created_at: now.to_rfc3339(),
425 project_root: project_root.to_string(),
426 agent_id: agent_id.to_string(),
427 agents,
428 messages,
429 tasks,
430 diary,
431 };
432
433 let json = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string());
434
435 if write {
436 let proofs_dir = std::path::Path::new(project_root)
437 .join(".lean-ctx")
438 .join("proofs");
439 if let Err(e) = std::fs::create_dir_all(&proofs_dir) {
440 return format!("Error: create proofs dir: {e}");
441 }
442
443 let name = if let Some(f) = filename {
444 let p = std::path::Path::new(f);
445 if p.components().count() != 1 {
446 return "Error: filename must be a plain file name (no directories)"
447 .to_string();
448 }
449 f.to_string()
450 } else {
451 format!("a2a-snapshot-v1_{}.json", now.format("%Y%m%d_%H%M%S"))
452 };
453
454 let out_path = proofs_dir.join(name);
455 if let Err(e) = std::fs::write(&out_path, &json) {
456 return format!("Error: write snapshot: {e}");
457 }
458
459 let mut ledger = EvidenceLedgerV1::load();
460 if let Err(e) = ledger.record_artifact_file(
461 "proof:a2a-snapshot-v1",
462 &out_path,
463 chrono::Utc::now(),
464 ) {
465 return format!("Snapshot written but evidence ledger record failed: {e}");
466 }
467 if let Err(e) = ledger.save() {
468 return format!("Snapshot written but evidence ledger save failed: {e}");
469 }
470
471 return format!(
472 "A2A snapshot exported: {}\n agents: {}\n messages: {}\n tasks: {}",
473 out_path.display(),
474 payload.agents.len(),
475 payload.messages.len(),
476 payload.tasks.len()
477 );
478 }
479
480 match format.unwrap_or("json") {
481 "text" => format!(
482 "A2A snapshot (v1)\n agents: {}\n messages: {}\n tasks: {}",
483 payload.agents.len(),
484 payload.messages.len(),
485 payload.tasks.len()
486 ),
487 _ => json,
488 }
489 }
490
491 "diary" => {
492 let Some(agent_id) = current_agent_id else { return "Error: agent must be registered first".to_string() };
493 let Some(content) = message else { return "Error: message is required for diary entry".to_string() };
494 let entry_type = match category.unwrap_or("progress") {
495 "discovery" | "found" => DiaryEntryType::Discovery,
496 "decision" | "decided" => DiaryEntryType::Decision,
497 "blocker" | "blocked" => DiaryEntryType::Blocker,
498 "progress" | "done" => DiaryEntryType::Progress,
499 "insight" => DiaryEntryType::Insight,
500 other => return format!("Unknown diary type: {other}. Use: discovery, decision, blocker, progress, insight"),
501 };
502 let atype = agent_type.unwrap_or("unknown");
503 let mut diary = AgentDiary::load_or_create(agent_id, atype, project_root);
504 let context_str = to_agent;
505 diary.add_entry(entry_type.clone(), content, context_str);
506 match diary.save() {
507 Ok(()) => format!("Diary entry [{entry_type}] added: {content}"),
508 Err(e) => format!("Diary entry added but save failed: {e}"),
509 }
510 }
511
512 "recall_diary" | "diary_recall" => {
513 let Some(agent_id) = current_agent_id else {
514 let diaries = AgentDiary::list_all();
515 if diaries.is_empty() {
516 return "No agent diaries found.".to_string();
517 }
518 let mut out = format!("Agent Diaries ({}):\n", diaries.len());
519 for (id, count, updated) in &diaries {
520 let age = (chrono::Utc::now() - *updated).num_minutes();
521 out.push_str(&format!(" {id}: {count} entries ({age}m ago)\n"));
522 }
523 return out;
524 };
525 match AgentDiary::load(agent_id) {
526 Some(diary) => diary.format_summary(),
527 None => format!("No diary found for agent '{agent_id}'."),
528 }
529 }
530
531 "diaries" => {
532 let diaries = AgentDiary::list_all();
533 if diaries.is_empty() {
534 return "No agent diaries found.".to_string();
535 }
536 let mut out = format!("Agent Diaries ({}):\n", diaries.len());
537 for (id, count, updated) in &diaries {
538 let age = (chrono::Utc::now() - *updated).num_minutes();
539 out.push_str(&format!(" {id}: {count} entries ({age}m ago)\n"));
540 }
541 out
542 }
543
544 "share_knowledge" => {
545 let cat = category.unwrap_or("general");
546 let Some(msg_text) = message else { return "Error: message required (format: key1=value1;key2=value2)".to_string() };
547 let facts: Vec<(String, String)> = msg_text
548 .split(';')
549 .filter_map(|kv| {
550 let (k, v) = kv.split_once('=')?;
551 Some((k.trim().to_string(), v.trim().to_string()))
552 })
553 .collect();
554 if facts.is_empty() {
555 return "Error: no valid key=value pairs found".to_string();
556 }
557 let from = current_agent_id.unwrap_or("anonymous");
558 let mut registry = AgentRegistry::load_or_create();
559 registry.share_knowledge(from, cat, &facts);
560 match registry.save() {
561 Ok(()) => format!("Shared {} facts in category '{}'", facts.len(), cat),
562 Err(e) => format!("Share failed: {e}"),
563 }
564 }
565
566 "receive_knowledge" => {
567 let Some(agent_id) = current_agent_id else { return "Error: agent must be registered first".to_string() };
568 let mut registry = AgentRegistry::load_or_create();
569 let facts = registry.receive_shared_knowledge(agent_id);
570 let _ = registry.save();
571 if facts.is_empty() {
572 return "No new shared knowledge.".to_string();
573 }
574 let mut out = format!("Received {} facts:\n", facts.len());
575 for f in &facts {
576 let age = (chrono::Utc::now() - f.timestamp).num_minutes();
577 out.push_str(&format!(
578 " [{}] {}={} (from {}, {}m ago)\n",
579 f.category, f.key, f.value, f.from_agent, age
580 ));
581 }
582 out
583 }
584
585 _ => format!("Unknown action: {action}. Use: register, list, post, read, status, info, handoff, sync, diary, recall_diary, diaries, share_knowledge, receive_knowledge"),
586 }
587}