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