1use lean_ctx::core::a2a::message::{MessagePriority, PrivacyLevel};
3use lean_ctx::core::a2a::task::TaskStore;
4use lean_ctx::core::agents::{
5 AgentDiary, AgentRegistry, AgentStatus, DiaryEntryType, ScratchpadEntry,
6};
7use lean_ctx::core::ocla::capsule::global_capsule_store;
8use lean_ctx::core::ocla::registry::OclaRegistry;
9use lean_ctx::core::ocla::types::{
10 AGENT_ENVELOPE_SCHEMA_VERSION, AgentEnvelope, AgentMessageRequest, OclaRequestContext,
11};
12
13#[path = "ctx_agent_control.rs"]
14mod control;
15#[path = "ctx_agent_lease.rs"]
16mod lease;
17use control::{control_status, lease_resource};
18#[cfg(test)]
19use lease::LeaseSettle;
20
21fn presence_ttl_hours() -> u64 {
22 lean_ctx::core::config::Config::load()
23 .agents
24 .presence_ttl_hours
25}
26
27fn scratchpad_default_ttl_hours() -> u64 {
28 lean_ctx::core::config::Config::load()
29 .agents
30 .scratchpad_default_ttl_hours
31}
32
33pub(crate) const ACTIONS: &[&str] = &[
36 "register",
37 "list",
38 "post",
39 "read",
40 "wait",
41 "status",
42 "info",
43 "handoff",
44 "sync",
45 "claim",
46 "release",
47 "brief",
48 "return",
49 "export",
50 "diary",
51 "recall_diary",
52 "diaries",
53 "share_knowledge",
54 "receive_knowledge",
55 "poll_events",
56 "control_status",
57 "lease_acquire",
58 "lease_renew",
59 "lease_release",
60 "send",
61 "reply",
62 "inbox",
63];
64
65#[derive(Debug, Clone, Default)]
67pub struct MessageArgs {
68 pub message_type: Option<String>,
69 pub text: Option<String>,
70 pub to: Option<String>,
71 pub reply_to: Option<String>,
72 pub references: Vec<String>,
73 pub accept: Option<bool>,
75}
76
77impl MessageArgs {
78 fn untyped(text: Option<&str>, category: Option<&str>, to: Option<&str>) -> Self {
79 Self {
80 message_type: category.map(str::to_string),
81 text: text.map(str::to_string),
82 to: to.map(str::to_string),
83 ..Self::default()
84 }
85 }
86}
87
88pub fn handle_message(
93 action: &str,
94 args: &MessageArgs,
95 project_root: &str,
96 current_agent_id: Option<&str>,
97) -> String {
98 use lean_ctx_hive::bus::messages_v2::{Extras, send};
99 let Some(agent) = current_agent_id else {
100 return "Error: agent must be registered first (use action=register)".to_string();
101 };
102 if action == "inbox" {
103 return match read_messages_as(agent, project_root, true) {
104 Ok(text) | Err(text) => text,
105 };
106 }
107 let Some(text) = args.text.as_deref() else {
108 return format!("Error: message is required for {action}");
109 };
110 let reply_to = args.reply_to.as_deref();
111 if action == "reply" && reply_to.is_none() {
112 return "Error: reply_to (the msg_… id of the message answered) is required".to_string();
113 }
114 let message_type = args
115 .message_type
116 .as_deref()
117 .or((action == "reply").then_some("answer"));
118 let extras = Extras {
119 to_worker: args.to.as_deref(),
120 reply_to,
121 accept: args.accept,
122 resource: args.references.first().map(String::as_str),
123 refs: &args.references,
124 };
125 match send(project_root, agent, message_type, text, extras) {
126 Ok(id) => format!(
127 "Queued [{}] {id}; the Hive relay sends it to the vault's agents",
128 message_type.unwrap_or("?")
129 ),
130 Err(error) => format!("Error: {error}"),
131 }
132}
133
134pub fn handle_read(
141 message: Option<&str>,
142 current_agent_id: Option<&str>,
143 project_root: &str,
144) -> Result<String, String> {
145 if let Some(text) = message
147 && let Err(withheld) = lean_ctx::core::context_admission::carrier::admit_for_store(text)
148 {
149 return Err(format!("Error: {}", withheld.message("agent message")));
150 }
151 read_messages(current_agent_id, project_root)
152}
153
154fn read_messages(current_agent_id: Option<&str>, project_root: &str) -> Result<String, String> {
156 let Some(agent_id) = current_agent_id else {
157 return Err("Error: agent must be registered first (use action=register)".to_string());
158 };
159 let mut text = read_messages_as(agent_id, project_root, true)?;
160 if let Some(sessions) =
162 lean_ctx::core::providers::emspace::admitted_sessions_block(project_root)
163 {
164 text.push_str(&sessions);
165 }
166 Ok(text)
167}
168
169fn renewed_line(agent_id: &str) -> String {
171 format!(
172 "Note: the registration of {agent_id} had expired and was renewed; retry a claim refused meanwhile.\n"
173 )
174}
175
176pub(crate) const READ_PAGE_BYTES: usize = 6_000;
180
181fn render_message(m: &ScratchpadEntry, project_root: &str) -> String {
183 let age = (chrono::Utc::now() - m.timestamp).num_minutes();
184 if lean_ctx_hive::bus::relay_v2::is_inbound(m) {
187 return format!(
188 " [hive] ({age}m ago):\n{}\n",
189 m.served_message(Some(project_root))
190 );
191 }
192 format!(
193 " [{}] from {} ({}m ago): {}\n",
194 m.category,
195 m.from_agent,
196 age,
197 m.served_message(Some(project_root))
198 )
199}
200
201pub fn read_messages_as(
207 agent_id: &str,
208 project_root: &str,
209 own_process: bool,
210) -> Result<String, String> {
211 let (shown, left) = match AgentRegistry::mutate_locked(|registry| {
212 if own_process {
213 registry.renew_worker(agent_id)?;
214 }
215 registry.read_unread_page(agent_id, project_root, READ_PAGE_BYTES, |m| {
216 render_message(m, project_root)
217 })
218 }) {
219 Ok((_, Ok(page))) => page,
220 Ok((_, Err(error))) => {
221 tracing::warn!("lean-ctx: message read rejected: {error}");
222 return Err(format!("Error: {error}"));
223 }
224 Err(e) => {
225 tracing::warn!(
226 "lean-ctx: failed to persist agent registry (messages may reappear): {e}"
227 );
228 return Err("Error: failed to read messages".to_string());
229 }
230 };
231
232 let mut renewed = if lean_ctx::core::agents::take_renewed(agent_id) {
233 renewed_line(agent_id)
234 } else {
235 String::new()
236 };
237 if own_process && let Some(note) = lean_ctx::core::binary_identity::replaced_note() {
239 renewed.insert_str(0, ¬e);
240 }
241 if shown.is_empty() {
242 return Ok(format!("{renewed}No new messages."));
243 }
244
245 let mut out = format!("{renewed}New messages ({}):\n", shown.len());
246 for line in &shown {
247 out.push_str(line);
248 }
249 if left > 0 {
250 out.push_str(&format!(
251 "{left} more unread (still unread): read again for the next ones.\n"
252 ));
253 }
254 Ok(out)
255}
256
257pub const WAIT_DEFAULT_SECS: u64 = 600;
259pub const WAIT_MAX_SECS: u64 = 1800;
260
261pub fn wait_timeout_line(secs: u64) -> String {
263 format!("No new messages within {secs}s.")
264}
265
266pub fn wait_messages(
272 agent_id: &str,
273 project_root: &str,
274 timeout_secs: Option<u64>,
275 own_process: bool,
276) -> Result<Option<String>, String> {
277 let secs = timeout_secs.unwrap_or(WAIT_DEFAULT_SECS).min(WAIT_MAX_SECS);
278 if own_process {
279 AgentRegistry::renew_worker_persistent(agent_id).map_err(|e| format!("Error: {e}"))?;
281 }
282 let arrived = AgentRegistry::wait_for_unread(
283 agent_id,
284 project_root,
285 std::time::Duration::from_secs(secs),
286 || {
287 if own_process && let Err(e) = AgentRegistry::renew_worker_persistent(agent_id) {
288 tracing::debug!("lean-ctx: wait keepalive for {agent_id}: {e}");
289 }
290 },
291 );
292 if arrived {
293 return read_messages_as(agent_id, project_root, own_process).map(Some);
294 }
295 lean_ctx::core::agents::friction::record_for(
296 lean_ctx::core::agents::friction::WAIT_TIMEOUT,
297 Some(agent_id),
298 Some(project_root),
299 );
300 Ok(None)
301}
302
303pub fn handle_wait(
306 current_agent_id: Option<&str>,
307 project_root: &str,
308 timeout_secs: Option<u64>,
309) -> Result<String, String> {
310 let Some(agent_id) = current_agent_id else {
311 return Err("Error: agent must be registered first (use action=register)".to_string());
312 };
313 let secs = timeout_secs.unwrap_or(WAIT_DEFAULT_SECS).min(WAIT_MAX_SECS);
314 Ok(
315 match wait_messages(agent_id, project_root, timeout_secs, true)? {
316 Some(text) => text,
317 None if lean_ctx::core::agents::take_renewed(agent_id) => {
318 format!("{}{}", renewed_line(agent_id), wait_timeout_line(secs))
319 }
320 None => wait_timeout_line(secs),
321 },
322 )
323}
324
325#[allow(clippy::too_many_arguments)]
329pub fn handle(
330 action: &str,
331 agent_type: Option<&str>,
332 role: Option<&str>,
333 durable_identity_id: Option<&str>,
334 project_root: &str,
335 current_agent_id: Option<&str>,
336 message: Option<&str>,
337 category: Option<&str>,
338 to_agent: Option<&str>,
339 status: Option<&str>,
340 privacy: Option<&str>,
341 priority: Option<&str>,
342 ttl_hours: Option<u64>,
343 format: Option<&str>,
344 write: bool,
345 filename: Option<&str>,
346 request_id: Option<&str>,
347) -> String {
348 let earlier = current_agent_id.filter(|_| action != "read").map(|agent| {
349 lean_ctx_hive::background::earlier(&lean_ctx_hive::bus::leases::caller(agent))
350 });
351 let answer = handle_action(
352 action,
353 agent_type,
354 role,
355 durable_identity_id,
356 project_root,
357 current_agent_id,
358 message,
359 category,
360 to_agent,
361 status,
362 privacy,
363 priority,
364 ttl_hours,
365 format,
366 write,
367 filename,
368 request_id,
369 );
370 match earlier {
371 Some(earlier) => earlier.present(answer),
372 None => answer,
373 }
374}
375
376#[allow(clippy::too_many_arguments)]
377fn handle_action(
378 action: &str,
379 agent_type: Option<&str>,
380 role: Option<&str>,
381 durable_identity_id: Option<&str>,
382 project_root: &str,
383 current_agent_id: Option<&str>,
384 message: Option<&str>,
385 category: Option<&str>,
386 to_agent: Option<&str>,
387 status: Option<&str>,
388 privacy: Option<&str>,
389 priority: Option<&str>,
390 _ttl_hours: Option<u64>,
391 format: Option<&str>,
392 write: bool,
393 filename: Option<&str>,
394 request_id: Option<&str>,
395) -> String {
396 let admitted_message = match message
401 .map(lean_ctx::core::context_admission::carrier::admit_for_store)
402 .transpose()
403 {
404 Ok(admitted) => admitted,
405 Err(withheld) => return format!("Error: {}", withheld.message("agent message")),
406 };
407 let message = admitted_message.as_deref();
408 match action {
409 "register" => {
410 let atype = agent_type.unwrap_or("unknown");
411 match AgentRegistry::mutate_locked(|registry| {
412 registry.cleanup_stale(presence_ttl_hours());
413 registry.register(atype, role, project_root, durable_identity_id)
414 })
415 .and_then(|(_, agent_id)| agent_id)
416 {
417 Ok(agent_id) => {
418 lean_ctx_hive::bus::presence::request_announce();
421 format!(
424 "Agent registered: {agent_id} (type: {atype}, role: {}){}",
425 role.unwrap_or("none"),
426 lean_ctx::core::binary_identity::replaced_note()
427 .map(|note| format!("\n{}", note.trim_end()))
428 .unwrap_or_default()
429 )
430 }
431 Err(e) => format!("Registration failed: {e}"),
432 }
433 }
434
435 "list" => {
436 let registry = match AgentRegistry::mutate_locked(|registry| {
437 registry.cleanup_stale(presence_ttl_hours());
438 }) {
439 Ok((registry, ())) => registry,
440 Err(e) => {
441 tracing::warn!("lean-ctx: failed to persist agent registry: {e}");
442 AgentRegistry::load_or_create()
443 }
444 };
445
446 let agents = registry.list_active(Some(project_root));
447 let sessions =
448 lean_ctx::core::providers::emspace::admitted_sessions_block(project_root)
449 .unwrap_or_default();
450 if agents.is_empty() {
451 return format!("No active agents for this project.{sessions}");
452 }
453
454 let store = lean_ctx_hive::HiveStore::open().ok();
457 let instance = store
458 .as_ref()
459 .and_then(|s| s.instance().ok().flatten())
460 .map(|record| record.instance_id);
461 let book = match (&store, &instance) {
462 (Some(store), Some(id)) => {
463 lean_ctx_hive::workers::attach::list(store, id).unwrap_or_default()
464 }
465 _ => Vec::new(),
466 };
467 let mut roster = lean_ctx_hive::workers::roster::render(
468 &agents,
469 instance.as_deref().map(|id| (id, book.as_slice())),
470 chrono::Utc::now(),
471 );
472 if let Some(store) = &store {
473 roster.push_str(&lean_ctx_hive::bus::presence::render_remote(
474 store.data_dir(),
475 chrono::Utc::now(),
476 ));
477 }
478 roster.push_str(&sessions);
479 roster
480 }
481
482 "post" => {
483 let Some(msg) = message else {
484 return "Error: message is required for post".to_string();
485 };
486 let cat = category.unwrap_or("status");
487 let from = current_agent_id.unwrap_or("anonymous");
488 let msg_privacy = privacy.map_or(PrivacyLevel::Team, PrivacyLevel::parse_str);
489 let msg_priority = priority.map_or(MessagePriority::Normal, MessagePriority::parse_str);
490 let ttl_hours = _ttl_hours.or(Some(scratchpad_default_ttl_hours()));
491 if msg_privacy == PrivacyLevel::Private && to_agent.is_none() {
492 return "Error: private messages require to_agent".to_string();
493 }
494 let request = AgentMessageRequest {
495 project_root: project_root.to_string(),
496 from_agent_id: from.to_string(),
497 to_agent_id: to_agent.map(str::to_string),
498 category: cat.to_string(),
499 message: msg.to_string(),
500 privacy: msg_privacy,
501 priority: msg_priority,
502 ttl_hours,
503 request_id: request_id.map(str::to_string),
504 };
505 match OclaRegistry::global().agent_gateway.route_message(&request) {
506 Ok(msg_id) => {
507 let target = to_agent.unwrap_or("all agents (broadcast)");
508 format!("Posted [{cat}] to {target}: {msg} (id: {msg_id})")
509 }
510 Err(e) => format!("Post failed: {e}"),
511 }
512 }
513
514 "read" => match read_messages(current_agent_id, project_root) {
517 Ok(text) | Err(text) => text,
518 },
519
520 "wait" => match handle_wait(current_agent_id, project_root, None) {
523 Ok(text) | Err(text) => text,
524 },
525
526 "send" => handle_message(
529 "send",
530 &MessageArgs::untyped(message, category, to_agent),
531 project_root,
532 current_agent_id,
533 ),
534 "reply" => handle_message(
535 "reply",
536 &MessageArgs::untyped(message, category, to_agent),
537 project_root,
538 current_agent_id,
539 ),
540 "inbox" => handle_message(
541 "inbox",
542 &MessageArgs::untyped(message, category, to_agent),
543 project_root,
544 current_agent_id,
545 ),
546
547 "status" => {
548 let Some(agent_id) = current_agent_id else {
549 return "Error: agent must be registered first".to_string();
550 };
551 let new_status = match status {
552 Some("active") => AgentStatus::Active,
553 Some("idle") => AgentStatus::Idle,
554 Some("finished") => AgentStatus::Finished,
555 Some(other) => {
556 return format!("Unknown status: {other}. Use: active, idle, finished");
557 }
558 None => return "Error: status value is required".to_string(),
559 };
560 let status_msg = message;
561
562 match AgentRegistry::mutate_locked(|registry| {
563 registry.set_status(agent_id, new_status.clone(), status_msg)
564 })
565 .and_then(|(_, result)| result)
566 {
567 Ok(()) => format!(
568 "Status updated: {} → {}{}",
569 agent_id,
570 new_status,
571 status_msg.map(|m| format!(" ({m})")).unwrap_or_default()
572 ),
573 Err(e) => format!("Status update rejected: {e}"),
574 }
575 }
576
577 "info" => {
578 let registry = AgentRegistry::mutate_locked(|registry| {
579 registry.cleanup_stale(presence_ttl_hours());
580 })
581 .map(|(registry, ())| registry)
582 .unwrap_or_else(|_| AgentRegistry::load_or_create());
583 let total = registry.agents.len();
584 let active = registry.list_active(None).len();
585 let messages = registry.scratchpad.len();
586 format!(
587 "Agent Registry: {total} total, {active} active, {messages} scratchpad entries\nLast updated: {}",
588 registry.updated_at.format("%Y-%m-%d %H:%M UTC")
589 )
590 }
591
592 "handoff" => {
593 let Some(from) = current_agent_id else {
594 return "Error: agent must be registered first".to_string();
595 };
596 let Some(target) = to_agent else {
597 return "Error: to_agent is required for handoff".to_string();
598 };
599 let summary = message.unwrap_or("(no summary provided)");
600 let scratchpad_ttl = scratchpad_default_ttl_hours();
601 if target.starts_with("wkr_") {
606 use lean_ctx_hive::bus::messages_v2::{Extras, send};
607 let extras = Extras {
608 to_worker: Some(target),
609 ..Extras::default()
610 };
611 return match send(project_root, from, Some("handoff"), summary, extras) {
612 Ok(id) => {
613 let waiting = format!("handoff to {target} offered through Hive");
614 let _ = AgentRegistry::mutate_locked(|registry| {
615 registry.set_status(from, AgentStatus::Idle, Some(&waiting))
616 });
617 format!(
618 "Handoff offered (hive): {from} → {target} message={id}; \
619 {target} answers it (accept or decline)\nSummary: {summary}"
620 )
621 }
622 Err(error) => format!("Handoff rejected: {error}"),
623 };
624 }
625
626 if let Err(error) = AgentRegistry::mutate_locked(|registry| {
627 registry.post_message_scoped(
631 Some(project_root),
632 from,
633 Some(target),
634 "handoff",
635 &format!("HANDOFF from {from}: {summary}"),
636 PrivacyLevel::Team,
637 MessagePriority::Normal,
638 Some(scratchpad_ttl),
639 )?;
640 registry.set_status(from, AgentStatus::Finished, Some("handed off"))
641 })
642 .and_then(|(_, result)| result)
643 {
644 return format!("Handoff rejected: {error}");
645 }
646
647 let handoff_message = format!("HANDOFF: {summary}");
649 let capsule_ref = global_capsule_store().register(handoff_message.as_bytes());
650 let mut envelope = AgentEnvelope {
651 schema_version: AGENT_ENVELOPE_SCHEMA_VERSION,
652 relay_id: String::new(),
653 context: OclaRequestContext::new(
654 format!("handoff:{capsule_ref}"),
655 project_root.to_string(),
656 from.to_string(),
657 capsule_ref.clone(),
658 None,
659 Some(format!("handoff:{from}:{target}")),
660 ),
661 from_agent_id: from.to_string(),
662 to_agent_id: target.to_string(),
663 capsule_ref,
664 budget_tokens: handoff_message.len().max(1) as u64,
665 };
666 if let Err(error) = envelope.assign_relay_id() {
667 tracing::debug!("OCLA relay setup failed (non-fatal): {error}");
668 } else if let Err(error) = OclaRegistry::global().agent_gateway.relay_agent(envelope) {
669 tracing::debug!("OCLA relay failed (non-fatal): {error}");
670 }
671
672 lean_ctx::core::scent_field::deposit_for(
675 project_root,
676 from,
677 lean_ctx::core::scent_field::ScentKind::Done,
678 summary,
679 1.0,
680 );
681
682 format!("Handoff complete: {from} → {target}\nSummary: {summary}")
683 }
684
685 "claim" => {
689 let Some(target) = message else {
690 return "Error: message (the claim target, e.g. a file path or task label) is required for claim".to_string();
691 };
692 let agent = current_agent_id.map_or_else(
693 || lean_ctx::core::scent_field::scent_agent_id().to_string(),
694 str::to_string,
695 );
696 let normalized = lean_ctx::core::pathutil::normalize_tool_path(target);
697 match lean_ctx::core::scent_field::claim(project_root, &agent, &normalized) {
698 Ok(()) => {
699 format!("Claimed: {normalized} (by {agent}, decays in ~10m unless re-claimed)")
700 }
701 Err(e) => format!("Claim REJECTED: {normalized} — {e}"),
702 }
703 }
704
705 "release" => {
707 let Some(target) = message else {
708 return "Error: message (the claim target) is required for release".to_string();
709 };
710 let agent = current_agent_id.map_or_else(
711 || lean_ctx::core::scent_field::scent_agent_id().to_string(),
712 str::to_string,
713 );
714 let normalized = lean_ctx::core::pathutil::normalize_tool_path(target);
715 lean_ctx::core::scent_field::release(project_root, &agent, &normalized);
716 format!("Released: {normalized}")
717 }
718
719 "brief" => {
723 let Some(task) = message else {
724 return "Error: message (the sub-agent task) is required for brief".to_string();
725 };
726 let budget = priority
727 .and_then(|p| p.parse::<usize>().ok())
728 .unwrap_or(2000);
729 let Some(knowledge) = lean_ctx::core::knowledge::ProjectKnowledge::load(project_root)
730 else {
731 return "No knowledge stored for this project yet — a briefing pack needs facts. Use ctx_knowledge(action=\"remember\") first.".to_string();
732 };
733 let pack =
735 lean_ctx::core::subagent_contract::build_briefing_pack(&knowledge, task, budget);
736 match lean_ctx::core::subagent_contract::serialize_pack(&pack) {
737 Ok(json) => json,
738 Err(e) => format!("Error: {e}"),
739 }
740 }
741
742 "return" => {
746 let Some(report) = message else {
747 return "Error: message (the sub-agent report) is required for return".to_string();
748 };
749 let (facts, rejected) = lean_ctx::core::subagent_contract::parse_return_lines(report);
750 if facts.is_empty() {
751 return format!(
752 "No contract-formatted lines found ({} rejected). Expected 'category/key: value' per line.",
753 rejected.len()
754 );
755 }
756
757 let session_label = current_agent_id.unwrap_or("subagent");
758 let policy = match crate::tools::knowledge_shared::load_policy_or_error() {
759 Ok(p) => p,
760 Err(e) => return e,
761 };
762 let count = facts.len();
763 let res = lean_ctx::core::knowledge::ProjectKnowledge::mutate_locked(
764 project_root,
765 |knowledge| {
766 for f in &facts {
767 knowledge.remember(
768 &f.category,
769 &f.key,
770 &f.value,
771 session_label,
772 0.8,
773 &policy,
774 );
775 }
776 },
777 );
778 match res {
779 Ok(_) => {
780 let mut out = format!(
781 "Return synthesis: {count} fact(s) distilled into parent knowledge"
782 );
783 if !rejected.is_empty() {
784 out.push_str(&format!(
785 "\n{} line(s) rejected (not 'category/key: value'):",
786 rejected.len()
787 ));
788 for r in rejected.iter().take(5) {
789 out.push_str(&format!("\n ✗ {r}"));
790 }
791 }
792 out
793 }
794 Err(e) => format!("Error: knowledge store update failed: {e}"),
795 }
796 }
797
798 "sync" => {
799 let registry = AgentRegistry::mutate_locked(|registry| {
800 registry.cleanup_stale(presence_ttl_hours());
801 })
802 .map(|(registry, ())| registry)
803 .unwrap_or_else(|_| AgentRegistry::load_or_create());
804 let pending_count = current_agent_id.map_or(0, |id| {
805 registry
806 .scratchpad
807 .iter()
808 .filter(|e| {
809 !e.read_by.contains(&id.to_string())
810 && e.from_agent != id
811 && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(id))
812 && e.project_root.as_deref().is_some_and(|own| {
813 lean_ctx::core::pathutil::same_project(own, project_root)
814 })
815 })
816 .count()
817 });
818 let agents = registry.list_active(Some(project_root));
819
820 if agents.is_empty() {
821 return "No active agents to sync with.".to_string();
822 }
823
824 let shared_dir = lean_ctx::core::data_dir::lean_ctx_data_dir()
826 .unwrap_or_default()
827 .join("agents")
828 .join("shared")
829 .join(lean_ctx::core::project_hash::hash_project_root(
830 project_root,
831 ));
832
833 let shared_count = if shared_dir.exists() {
834 std::fs::read_dir(&shared_dir).map_or(0, std::iter::Iterator::count)
835 } else {
836 0
837 };
838
839 let mut out = "Multi-Agent Sync Status:\n".to_string();
840 out.push_str(&format!(" Active agents: {}\n", agents.len()));
841 for a in &agents {
842 let role = a.role.as_deref().unwrap_or("-");
843 let age = (chrono::Utc::now() - a.last_active).num_minutes();
844 out.push_str(&format!(
845 " {} [{}] role={} ({}m ago)\n",
846 a.agent_id, a.agent_type, role, age
847 ));
848 }
849 out.push_str(&format!(" Pending messages: {pending_count}\n"));
850 out.push_str(&format!(" Shared contexts: {shared_count}\n"));
851
852 let scents = lean_ctx::core::scent_field::sync_block(project_root);
855 if !scents.is_empty() {
856 out.push_str(&scents);
857 }
858 out
859 }
860
861 "control_status" => control_status(project_root, current_agent_id),
866
867 "export" => {
868 let Some(agent_id) = current_agent_id else {
869 return "Error: agent must be registered first (use action=register)".to_string();
870 };
871
872 fn privacy_label(p: &PrivacyLevel) -> &'static str {
873 match p {
874 PrivacyLevel::Public => "public",
875 PrivacyLevel::Team => "team",
876 PrivacyLevel::Private => "private",
877 }
878 }
879
880 fn priority_label(p: &MessagePriority) -> &'static str {
881 match p {
882 MessagePriority::Low => "low",
883 MessagePriority::Normal => "normal",
884 MessagePriority::High => "high",
885 MessagePriority::Critical => "critical",
886 }
887 }
888
889 fn maybe_redact(s: &str, should_redact: bool) -> String {
890 if should_redact {
891 lean_ctx::core::redaction::redact_text(s)
892 } else {
893 s.to_string()
894 }
895 }
896
897 #[derive(serde::Serialize)]
898 struct ExportAgentV1 {
899 agent_id: String,
900 agent_type: String,
901 role: Option<String>,
902 status: String,
903 status_message: Option<String>,
904 started_at: String,
905 last_active: String,
906 pid: u32,
907 }
908
909 #[derive(serde::Serialize)]
910 struct ExportMessageV1 {
911 id: String,
912 from_agent: String,
913 to_agent: Option<String>,
914 category: String,
915 privacy: String,
916 priority: String,
917 message: String,
918 metadata: std::collections::BTreeMap<String, String>,
919 timestamp: String,
920 expires_at: Option<String>,
921 read_by_count: usize,
922 }
923
924 #[derive(serde::Serialize)]
925 struct ExportTaskV1 {
926 id: String,
927 from_agent: String,
928 to_agent: String,
929 state: String,
930 description: String,
931 created_at: String,
932 updated_at: String,
933 messages: usize,
934 artifacts: usize,
935 transitions: usize,
936 }
937
938 #[derive(serde::Serialize)]
939 struct ExportDiaryEntryV1 {
940 entry_type: String,
941 content: String,
942 context: Option<String>,
943 timestamp: String,
944 }
945
946 #[derive(serde::Serialize)]
947 struct ExportDiaryV1 {
948 agent_id: String,
949 agent_type: String,
950 project_root: String,
951 updated_at: String,
952 entries: Vec<ExportDiaryEntryV1>,
953 }
954
955 #[derive(serde::Serialize)]
956 struct A2ASnapshotV1 {
957 schema_version: u32,
958 created_at: String,
959 project_root: String,
960 agent_id: String,
961 agents: Vec<ExportAgentV1>,
962 messages: Vec<ExportMessageV1>,
963 tasks: Vec<ExportTaskV1>,
964 diary: Option<ExportDiaryV1>,
965 }
966
967 let privacy_mode = privacy.unwrap_or("redacted");
968 let allow_full = privacy_mode == "full"
969 && !lean_ctx::core::redaction::redaction_enabled_for_active_role();
970 let should_redact = !allow_full;
971
972 let now = chrono::Utc::now();
973 let mut registry = AgentRegistry::load_or_create();
974 registry.cleanup_stale(presence_ttl_hours());
975
976 let mut agents: Vec<ExportAgentV1> = registry
977 .list_active(Some(project_root))
978 .into_iter()
979 .map(|a| ExportAgentV1 {
980 agent_id: a.agent_id.clone(),
981 agent_type: a.agent_type.clone(),
982 role: a.role.clone(),
983 status: a.status.to_string(),
984 status_message: a.status_message.clone(),
985 started_at: a.started_at.to_rfc3339(),
986 last_active: a.last_active.to_rfc3339(),
987 pid: a.pid,
988 })
989 .collect();
990 agents.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
991
992 let mut messages: Vec<ExportMessageV1> = registry
993 .scratchpad
994 .iter()
995 .filter(|e| {
996 e.project_root.as_deref().is_some_and(|own| {
997 lean_ctx::core::pathutil::same_project(own, project_root)
998 }) && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
999 })
1000 .take(200)
1001 .map(|m| ExportMessageV1 {
1002 id: m.id.clone(),
1003 from_agent: m.from_agent.clone(),
1004 to_agent: m.to_agent.clone(),
1005 category: m.category.clone(),
1006 privacy: privacy_label(&m.privacy).to_string(),
1007 priority: priority_label(&m.priority).to_string(),
1008 message: maybe_redact(&m.served_message(Some(project_root)), should_redact),
1009 metadata: m
1011 .metadata
1012 .iter()
1013 .filter(|(k, _)| k.as_str() != lean_ctx::core::agents::BINDING_METADATA_KEY)
1014 .map(|(k, v)| (k.clone(), maybe_redact(v, should_redact)))
1015 .collect(),
1016 timestamp: m.timestamp.to_rfc3339(),
1017 expires_at: m.expires_at.map(|t| t.to_rfc3339()),
1018 read_by_count: m.read_by.len(),
1019 })
1020 .collect();
1021 messages.sort_by(|a, b| a.timestamp.cmp(&b.timestamp).then_with(|| a.id.cmp(&b.id)));
1022
1023 const EXPORT_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
1028 let tasks = TaskStore::default_path().and_then(|path| {
1029 TaskStore::mutate_locked_within(&path, EXPORT_LOCK_WAIT, |task_store| {
1030 task_store.cleanup_old(72);
1031 Ok::<_, std::io::Error>(
1032 task_store
1033 .tasks_for_agent(agent_id)
1034 .into_iter()
1035 .take(200)
1036 .map(|t| ExportTaskV1 {
1037 id: t.id.clone(),
1038 from_agent: t.from_agent.clone(),
1039 to_agent: t.to_agent.clone(),
1040 state: t.state.to_string(),
1041 description: maybe_redact(&t.served_description(), should_redact),
1042 created_at: t.created_at.to_rfc3339(),
1043 updated_at: t.updated_at.to_rfc3339(),
1044 messages: t.messages.len(),
1045 artifacts: t.artifacts.len(),
1046 transitions: t.history.len(),
1047 })
1048 .collect::<Vec<ExportTaskV1>>(),
1049 )
1050 })
1051 });
1052 let mut tasks = match tasks {
1053 Ok(tasks) => tasks,
1054 Err(error) => {
1055 tracing::warn!("lean-ctx: failed to read task store: {error}");
1056 return "Error: task storage unavailable".to_string();
1057 }
1058 };
1059 tasks.sort_by(|a, b| {
1060 b.updated_at
1061 .cmp(&a.updated_at)
1062 .then_with(|| a.id.cmp(&b.id))
1063 });
1064
1065 let diary =
1066 AgentDiary::load_for_project(agent_id, project_root).map(|d| ExportDiaryV1 {
1067 agent_id: d.agent_id,
1068 agent_type: d.agent_type,
1069 project_root: d.project_root,
1070 updated_at: d.updated_at.to_rfc3339(),
1071 entries: d
1072 .entries
1073 .iter()
1074 .rev()
1075 .take(25)
1076 .rev()
1077 .map(|e| ExportDiaryEntryV1 {
1078 entry_type: e.entry_type.to_string(),
1079 content: maybe_redact(&e.content, should_redact),
1080 context: e.context.as_deref().map(|c| maybe_redact(c, should_redact)),
1081 timestamp: e.timestamp.to_rfc3339(),
1082 })
1083 .collect(),
1084 });
1085
1086 let payload = A2ASnapshotV1 {
1087 schema_version: lean_ctx::core::contracts::A2A_SNAPSHOT_V1_SCHEMA_VERSION,
1088 created_at: now.to_rfc3339(),
1089 project_root: project_root.to_string(),
1090 agent_id: agent_id.to_string(),
1091 agents,
1092 messages,
1093 tasks,
1094 diary,
1095 };
1096
1097 let json = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string());
1098
1099 if write {
1100 let proofs_dir = std::path::Path::new(project_root)
1101 .join(".lean-ctx")
1102 .join("proofs");
1103 if let Err(e) = std::fs::create_dir_all(&proofs_dir) {
1104 return format!("Error: create proofs dir: {e}");
1105 }
1106
1107 let name = if let Some(f) = filename {
1108 let p = std::path::Path::new(f);
1109 if p.components().count() != 1 {
1110 return "Error: filename must be a plain file name (no directories)"
1111 .to_string();
1112 }
1113 f.to_string()
1114 } else {
1115 format!("a2a-snapshot-v1_{}.json", now.format("%Y%m%d_%H%M%S"))
1116 };
1117
1118 let out_path = proofs_dir.join(name);
1119 if let Err(e) = std::fs::write(&out_path, &json) {
1120 return format!("Error: write snapshot: {e}");
1121 }
1122
1123 return format!(
1124 "A2A snapshot exported: {}\n agents: {}\n messages: {}\n tasks: {}",
1125 out_path.display(),
1126 payload.agents.len(),
1127 payload.messages.len(),
1128 payload.tasks.len()
1129 );
1130 }
1131
1132 match format.unwrap_or("json") {
1133 "text" => format!(
1134 "A2A snapshot (v1)\n agents: {}\n messages: {}\n tasks: {}",
1135 payload.agents.len(),
1136 payload.messages.len(),
1137 payload.tasks.len()
1138 ),
1139 _ => json,
1140 }
1141 }
1142
1143 "diary" => {
1144 let Some(agent_id) = current_agent_id else {
1145 return "Error: agent must be registered first".to_string();
1146 };
1147 let Some(content) = message else {
1148 return "Error: message is required for diary entry".to_string();
1149 };
1150 let entry_type = match category.unwrap_or("progress") {
1151 "discovery" | "found" => DiaryEntryType::Discovery,
1152 "decision" | "decided" => DiaryEntryType::Decision,
1153 "blocker" | "blocked" => DiaryEntryType::Blocker,
1154 "progress" | "done" => DiaryEntryType::Progress,
1155 "insight" => DiaryEntryType::Insight,
1156 other => {
1157 return format!(
1158 "Unknown diary type: {other}. Use: discovery, decision, blocker, progress, insight"
1159 );
1160 }
1161 };
1162 let atype = agent_type.unwrap_or("unknown");
1163 let mut diary = AgentDiary::load_or_create(agent_id, atype, project_root);
1164 let context_str = to_agent;
1165 diary.add_entry(entry_type.clone(), content, context_str);
1166 match diary.save() {
1167 Ok(()) => format!("Diary entry [{entry_type}] added: {content}"),
1168 Err(e) => format!("Diary entry added but save failed: {e}"),
1169 }
1170 }
1171
1172 "recall_diary" | "diary_recall" => {
1173 let Some(agent_id) = current_agent_id else {
1174 let diaries = AgentDiary::list_for_project(project_root);
1175 if diaries.is_empty() {
1176 return "No agent diaries found.".to_string();
1177 }
1178 let mut out = format!("Agent Diaries ({}):\n", diaries.len());
1179 for (id, count, updated) in &diaries {
1180 let age = (chrono::Utc::now() - *updated).num_minutes();
1181 out.push_str(&format!(" {id}: {count} entries ({age}m ago)\n"));
1182 }
1183 return out;
1184 };
1185 match AgentDiary::load_for_project(agent_id, project_root) {
1186 Some(diary) => diary.format_summary(),
1187 None => format!("No diary found for agent '{agent_id}'."),
1188 }
1189 }
1190
1191 "diaries" => {
1192 let diaries = AgentDiary::list_for_project(project_root);
1193 if diaries.is_empty() {
1194 return "No agent diaries found.".to_string();
1195 }
1196 let mut out = format!("Agent Diaries ({}):\n", diaries.len());
1197 for (id, count, updated) in &diaries {
1198 let age = (chrono::Utc::now() - *updated).num_minutes();
1199 out.push_str(&format!(" {id}: {count} entries ({age}m ago)\n"));
1200 }
1201 out
1202 }
1203
1204 "share_knowledge" => {
1205 let cat = category.unwrap_or("general");
1206 let Some(msg_text) = message else {
1207 return "Error: message required (format: key1=value1;key2=value2)".to_string();
1208 };
1209 let facts: Vec<(String, String)> = msg_text
1210 .split(';')
1211 .filter_map(|kv| {
1212 let (k, v) = kv.split_once('=')?;
1213 Some((k.trim().to_string(), v.trim().to_string()))
1214 })
1215 .collect();
1216 if facts.is_empty() {
1217 return "Error: no valid key=value pairs found".to_string();
1218 }
1219 let Some(from) = current_agent_id else {
1220 return "Error: agent must be registered first".to_string();
1221 };
1222 match AgentRegistry::mutate_locked(|registry| {
1224 registry.require_active_for_presence(from, "sharing knowledge")?;
1225 registry.share_knowledge(project_root, from, cat, &facts)
1226 })
1227 .and_then(|(_, shared)| shared)
1228 {
1229 Ok(count) => format!("Shared {count} facts in category '{cat}' with this project"),
1230 Err(e) => format!("Share failed: {e}"),
1231 }
1232 }
1233
1234 "receive_knowledge" => {
1235 let Some(agent_id) = current_agent_id else {
1236 return "Error: agent must be registered first".to_string();
1237 };
1238 let facts = match AgentRegistry::mutate_locked(|registry| {
1239 registry.require_active_for_presence(agent_id, "receiving knowledge")?;
1240 registry.receive_shared_knowledge(project_root, agent_id)
1241 })
1242 .and_then(|(_, facts)| facts)
1243 {
1244 Ok(facts) => facts,
1245 Err(e) => return format!("Receive failed: {e}"),
1246 };
1247 if facts.is_empty() {
1248 return "No new shared knowledge.".to_string();
1249 }
1250 let mut out = format!("Received {} facts:\n", facts.len());
1251 for f in &facts {
1252 let age = (chrono::Utc::now() - f.timestamp).num_minutes();
1253 out.push_str(&format!(
1254 " [{}] {} (from {}, {}m ago)\n",
1255 f.category, f.text, f.from_agent, age
1256 ));
1257 }
1258 out
1259 }
1260
1261 "poll_events" => {
1262 let Some(agent_id) = current_agent_id else {
1263 return "Error: agent must be registered first".to_string();
1264 };
1265 let workspace_id = to_agent.unwrap_or(project_root);
1266 let channel_id = category.unwrap_or("default");
1267 let since_id: i64 = message.and_then(|s| s.parse().ok()).unwrap_or(0);
1268 let limit: usize = _ttl_hours.unwrap_or(50) as usize;
1269
1270 let rt = lean_ctx::core::context_os::runtime();
1271 let events = rt.bus.read(workspace_id, channel_id, since_id, limit);
1272
1273 let filter = lean_ctx::core::context_os::TopicFilter {
1274 agent_id: Some(agent_id.to_string()),
1275 kinds: privacy.and_then(|s| {
1276 let kinds: Vec<_> = s
1277 .split(',')
1278 .map(|k| lean_ctx::core::context_os::ContextEventKindV1::parse(k.trim()))
1279 .collect();
1280 if kinds.is_empty() { None } else { Some(kinds) }
1281 }),
1282 ..Default::default()
1283 };
1284
1285 let filtered: Vec<_> = events.into_iter().filter(|e| filter.matches(e)).collect();
1286 if filtered.is_empty() {
1287 return format!("No new events since id={since_id} for {agent_id}.");
1288 }
1289
1290 let mut out = format!("Events ({}, since={since_id}):\n", filtered.len());
1291 for ev in &filtered {
1292 let actor = ev.actor.as_deref().unwrap_or("-");
1293 out.push_str(&format!(
1294 " #{} [{}] actor={} cl={} ({})\n",
1295 ev.id,
1296 ev.kind,
1297 actor,
1298 ev.consistency_level,
1299 ev.timestamp.format("%H:%M:%S")
1300 ));
1301 }
1302 if let Some(last) = filtered.last() {
1303 out.push_str(&format!("cursor={}", last.id));
1304 }
1305 out
1306 }
1307
1308 "lease_acquire" => lease::acquire(project_root, current_agent_id, message, _ttl_hours),
1310 "lease_release" => lease::release(project_root, current_agent_id, message, category),
1311 "lease_renew" => lease::renew(
1312 project_root,
1313 current_agent_id,
1314 message,
1315 category,
1316 _ttl_hours,
1317 ),
1318
1319 _ => format!("Unknown action: {action}. Use: {}", ACTIONS.join(", ")),
1320 }
1321}
1322
1323#[cfg(test)]
1324#[path = "ctx_agent_tests.rs"]
1325mod tests;
1326