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