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;
5use crate::core::ocla::registry::OclaRegistry;
6
7#[allow(clippy::too_many_arguments)]
8pub fn handle(
9    action: &str,
10    agent_type: Option<&str>,
11    role: Option<&str>,
12    project_root: &str,
13    current_agent_id: Option<&str>,
14    message: Option<&str>,
15    category: Option<&str>,
16    to_agent: Option<&str>,
17    status: Option<&str>,
18    privacy: Option<&str>,
19    priority: Option<&str>,
20    _ttl_hours: Option<u64>,
21    format: Option<&str>,
22    write: bool,
23    filename: Option<&str>,
24) -> String {
25    match action {
26        "register" => {
27            let atype = agent_type.unwrap_or("unknown");
28            match AgentRegistry::mutate_locked(|registry| {
29                registry.cleanup_stale(24);
30                registry.register(atype, role, project_root)
31            }) {
32                Ok((_, agent_id)) => format!(
33                    "Agent registered: {agent_id} (type: {atype}, role: {})",
34                    role.unwrap_or("none")
35                ),
36                Err(e) => format!("Registration failed: {e}"),
37            }
38        }
39
40        "list" => {
41            let registry = match AgentRegistry::mutate_locked(|registry| {
42                registry.cleanup_stale(24);
43            }) {
44                Ok((registry, ())) => registry,
45                Err(e) => {
46                    tracing::warn!("lean-ctx: failed to persist agent registry: {e}");
47                    AgentRegistry::load_or_create()
48                }
49            };
50
51            let agents = registry.list_active(Some(project_root));
52            if agents.is_empty() {
53                return "No active agents for this project.".to_string();
54            }
55
56            let mut out = format!("Active agents ({}):\n", agents.len());
57            for a in agents {
58                let role_str = a.role.as_deref().unwrap_or("-");
59                let status_msg = a
60                    .status_message
61                    .as_deref()
62                    .map(|m| format!(" — {m}"))
63                    .unwrap_or_default();
64                let age = (chrono::Utc::now() - a.last_active).num_minutes();
65                out.push_str(&format!(
66                    "  {} [{}] role={} status={}{} (last active: {}m ago, pid: {})\n",
67                    a.agent_id, a.agent_type, role_str, a.status, status_msg, age, a.pid
68                ));
69            }
70            out
71        }
72
73        "post" => {
74            let Some(msg) = message else {
75                return "Error: message is required for post".to_string();
76            };
77            let cat = category.unwrap_or("status");
78            let from = current_agent_id.unwrap_or("anonymous");
79            let msg_privacy = privacy.map_or(PrivacyLevel::Team, PrivacyLevel::parse_str);
80            let msg_priority = priority.map_or(MessagePriority::Normal, MessagePriority::parse_str);
81            if msg_privacy == PrivacyLevel::Private && to_agent.is_none() {
82                return "Error: private messages require to_agent".to_string();
83            }
84            match OclaRegistry::global().agent_gateway.route_message(
85                from,
86                to_agent,
87                cat,
88                msg,
89                msg_privacy,
90                msg_priority,
91                _ttl_hours,
92            ) {
93                Ok(msg_id) => {
94                    let target = to_agent.unwrap_or("all agents (broadcast)");
95                    format!("Posted [{cat}] to {target}: {msg} (id: {msg_id})")
96                }
97                Err(e) => format!("Post failed: {e}"),
98            }
99        }
100
101        "read" => {
102            let Some(agent_id) = current_agent_id else {
103                return "Error: agent must be registered first (use action=register)".to_string();
104            };
105            let messages = match AgentRegistry::mutate_locked(|registry| {
106                registry
107                    .read_unread(agent_id)
108                    .into_iter()
109                    .cloned()
110                    .collect::<Vec<_>>()
111            }) {
112                Ok((_, messages)) => messages,
113                Err(e) => {
114                    tracing::warn!(
115                        "lean-ctx: failed to persist agent registry (messages may reappear): {e}"
116                    );
117                    return "Error: failed to read messages".to_string();
118                }
119            };
120
121            if messages.is_empty() {
122                return "No new messages.".to_string();
123            }
124
125            let mut out = format!("New messages ({}):\n", messages.len());
126            for m in &messages {
127                let age = (chrono::Utc::now() - m.timestamp).num_minutes();
128                out.push_str(&format!(
129                    "  [{}] from {} ({}m ago): {}\n",
130                    m.category, m.from_agent, age, m.message
131                ));
132            }
133            out
134        }
135
136        "status" => {
137            let Some(agent_id) = current_agent_id else {
138                return "Error: agent must be registered first".to_string();
139            };
140            let new_status = match status {
141                Some("active") => AgentStatus::Active,
142                Some("idle") => AgentStatus::Idle,
143                Some("finished") => AgentStatus::Finished,
144                Some(other) => {
145                    return format!("Unknown status: {other}. Use: active, idle, finished");
146                }
147                None => return "Error: status value is required".to_string(),
148            };
149            let status_msg = message;
150
151            match AgentRegistry::mutate_locked(|registry| {
152                registry.set_status(agent_id, new_status.clone(), status_msg);
153            }) {
154                Ok(_) => format!(
155                    "Status updated: {} → {}{}",
156                    agent_id,
157                    new_status,
158                    status_msg.map(|m| format!(" ({m})")).unwrap_or_default()
159                ),
160                Err(e) => format!("Status set but save failed: {e}"),
161            }
162        }
163
164        "info" => {
165            let registry = AgentRegistry::load_or_create();
166            let total = registry.agents.len();
167            let active = registry
168                .agents
169                .iter()
170                .filter(|a| a.status == AgentStatus::Active)
171                .count();
172            let messages = registry.scratchpad.len();
173            format!(
174                "Agent Registry: {total} total, {active} active, {messages} scratchpad entries\nLast updated: {}",
175                registry.updated_at.format("%Y-%m-%d %H:%M UTC")
176            )
177        }
178
179        "handoff" => {
180            let Some(from) = current_agent_id else {
181                return "Error: agent must be registered first".to_string();
182            };
183            let Some(target) = to_agent else {
184                return "Error: to_agent is required for handoff".to_string();
185            };
186            let summary = message.unwrap_or("(no summary provided)");
187
188            let _ = AgentRegistry::mutate_locked(|registry| {
189                registry.post_message(
190                    from,
191                    Some(target),
192                    "handoff",
193                    &format!("HANDOFF from {from}: {summary}"),
194                );
195                registry.set_status(from, AgentStatus::Finished, Some("handed off"));
196            });
197
198            // Stigmergy (#540): mark the handed-off work as Done in the field
199            // so other agents see it arithmetically, without reading messages.
200            crate::core::scent_field::deposit(
201                from,
202                crate::core::scent_field::ScentKind::Done,
203                summary,
204                1.0,
205            );
206
207            format!("Handoff complete: {from} → {target}\nSummary: {summary}")
208        }
209
210        // Stigmergic claim (#540): atomically claim a target (file, task,
211        // deploy unit) in the shared scent field. Fails fast when another
212        // agent's claim is still active — prevents duplicate work for ~0 tokens.
213        "claim" => {
214            let Some(target) = message else {
215                return "Error: message (the claim target, e.g. a file path or task label) is required for claim".to_string();
216            };
217            let agent = current_agent_id.map_or_else(
218                || crate::core::scent_field::scent_agent_id().to_string(),
219                str::to_string,
220            );
221            let normalized = crate::core::pathutil::normalize_tool_path(target);
222            match crate::core::scent_field::claim(&agent, &normalized) {
223                Ok(()) => {
224                    format!("Claimed: {normalized} (by {agent}, decays in ~10m unless re-claimed)")
225                }
226                Err(e) => format!("Claim REJECTED: {normalized} — {e}"),
227            }
228        }
229
230        // Release a stigmergic claim early (done or abandoned).
231        "release" => {
232            let Some(target) = message else {
233                return "Error: message (the claim target) is required for release".to_string();
234            };
235            let agent = current_agent_id.map_or_else(
236                || crate::core::scent_field::scent_agent_id().to_string(),
237                str::to_string,
238            );
239            let normalized = crate::core::pathutil::normalize_tool_path(target);
240            crate::core::scent_field::release(&agent, &normalized);
241            format!("Released: {normalized}")
242        }
243
244        // Sub-agent context contract (GL#450): deterministic briefing pack.
245        // `message` is the task; `priority` doubles as the token budget when
246        // numeric (default 2000). Same knowledge + task ⇒ byte-identical pack.
247        "brief" => {
248            let Some(task) = message else {
249                return "Error: message (the sub-agent task) is required for brief".to_string();
250            };
251            let budget = priority
252                .and_then(|p| p.parse::<usize>().ok())
253                .unwrap_or(2000);
254            let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(project_root)
255            else {
256                return "No knowledge stored for this project yet — a briefing pack needs facts. Use ctx_knowledge(action=\"remember\") first.".to_string();
257            };
258            let pack =
259                crate::core::subagent_contract::build_briefing_pack(&knowledge, task, budget);
260            match crate::core::subagent_contract::serialize_pack(&pack) {
261                Ok(json) => json,
262                Err(e) => format!("Error: {e}"),
263            }
264        }
265
266        // Return synthesis (GL#450): distill a sub-agent's report into
267        // recallable parent knowledge. Expects contract-formatted lines
268        // ('category/key: value'); rejects are listed, never silently dropped.
269        "return" => {
270            let Some(report) = message else {
271                return "Error: message (the sub-agent report) is required for return".to_string();
272            };
273            let (facts, rejected) = crate::core::subagent_contract::parse_return_lines(report);
274            if facts.is_empty() {
275                return format!(
276                    "No contract-formatted lines found ({} rejected). Expected 'category/key: value' per line.",
277                    rejected.len()
278                );
279            }
280
281            let session_label = current_agent_id.unwrap_or("subagent");
282            let policy = match crate::tools::knowledge_shared::load_policy_or_error() {
283                Ok(p) => p,
284                Err(e) => return e,
285            };
286            let count = facts.len();
287            let res = crate::core::knowledge::ProjectKnowledge::mutate_locked(
288                project_root,
289                |knowledge| {
290                    for f in &facts {
291                        knowledge.remember(
292                            &f.category,
293                            &f.key,
294                            &f.value,
295                            session_label,
296                            0.8,
297                            &policy,
298                        );
299                    }
300                },
301            );
302            match res {
303                Ok(_) => {
304                    let mut out = format!(
305                        "Return synthesis: {count} fact(s) distilled into parent knowledge"
306                    );
307                    if !rejected.is_empty() {
308                        out.push_str(&format!(
309                            "\n{} line(s) rejected (not 'category/key: value'):",
310                            rejected.len()
311                        ));
312                        for r in rejected.iter().take(5) {
313                            out.push_str(&format!("\n  ✗ {r}"));
314                        }
315                    }
316                    out
317                }
318                Err(e) => format!("Error: knowledge store update failed: {e}"),
319            }
320        }
321
322        "sync" => {
323            let registry = AgentRegistry::load_or_create();
324            let pending_count = current_agent_id.map_or(0, |id| {
325                registry
326                    .scratchpad
327                    .iter()
328                    .filter(|e| {
329                        !e.read_by.contains(&id.to_string())
330                            && e.from_agent != id
331                            && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(id))
332                    })
333                    .count()
334            });
335            let agents: Vec<&crate::core::agents::AgentEntry> = registry
336                .agents
337                .iter()
338                .filter(|a| a.status != AgentStatus::Finished && a.project_root == project_root)
339                .collect();
340
341            if agents.is_empty() {
342                return "No active agents to sync with.".to_string();
343            }
344
345            let shared_dir = crate::core::data_dir::lean_ctx_data_dir()
346                .unwrap_or_default()
347                .join("agents")
348                .join("shared");
349
350            let shared_count = if shared_dir.exists() {
351                std::fs::read_dir(&shared_dir).map_or(0, std::iter::Iterator::count)
352            } else {
353                0
354            };
355
356            let mut out = "Multi-Agent Sync Status:\n".to_string();
357            out.push_str(&format!("  Active agents: {}\n", agents.len()));
358            for a in &agents {
359                let role = a.role.as_deref().unwrap_or("-");
360                let age = (chrono::Utc::now() - a.last_active).num_minutes();
361                out.push_str(&format!(
362                    "    {} [{}] role={} ({}m ago)\n",
363                    a.agent_id, a.agent_type, role, age
364                ));
365            }
366            out.push_str(&format!("  Pending messages: {pending_count}\n"));
367            out.push_str(&format!("  Shared contexts: {shared_count}\n"));
368
369            // Stigmergy (#540): arithmetic field view — claims, stuck markers,
370            // hot files across all agents, no scratchpad reads needed.
371            let scents = crate::core::scent_field::sync_block();
372            if !scents.is_empty() {
373                out.push_str(&scents);
374            }
375            out
376        }
377
378        "export" => {
379            let Some(agent_id) = current_agent_id else {
380                return "Error: agent must be registered first (use action=register)".to_string();
381            };
382
383            fn privacy_label(p: &PrivacyLevel) -> &'static str {
384                match p {
385                    PrivacyLevel::Public => "public",
386                    PrivacyLevel::Team => "team",
387                    PrivacyLevel::Private => "private",
388                }
389            }
390
391            fn priority_label(p: &MessagePriority) -> &'static str {
392                match p {
393                    MessagePriority::Low => "low",
394                    MessagePriority::Normal => "normal",
395                    MessagePriority::High => "high",
396                    MessagePriority::Critical => "critical",
397                }
398            }
399
400            fn maybe_redact(s: &str, should_redact: bool) -> String {
401                if should_redact {
402                    crate::core::redaction::redact_text(s)
403                } else {
404                    s.to_string()
405                }
406            }
407
408            #[derive(serde::Serialize)]
409            struct ExportAgentV1 {
410                agent_id: String,
411                agent_type: String,
412                role: Option<String>,
413                status: String,
414                status_message: Option<String>,
415                started_at: String,
416                last_active: String,
417                pid: u32,
418            }
419
420            #[derive(serde::Serialize)]
421            struct ExportMessageV1 {
422                id: String,
423                from_agent: String,
424                to_agent: Option<String>,
425                category: String,
426                privacy: String,
427                priority: String,
428                message: String,
429                metadata: std::collections::BTreeMap<String, String>,
430                timestamp: String,
431                expires_at: Option<String>,
432                read_by_count: usize,
433            }
434
435            #[derive(serde::Serialize)]
436            struct ExportTaskV1 {
437                id: String,
438                from_agent: String,
439                to_agent: String,
440                state: String,
441                description: String,
442                created_at: String,
443                updated_at: String,
444                messages: usize,
445                artifacts: usize,
446                transitions: usize,
447            }
448
449            #[derive(serde::Serialize)]
450            struct ExportDiaryEntryV1 {
451                entry_type: String,
452                content: String,
453                context: Option<String>,
454                timestamp: String,
455            }
456
457            #[derive(serde::Serialize)]
458            struct ExportDiaryV1 {
459                agent_id: String,
460                agent_type: String,
461                project_root: String,
462                updated_at: String,
463                entries: Vec<ExportDiaryEntryV1>,
464            }
465
466            #[derive(serde::Serialize)]
467            struct A2ASnapshotV1 {
468                schema_version: u32,
469                created_at: String,
470                project_root: String,
471                agent_id: String,
472                agents: Vec<ExportAgentV1>,
473                messages: Vec<ExportMessageV1>,
474                tasks: Vec<ExportTaskV1>,
475                diary: Option<ExportDiaryV1>,
476            }
477
478            let privacy_mode = privacy.unwrap_or("redacted");
479            let allow_full = privacy_mode == "full"
480                && !crate::core::redaction::redaction_enabled_for_active_role();
481            let should_redact = !allow_full;
482
483            let now = chrono::Utc::now();
484            let mut registry = AgentRegistry::load_or_create();
485            registry.cleanup_stale(24);
486
487            let mut agents: Vec<ExportAgentV1> = registry
488                .list_active(Some(project_root))
489                .into_iter()
490                .map(|a| ExportAgentV1 {
491                    agent_id: a.agent_id.clone(),
492                    agent_type: a.agent_type.clone(),
493                    role: a.role.clone(),
494                    status: a.status.to_string(),
495                    status_message: a.status_message.clone(),
496                    started_at: a.started_at.to_rfc3339(),
497                    last_active: a.last_active.to_rfc3339(),
498                    pid: a.pid,
499                })
500                .collect();
501            agents.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
502
503            let mut messages: Vec<ExportMessageV1> = registry
504                .scratchpad
505                .iter()
506                .filter(|e| e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
507                .take(200)
508                .map(|m| ExportMessageV1 {
509                    id: m.id.clone(),
510                    from_agent: m.from_agent.clone(),
511                    to_agent: m.to_agent.clone(),
512                    category: m.category.clone(),
513                    privacy: privacy_label(&m.privacy).to_string(),
514                    priority: priority_label(&m.priority).to_string(),
515                    message: maybe_redact(&m.message, should_redact),
516                    metadata: m
517                        .metadata
518                        .iter()
519                        .map(|(k, v)| (k.clone(), maybe_redact(v, should_redact)))
520                        .collect(),
521                    timestamp: m.timestamp.to_rfc3339(),
522                    expires_at: m.expires_at.map(|t| t.to_rfc3339()),
523                    read_by_count: m.read_by.len(),
524                })
525                .collect();
526            messages.sort_by(|a, b| a.timestamp.cmp(&b.timestamp).then_with(|| a.id.cmp(&b.id)));
527
528            let mut task_store = TaskStore::load();
529            task_store.cleanup_old(72);
530            let mut tasks: Vec<ExportTaskV1> = task_store
531                .tasks_for_agent(agent_id)
532                .into_iter()
533                .take(200)
534                .map(|t| ExportTaskV1 {
535                    id: t.id.clone(),
536                    from_agent: t.from_agent.clone(),
537                    to_agent: t.to_agent.clone(),
538                    state: t.state.to_string(),
539                    description: maybe_redact(&t.description, should_redact),
540                    created_at: t.created_at.to_rfc3339(),
541                    updated_at: t.updated_at.to_rfc3339(),
542                    messages: t.messages.len(),
543                    artifacts: t.artifacts.len(),
544                    transitions: t.history.len(),
545                })
546                .collect();
547            tasks.sort_by(|a, b| {
548                b.updated_at
549                    .cmp(&a.updated_at)
550                    .then_with(|| a.id.cmp(&b.id))
551            });
552
553            let diary = AgentDiary::load(agent_id).map(|d| ExportDiaryV1 {
554                agent_id: d.agent_id,
555                agent_type: d.agent_type,
556                project_root: d.project_root,
557                updated_at: d.updated_at.to_rfc3339(),
558                entries: d
559                    .entries
560                    .iter()
561                    .rev()
562                    .take(25)
563                    .rev()
564                    .map(|e| ExportDiaryEntryV1 {
565                        entry_type: e.entry_type.to_string(),
566                        content: maybe_redact(&e.content, should_redact),
567                        context: e.context.as_deref().map(|c| maybe_redact(c, should_redact)),
568                        timestamp: e.timestamp.to_rfc3339(),
569                    })
570                    .collect(),
571            });
572
573            let payload = A2ASnapshotV1 {
574                schema_version: crate::core::contracts::A2A_SNAPSHOT_V1_SCHEMA_VERSION,
575                created_at: now.to_rfc3339(),
576                project_root: project_root.to_string(),
577                agent_id: agent_id.to_string(),
578                agents,
579                messages,
580                tasks,
581                diary,
582            };
583
584            let json = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string());
585
586            if write {
587                let proofs_dir = std::path::Path::new(project_root)
588                    .join(".lean-ctx")
589                    .join("proofs");
590                if let Err(e) = std::fs::create_dir_all(&proofs_dir) {
591                    return format!("Error: create proofs dir: {e}");
592                }
593
594                let name = if let Some(f) = filename {
595                    let p = std::path::Path::new(f);
596                    if p.components().count() != 1 {
597                        return "Error: filename must be a plain file name (no directories)"
598                            .to_string();
599                    }
600                    f.to_string()
601                } else {
602                    format!("a2a-snapshot-v1_{}.json", now.format("%Y%m%d_%H%M%S"))
603                };
604
605                let out_path = proofs_dir.join(name);
606                if let Err(e) = std::fs::write(&out_path, &json) {
607                    return format!("Error: write snapshot: {e}");
608                }
609
610                let mut ledger = EvidenceLedgerV1::load();
611                if let Err(e) = ledger.record_artifact_file(
612                    "proof:a2a-snapshot-v1",
613                    &out_path,
614                    chrono::Utc::now(),
615                ) {
616                    return format!("Snapshot written but evidence ledger record failed: {e}");
617                }
618                if let Err(e) = ledger.save() {
619                    return format!("Snapshot written but evidence ledger save failed: {e}");
620                }
621
622                return format!(
623                    "A2A snapshot exported: {}\n  agents: {}\n  messages: {}\n  tasks: {}",
624                    out_path.display(),
625                    payload.agents.len(),
626                    payload.messages.len(),
627                    payload.tasks.len()
628                );
629            }
630
631            match format.unwrap_or("json") {
632                "text" => format!(
633                    "A2A snapshot (v1)\n  agents: {}\n  messages: {}\n  tasks: {}",
634                    payload.agents.len(),
635                    payload.messages.len(),
636                    payload.tasks.len()
637                ),
638                _ => json,
639            }
640        }
641
642        "diary" => {
643            let Some(agent_id) = current_agent_id else {
644                return "Error: agent must be registered first".to_string();
645            };
646            let Some(content) = message else {
647                return "Error: message is required for diary entry".to_string();
648            };
649            let entry_type = match category.unwrap_or("progress") {
650                "discovery" | "found" => DiaryEntryType::Discovery,
651                "decision" | "decided" => DiaryEntryType::Decision,
652                "blocker" | "blocked" => DiaryEntryType::Blocker,
653                "progress" | "done" => DiaryEntryType::Progress,
654                "insight" => DiaryEntryType::Insight,
655                other => {
656                    return format!(
657                        "Unknown diary type: {other}. Use: discovery, decision, blocker, progress, insight"
658                    );
659                }
660            };
661            let atype = agent_type.unwrap_or("unknown");
662            let mut diary = AgentDiary::load_or_create(agent_id, atype, project_root);
663            let context_str = to_agent;
664            diary.add_entry(entry_type.clone(), content, context_str);
665            match diary.save() {
666                Ok(()) => format!("Diary entry [{entry_type}] added: {content}"),
667                Err(e) => format!("Diary entry added but save failed: {e}"),
668            }
669        }
670
671        "recall_diary" | "diary_recall" => {
672            let Some(agent_id) = current_agent_id else {
673                let diaries = AgentDiary::list_all();
674                if diaries.is_empty() {
675                    return "No agent diaries found.".to_string();
676                }
677                let mut out = format!("Agent Diaries ({}):\n", diaries.len());
678                for (id, count, updated) in &diaries {
679                    let age = (chrono::Utc::now() - *updated).num_minutes();
680                    out.push_str(&format!("  {id}: {count} entries ({age}m ago)\n"));
681                }
682                return out;
683            };
684            match AgentDiary::load(agent_id) {
685                Some(diary) => diary.format_summary(),
686                None => format!("No diary found for agent '{agent_id}'."),
687            }
688        }
689
690        "diaries" => {
691            let diaries = AgentDiary::list_all();
692            if diaries.is_empty() {
693                return "No agent diaries found.".to_string();
694            }
695            let mut out = format!("Agent Diaries ({}):\n", diaries.len());
696            for (id, count, updated) in &diaries {
697                let age = (chrono::Utc::now() - *updated).num_minutes();
698                out.push_str(&format!("  {id}: {count} entries ({age}m ago)\n"));
699            }
700            out
701        }
702
703        "share_knowledge" => {
704            let cat = category.unwrap_or("general");
705            let Some(msg_text) = message else {
706                return "Error: message required (format: key1=value1;key2=value2)".to_string();
707            };
708            let facts: Vec<(String, String)> = msg_text
709                .split(';')
710                .filter_map(|kv| {
711                    let (k, v) = kv.split_once('=')?;
712                    Some((k.trim().to_string(), v.trim().to_string()))
713                })
714                .collect();
715            if facts.is_empty() {
716                return "Error: no valid key=value pairs found".to_string();
717            }
718            let from = current_agent_id.unwrap_or("anonymous");
719            match AgentRegistry::mutate_locked(|registry| {
720                registry.share_knowledge(from, cat, &facts);
721            }) {
722                Ok(_) => format!("Shared {} facts in category '{}'", facts.len(), cat),
723                Err(e) => format!("Share failed: {e}"),
724            }
725        }
726
727        "receive_knowledge" => {
728            let Some(agent_id) = current_agent_id else {
729                return "Error: agent must be registered first".to_string();
730            };
731            let facts = AgentRegistry::mutate_locked(|registry| {
732                registry.receive_shared_knowledge(agent_id)
733            })
734            .map(|(_, facts)| facts)
735            .unwrap_or_default();
736            if facts.is_empty() {
737                return "No new shared knowledge.".to_string();
738            }
739            let mut out = format!("Received {} facts:\n", facts.len());
740            for f in &facts {
741                let age = (chrono::Utc::now() - f.timestamp).num_minutes();
742                out.push_str(&format!(
743                    "  [{}] {}={} (from {}, {}m ago)\n",
744                    f.category, f.key, f.value, f.from_agent, age
745                ));
746            }
747            out
748        }
749
750        "poll_events" => {
751            let Some(agent_id) = current_agent_id else {
752                return "Error: agent must be registered first".to_string();
753            };
754            let workspace_id = to_agent.unwrap_or(project_root);
755            let channel_id = category.unwrap_or("default");
756            let since_id: i64 = message.and_then(|s| s.parse().ok()).unwrap_or(0);
757            let limit: usize = _ttl_hours.unwrap_or(50) as usize;
758
759            let rt = crate::core::context_os::runtime();
760            let events = rt.bus.read(workspace_id, channel_id, since_id, limit);
761
762            let filter = crate::core::context_os::TopicFilter {
763                agent_id: Some(agent_id.to_string()),
764                kinds: privacy.and_then(|s| {
765                    let kinds: Vec<_> = s
766                        .split(',')
767                        .map(|k| crate::core::context_os::ContextEventKindV1::parse(k.trim()))
768                        .collect();
769                    if kinds.is_empty() { None } else { Some(kinds) }
770                }),
771                ..Default::default()
772            };
773
774            let filtered: Vec<_> = events.into_iter().filter(|e| filter.matches(e)).collect();
775            if filtered.is_empty() {
776                return format!("No new events since id={since_id} for {agent_id}.");
777            }
778
779            let mut out = format!("Events ({}, since={since_id}):\n", filtered.len());
780            for ev in &filtered {
781                let actor = ev.actor.as_deref().unwrap_or("-");
782                out.push_str(&format!(
783                    "  #{} [{}] actor={} cl={} ({})\n",
784                    ev.id,
785                    ev.kind,
786                    actor,
787                    ev.consistency_level,
788                    ev.timestamp.format("%H:%M:%S")
789                ));
790            }
791            if let Some(last) = filtered.last() {
792                out.push_str(&format!("cursor={}", last.id));
793            }
794            out
795        }
796
797        // P11: Acquire an opaque path/symbol lease for mutation planning.
798        "lease_acquire" => {
799            let Some(agent_id) = current_agent_id else {
800                return "Error: agent must be registered first".to_string();
801            };
802            let Some(target) = message else {
803                return "Error: message (path or symbol:<name>) is required".to_string();
804            };
805            let (resource_kind, resource_ref) = lease_resource(target);
806            let duration_ms = match _ttl_hours.unwrap_or(0) {
807                0 => 10 * 60 * 1_000,
808                1 => 60 * 60 * 1_000,
809                _ => return "Error: ttl_hours for lease must be 0 (10min) or 1 (1h)".to_string(),
810            };
811            let request = crate::core::agent_lease::AgentLeaseRequestV1 {
812                schema_version: crate::core::agent_lease::AGENT_LEASE_SCHEMA_VERSION,
813                lease_request_ref: format!(
814                    "lease-request:{}",
815                    blake3::hash(format!("{agent_id}:{resource_ref}").as_bytes()).to_hex()
816                ),
817                resource_kind,
818                resource_ref: resource_ref.clone(),
819                owner_agent_id: agent_id.to_string(),
820                duration_ms,
821            };
822            match crate::core::agent_lease::acquire_local(request) {
823                Ok(crate::core::agent_lease::AgentLeaseAcquireV1::Granted(lease)) => format!(
824                    "Lease granted: {} resource={} owner={} expires_at_epoch_ms={}",
825                    lease.lease_ref, resource_ref, agent_id, lease.expires_at_epoch_ms
826                ),
827                Ok(crate::core::agent_lease::AgentLeaseAcquireV1::HeldBy {
828                    owner_agent_id,
829                    lease_ref,
830                    expires_at_epoch_ms,
831                }) => format!(
832                    "Lease HELD: resource={resource_ref} owner={owner_agent_id} lease_ref={lease_ref} expires={expires_at_epoch_ms}"
833                ),
834                Err(e) => format!("Lease rejected: {e}"),
835            }
836        }
837
838        // P11: Release a previously acquired lease.
839        "lease_release" => {
840            let Some(agent_id) = current_agent_id else {
841                return "Error: agent must be registered first".to_string();
842            };
843            let Some(target) = message else {
844                return "Error: message (same path or symbol:<name>) is required".to_string();
845            };
846            let Some(lease_ref) = category else {
847                return "Error: category (lease_ref from lease_acquire) is required".to_string();
848            };
849            let (resource_kind, resource_ref) = lease_resource(target);
850            match crate::core::agent_lease::release_local(
851                resource_kind,
852                &resource_ref,
853                agent_id,
854                lease_ref,
855            ) {
856                Ok(true) => format!("Lease released: {lease_ref} resource={resource_ref}"),
857                Ok(false) => format!("No active lease found for resource={resource_ref}"),
858                Err(e) => format!("Release failed: {e}"),
859            }
860        }
861
862        _ => format!(
863            "Unknown action: {action}. Use: register, list, post, read, status, info, handoff, sync, poll_events, diary, recall_diary, diaries, share_knowledge, receive_knowledge, lease_acquire, lease_release"
864        ),
865    }
866}
867
868/// Convert a raw path/symbol target into an opaque lease resource.
869fn lease_resource(target: &str) -> (crate::core::agent_lease::AgentLeaseResourceKindV1, String) {
870    if let Some(symbol) = target.strip_prefix("symbol:") {
871        (
872            crate::core::agent_lease::AgentLeaseResourceKindV1::Symbol,
873            format!("symbolref:{}", blake3::hash(symbol.as_bytes()).to_hex()),
874        )
875    } else {
876        let normalized = crate::core::pathutil::normalize_tool_path(target);
877        (
878            crate::core::agent_lease::AgentLeaseResourceKindV1::Path,
879            format!("pathref:{}", blake3::hash(normalized.as_bytes()).to_hex()),
880        )
881    }
882}