Skip to main content

lean_ctx/tools/
ctx_agent.rs

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}