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