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 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}