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