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 lean_ctx::core::context_admission::capture::note_origin(
189 &lean_ctx::core::context_admission::origin::SourceClass::External(
190 lean_ctx::core::context_admission::origin::InputOrigin::new(
191 lean_ctx::core::context_admission::origin::InputKind::BusMessage,
192 format!("hive:{}", m.id),
193 ),
194 ),
195 );
196 return format!(
197 " [hive] ({age}m ago):\n{}\n",
198 m.served_message(Some(project_root))
199 );
200 }
201 format!(
202 " [{}] from {} ({}m ago): {}\n",
203 m.category,
204 m.from_agent,
205 age,
206 m.served_message(Some(project_root))
207 )
208}
209
210pub fn read_messages_as(
216 agent_id: &str,
217 project_root: &str,
218 own_process: bool,
219) -> Result<String, String> {
220 let (shown, left) = match AgentRegistry::mutate_locked(|registry| {
221 if own_process {
222 registry.renew_worker(agent_id)?;
223 }
224 registry.read_unread_page(agent_id, project_root, READ_PAGE_BYTES, |m| {
225 render_message(m, project_root)
226 })
227 }) {
228 Ok((_, Ok(page))) => page,
229 Ok((_, Err(error))) => {
230 tracing::warn!("lean-ctx: message read rejected: {error}");
231 return Err(format!("Error: {error}"));
232 }
233 Err(e) => {
234 tracing::warn!(
235 "lean-ctx: failed to persist agent registry (messages may reappear): {e}"
236 );
237 return Err("Error: failed to read messages".to_string());
238 }
239 };
240
241 let mut renewed = if lean_ctx::core::agents::take_renewed(agent_id) {
242 renewed_line(agent_id)
243 } else {
244 String::new()
245 };
246 if own_process && let Some(note) = lean_ctx::core::binary_identity::replaced_note() {
248 renewed.insert_str(0, ¬e);
249 }
250 if shown.is_empty() {
251 return Ok(format!("{renewed}No new messages."));
252 }
253
254 let mut out = format!("{renewed}New messages ({}):\n", shown.len());
255 for line in &shown {
256 out.push_str(line);
257 }
258 if left > 0 {
259 out.push_str(&format!(
260 "{left} more unread (still unread): read again for the next ones.\n"
261 ));
262 }
263 Ok(out)
264}
265
266pub const WAIT_DEFAULT_SECS: u64 = 600;
268pub const WAIT_MAX_SECS: u64 = 1800;
269
270pub fn wait_timeout_line(secs: u64) -> String {
272 format!("No new messages within {secs}s.")
273}
274
275pub fn wait_messages(
281 agent_id: &str,
282 project_root: &str,
283 timeout_secs: Option<u64>,
284 own_process: bool,
285) -> Result<Option<String>, String> {
286 let secs = timeout_secs.unwrap_or(WAIT_DEFAULT_SECS).min(WAIT_MAX_SECS);
287 if own_process {
288 AgentRegistry::renew_worker_persistent(agent_id).map_err(|e| format!("Error: {e}"))?;
290 }
291 let arrived = AgentRegistry::wait_for_unread(
292 agent_id,
293 project_root,
294 std::time::Duration::from_secs(secs),
295 || {
296 if own_process && let Err(e) = AgentRegistry::renew_worker_persistent(agent_id) {
297 tracing::debug!("lean-ctx: wait keepalive for {agent_id}: {e}");
298 }
299 },
300 );
301 if arrived {
302 return read_messages_as(agent_id, project_root, own_process).map(Some);
303 }
304 lean_ctx::core::agents::friction::record_for(
305 lean_ctx::core::agents::friction::WAIT_TIMEOUT,
306 Some(agent_id),
307 Some(project_root),
308 );
309 Ok(None)
310}
311
312pub fn handle_wait(
315 current_agent_id: Option<&str>,
316 project_root: &str,
317 timeout_secs: Option<u64>,
318) -> Result<String, String> {
319 let Some(agent_id) = current_agent_id else {
320 return Err("Error: agent must be registered first (use action=register)".to_string());
321 };
322 let secs = timeout_secs.unwrap_or(WAIT_DEFAULT_SECS).min(WAIT_MAX_SECS);
323 Ok(
324 match wait_messages(agent_id, project_root, timeout_secs, true)? {
325 Some(text) => text,
326 None if lean_ctx::core::agents::take_renewed(agent_id) => {
327 format!("{}{}", renewed_line(agent_id), wait_timeout_line(secs))
328 }
329 None => wait_timeout_line(secs),
330 },
331 )
332}
333
334pub fn role_and_name(
340 role: Option<&str>,
341 name: Option<&str>,
342) -> (Option<String>, Option<String>, Vec<String>) {
343 let mut warnings = Vec::new();
344 let mut display = None;
345 if let Some(raw) = name {
346 display = lean_ctx::core::activity::session_name(raw);
347 if display.is_none() {
348 warnings.push("warning: name ignored (1-128 characters, one line)".to_owned());
349 }
350 }
351 let role_id = role.and_then(lean_ctx::core::agents::role_id);
352 if let (Some(raw), None) = (role, &role_id) {
353 warnings.push(format!(
354 "warning: unknown_role: {:?} is not a role id (coder, reviewer, tester, …); \
355 pass a display name as name=",
356 raw.chars().take(64).collect::<String>()
357 ));
358 if display.is_none() {
359 display = lean_ctx::core::activity::session_name(raw);
360 }
361 }
362 (role_id, display, warnings)
363}
364
365pub fn register(
369 agent_type: Option<&str>,
370 role: Option<&str>,
371 name: Option<&str>,
372 durable_identity_id: Option<&str>,
373 project_root: &str,
374) -> String {
375 let atype = agent_type.unwrap_or("unknown");
376 let (role, name, warnings) = role_and_name(role, name);
377 let stored = AgentRegistry::mutate_locked(|registry| {
378 registry.cleanup_stale(presence_ttl_hours());
379 let agent_id =
380 registry.register(atype, role.as_deref(), project_root, durable_identity_id)?;
381 if let Some(name) = &name {
382 registry.set_display_name(&agent_id, name);
383 }
384 let entry = registry.agents.iter().find(|a| a.agent_id == agent_id);
385 Ok((
386 agent_id.clone(),
387 entry.and_then(|a| a.role.clone()),
388 entry.and_then(|a| a.display_name.clone()),
389 ))
390 })
391 .and_then(|(_, stored)| stored);
392 match stored {
393 Ok((agent_id, role, name)) => {
394 lean_ctx_hive::bus::presence::request_announce();
397 let mut text = format!(
400 "Agent registered: {agent_id} (type: {atype}, role: {}, name: {})",
401 role.as_deref().unwrap_or("none"),
402 name.as_deref().unwrap_or("none"),
403 );
404 for warning in warnings {
405 text.push('\n');
406 text.push_str(&warning);
407 }
408 if let Some(note) = lean_ctx::core::binary_identity::replaced_note() {
409 text.push('\n');
410 text.push_str(note.trim_end());
411 }
412 text
413 }
414 Err(e) => format!("Registration failed: {e}"),
415 }
416}
417
418#[derive(Debug, Clone, Copy, Default)]
422pub struct PostFields<'a> {
423 pub kind: Option<&'a str>,
424 pub task_id: Option<&'a str>,
425 pub thread: Option<&'a str>,
426}
427
428#[allow(clippy::too_many_arguments)]
432pub fn handle(
433 action: &str,
434 agent_type: Option<&str>,
435 role: Option<&str>,
436 durable_identity_id: Option<&str>,
437 project_root: &str,
438 current_agent_id: Option<&str>,
439 message: Option<&str>,
440 category: Option<&str>,
441 to_agent: Option<&str>,
442 status: Option<&str>,
443 privacy: Option<&str>,
444 priority: Option<&str>,
445 ttl_hours: Option<u64>,
446 format: Option<&str>,
447 write: bool,
448 filename: Option<&str>,
449 request_id: Option<&str>,
450) -> String {
451 handle_with(
452 action,
453 agent_type,
454 role,
455 durable_identity_id,
456 project_root,
457 current_agent_id,
458 message,
459 category,
460 to_agent,
461 status,
462 privacy,
463 priority,
464 ttl_hours,
465 format,
466 write,
467 filename,
468 request_id,
469 PostFields::default(),
470 )
471}
472
473fn checked_post_fields(
480 registry: &AgentRegistry,
481 sender: &str,
482 post: PostFields<'_>,
483 priority: &MessagePriority,
484) -> Result<(Option<String>, std::collections::BTreeMap<String, String>), String> {
485 use lean_ctx_hive::bus::queen_kinds::{self, QueenKind, SenderRefusal};
486 let kind = QueenKind::from_input(post.kind)?;
487 if let Some(task_id) = post.task_id {
488 queen_kinds::check_task_id(task_id)?;
489 }
490 if let Some(thread) = post.thread {
491 queen_kinds::check_thread(thread)?;
492 }
493 if let Some(kind) = kind
494 && matches!(priority, MessagePriority::High | MessagePriority::Critical)
495 && !kind.allows_high()
496 {
497 return Err(format!(
498 "priority high is only for kinds conflict, escalate and stop, not {}",
499 kind.as_str()
500 ));
501 }
502 let now = chrono::Utc::now().timestamp_millis();
505 queen_kinds::check_sender(registry, sender, kind, now).map_err(|refused| match refused {
506 SenderRefusal::NotQueen(why) | SenderRefusal::RateLimited(why) => why,
507 })?;
508 let mut metadata = std::collections::BTreeMap::new();
509 if let Some(kind) = kind {
510 metadata.insert(queen_kinds::KIND_KEY.to_owned(), kind.as_str().to_owned());
511 }
512 if let Some(thread) = post.thread {
513 metadata.insert(queen_kinds::THREAD_KEY.to_owned(), thread.to_owned());
514 }
515 Ok((post.task_id.map(str::to_owned), metadata))
516}
517
518#[allow(clippy::too_many_arguments)]
520pub fn handle_with(
521 action: &str,
522 agent_type: Option<&str>,
523 role: Option<&str>,
524 durable_identity_id: Option<&str>,
525 project_root: &str,
526 current_agent_id: Option<&str>,
527 message: Option<&str>,
528 category: Option<&str>,
529 to_agent: Option<&str>,
530 status: Option<&str>,
531 privacy: Option<&str>,
532 priority: Option<&str>,
533 ttl_hours: Option<u64>,
534 format: Option<&str>,
535 write: bool,
536 filename: Option<&str>,
537 request_id: Option<&str>,
538 post: PostFields<'_>,
539) -> String {
540 let earlier = current_agent_id.filter(|_| action != "read").map(|agent| {
541 lean_ctx_hive::background::earlier(&lean_ctx_hive::bus::leases::caller(agent))
542 });
543 let answer = handle_action(
544 action,
545 agent_type,
546 role,
547 durable_identity_id,
548 project_root,
549 current_agent_id,
550 message,
551 category,
552 to_agent,
553 status,
554 privacy,
555 priority,
556 ttl_hours,
557 format,
558 write,
559 filename,
560 request_id,
561 post,
562 );
563 match earlier {
564 Some(earlier) => earlier.present(answer),
565 None => answer,
566 }
567}
568
569#[allow(clippy::too_many_arguments)]
570fn handle_action(
571 action: &str,
572 agent_type: Option<&str>,
573 role: Option<&str>,
574 durable_identity_id: Option<&str>,
575 project_root: &str,
576 current_agent_id: Option<&str>,
577 message: Option<&str>,
578 category: Option<&str>,
579 to_agent: Option<&str>,
580 status: Option<&str>,
581 privacy: Option<&str>,
582 priority: Option<&str>,
583 _ttl_hours: Option<u64>,
584 format: Option<&str>,
585 write: bool,
586 filename: Option<&str>,
587 request_id: Option<&str>,
588 post: PostFields<'_>,
589) -> String {
590 let admitted_message = match message
595 .map(lean_ctx::core::context_admission::carrier::admit_for_store)
596 .transpose()
597 {
598 Ok(admitted) => admitted,
599 Err(withheld) => return format!("Error: {}", withheld.message("agent message")),
600 };
601 let message = admitted_message.as_deref();
602 match action {
603 "register" => register(agent_type, role, None, durable_identity_id, project_root),
604
605 "list" => {
606 let registry = match AgentRegistry::mutate_locked(|registry| {
607 registry.cleanup_stale(presence_ttl_hours());
608 }) {
609 Ok((registry, ())) => registry,
610 Err(e) => {
611 tracing::warn!("lean-ctx: failed to persist agent registry: {e}");
612 AgentRegistry::load_or_create()
613 }
614 };
615
616 let agents = registry.list_active(Some(project_root));
617 let sessions =
618 lean_ctx::core::providers::emspace::admitted_sessions_block(project_root)
619 .unwrap_or_default();
620 if agents.is_empty() {
621 return format!("No active agents for this project.{sessions}");
622 }
623
624 let store = lean_ctx_hive::HiveStore::open().ok();
627 let instance = store
628 .as_ref()
629 .and_then(|s| s.instance().ok().flatten())
630 .map(|record| record.instance_id);
631 let book = match (&store, &instance) {
632 (Some(store), Some(id)) => {
633 lean_ctx_hive::workers::attach::list(store, id).unwrap_or_default()
634 }
635 _ => Vec::new(),
636 };
637 let mut roster = lean_ctx_hive::workers::roster::render(
638 &agents,
639 instance.as_deref().map(|id| (id, book.as_slice())),
640 chrono::Utc::now(),
641 );
642 if let Some(store) = &store {
643 roster.push_str(&lean_ctx_hive::bus::presence::render_remote(
644 store.data_dir(),
645 chrono::Utc::now(),
646 ));
647 }
648 roster.push_str(&sessions);
649 roster
650 }
651
652 "post" => {
653 let Some(msg) = message else {
654 return "Error: message is required for post".to_string();
655 };
656 let cat = category.unwrap_or("status");
657 let from = current_agent_id.unwrap_or("anonymous");
658 let msg_privacy = privacy.map_or(PrivacyLevel::Team, PrivacyLevel::parse_str);
659 let msg_priority = priority.map_or(MessagePriority::Normal, MessagePriority::parse_str);
660 let ttl_hours = _ttl_hours.or(Some(scratchpad_default_ttl_hours()));
661 if msg_privacy == PrivacyLevel::Private && to_agent.is_none() {
662 return "Error: private messages require to_agent".to_string();
663 }
664 let registry = AgentRegistry::load_or_create();
665 let to_agent = match to_agent
668 .map(|t| lean_ctx_hive::bus::queen_kinds::resolve_to(®istry, t))
669 .transpose()
670 {
671 Ok(to) => to,
672 Err(refused) => return format!("Error: not posted: {refused}"),
673 };
674 let to_agent = to_agent.as_deref();
675 let (task_id, metadata) =
676 match checked_post_fields(®istry, from, post, &msg_priority) {
677 Ok(fields) => fields,
678 Err(refused) => return format!("Error: not posted: {refused}"),
679 };
680 let msg = {
684 match lean_ctx_hive::bus::person_rules::check_local_post(
687 ®istry,
688 current_agent_id,
689 cat,
690 to_agent,
691 msg,
692 ) {
693 Ok(text) => text,
694 Err(refused) => return format!("Error: not posted: {refused}"),
695 }
696 };
697 let msg = msg.as_str();
698 let request = AgentMessageRequest {
699 project_root: project_root.to_string(),
700 from_agent_id: from.to_string(),
701 to_agent_id: to_agent.map(str::to_string),
702 category: cat.to_string(),
703 message: msg.to_string(),
704 privacy: msg_privacy,
705 priority: msg_priority,
706 ttl_hours,
707 request_id: request_id.map(str::to_string),
708 task_id,
709 metadata,
710 };
711 match OclaRegistry::global().agent_gateway.route_message(&request) {
712 Ok(msg_id) => {
713 let target = to_agent.unwrap_or("all agents (broadcast)");
714 format!("Posted [{cat}] to {target}: {msg} (id: {msg_id})")
715 }
716 Err(e) => format!("Post failed: {e}"),
717 }
718 }
719
720 "read" => match read_messages(current_agent_id, project_root) {
723 Ok(text) | Err(text) => text,
724 },
725
726 "wait" => match handle_wait(current_agent_id, project_root, None) {
729 Ok(text) | Err(text) => text,
730 },
731
732 "send" => handle_message(
735 "send",
736 &MessageArgs::untyped(message, category, to_agent),
737 project_root,
738 current_agent_id,
739 ),
740 "reply" => handle_message(
741 "reply",
742 &MessageArgs::untyped(message, category, to_agent),
743 project_root,
744 current_agent_id,
745 ),
746 "inbox" => handle_message(
747 "inbox",
748 &MessageArgs::untyped(message, category, to_agent),
749 project_root,
750 current_agent_id,
751 ),
752
753 "status" => {
754 let Some(agent_id) = current_agent_id else {
755 return "Error: agent must be registered first".to_string();
756 };
757 let new_status = match status {
758 Some("active") => AgentStatus::Active,
759 Some("idle") => AgentStatus::Idle,
760 Some("finished") => AgentStatus::Finished,
761 Some(other) => {
762 return format!("Unknown status: {other}. Use: active, idle, finished");
763 }
764 None => return "Error: status value is required".to_string(),
765 };
766 let status_msg = message;
767
768 match AgentRegistry::mutate_locked(|registry| {
769 registry.set_status(agent_id, new_status.clone(), status_msg)
770 })
771 .and_then(|(_, result)| result)
772 {
773 Ok(()) => format!(
774 "Status updated: {} → {}{}",
775 agent_id,
776 new_status,
777 status_msg.map(|m| format!(" ({m})")).unwrap_or_default()
778 ),
779 Err(e) => format!("Status update rejected: {e}"),
780 }
781 }
782
783 "info" => {
784 let registry = AgentRegistry::mutate_locked(|registry| {
785 registry.cleanup_stale(presence_ttl_hours());
786 })
787 .map(|(registry, ())| registry)
788 .unwrap_or_else(|_| AgentRegistry::load_or_create());
789 let total = registry.agents.len();
790 let active = registry.list_active(None).len();
791 let messages = registry.scratchpad.len();
792 format!(
793 "Agent Registry: {total} total, {active} active, {messages} scratchpad entries\nLast updated: {}",
794 registry.updated_at.format("%Y-%m-%d %H:%M UTC")
795 )
796 }
797
798 "handoff" => {
799 let Some(from) = current_agent_id else {
800 return "Error: agent must be registered first".to_string();
801 };
802 let Some(target) = to_agent else {
803 return "Error: to_agent is required for handoff".to_string();
804 };
805 let summary = message.unwrap_or("(no summary provided)");
806 let scratchpad_ttl = scratchpad_default_ttl_hours();
807 if target.starts_with("wkr_") {
812 use lean_ctx_hive::bus::messages_v2::{Extras, send};
813 let extras = Extras {
814 to_worker: Some(target),
815 ..Extras::default()
816 };
817 return match send(project_root, from, Some("handoff"), summary, extras) {
818 Ok(id) => {
819 let waiting = format!("handoff to {target} offered through Hive");
820 let _ = AgentRegistry::mutate_locked(|registry| {
821 registry.set_status(from, AgentStatus::Idle, Some(&waiting))
822 });
823 format!(
824 "Handoff offered (hive): {from} → {target} message={id}; \
825 {target} answers it (accept or decline)\nSummary: {summary}"
826 )
827 }
828 Err(error) => format!("Handoff rejected: {error}"),
829 };
830 }
831
832 if let Err(error) = AgentRegistry::mutate_locked(|registry| {
833 registry.post_message_scoped(
837 Some(project_root),
838 from,
839 Some(target),
840 "handoff",
841 &format!("HANDOFF from {from}: {summary}"),
842 PrivacyLevel::Team,
843 MessagePriority::Normal,
844 Some(scratchpad_ttl),
845 )?;
846 registry.set_status(from, AgentStatus::Finished, Some("handed off"))
847 })
848 .and_then(|(_, result)| result)
849 {
850 return format!("Handoff rejected: {error}");
851 }
852
853 let handoff_message = format!("HANDOFF: {summary}");
855 let capsule_ref = global_capsule_store().register(handoff_message.as_bytes());
856 let mut envelope = AgentEnvelope {
857 schema_version: AGENT_ENVELOPE_SCHEMA_VERSION,
858 relay_id: String::new(),
859 context: OclaRequestContext::new(
860 format!("handoff:{capsule_ref}"),
861 project_root.to_string(),
862 from.to_string(),
863 capsule_ref.clone(),
864 None,
865 Some(format!("handoff:{from}:{target}")),
866 ),
867 from_agent_id: from.to_string(),
868 to_agent_id: target.to_string(),
869 capsule_ref,
870 budget_tokens: handoff_message.len().max(1) as u64,
871 };
872 if let Err(error) = envelope.assign_relay_id() {
873 tracing::debug!("OCLA relay setup failed (non-fatal): {error}");
874 } else if let Err(error) = OclaRegistry::global().agent_gateway.relay_agent(envelope) {
875 tracing::debug!("OCLA relay failed (non-fatal): {error}");
876 }
877
878 lean_ctx::core::scent_field::deposit_for(
881 project_root,
882 from,
883 lean_ctx::core::scent_field::ScentKind::Done,
884 summary,
885 1.0,
886 );
887
888 format!("Handoff complete: {from} → {target}\nSummary: {summary}")
889 }
890
891 "claim" => {
895 let Some(target) = message else {
896 return "Error: message (the claim target, e.g. a file path or task label) is required for claim".to_string();
897 };
898 let agent = current_agent_id.map_or_else(
899 || lean_ctx::core::scent_field::scent_agent_id().to_string(),
900 str::to_string,
901 );
902 let normalized = lean_ctx::core::pathutil::normalize_tool_path(target);
903 match lean_ctx::core::scent_field::claim(project_root, &agent, &normalized) {
904 Ok(()) => {
905 format!("Claimed: {normalized} (by {agent}, decays in ~10m unless re-claimed)")
906 }
907 Err(e) => format!("Claim REJECTED: {normalized} — {e}"),
908 }
909 }
910
911 "release" => {
913 let Some(target) = message else {
914 return "Error: message (the claim target) is required for release".to_string();
915 };
916 let agent = current_agent_id.map_or_else(
917 || lean_ctx::core::scent_field::scent_agent_id().to_string(),
918 str::to_string,
919 );
920 let normalized = lean_ctx::core::pathutil::normalize_tool_path(target);
921 lean_ctx::core::scent_field::release(project_root, &agent, &normalized);
922 format!("Released: {normalized}")
923 }
924
925 "brief" => {
929 let Some(task) = message else {
930 return "Error: message (the sub-agent task) is required for brief".to_string();
931 };
932 let budget = priority
933 .and_then(|p| p.parse::<usize>().ok())
934 .unwrap_or(2000);
935 let Some(knowledge) = lean_ctx::core::knowledge::ProjectKnowledge::load(project_root)
936 else {
937 return "No knowledge stored for this project yet — a briefing pack needs facts. Use ctx_knowledge(action=\"remember\") first.".to_string();
938 };
939 let pack =
941 lean_ctx::core::subagent_contract::build_briefing_pack(&knowledge, task, budget);
942 match lean_ctx::core::subagent_contract::serialize_pack(&pack) {
943 Ok(json) => json,
944 Err(e) => format!("Error: {e}"),
945 }
946 }
947
948 "return" => {
952 let Some(report) = message else {
953 return "Error: message (the sub-agent report) is required for return".to_string();
954 };
955 let (facts, rejected) = lean_ctx::core::subagent_contract::parse_return_lines(report);
956 if facts.is_empty() {
957 return format!(
958 "No contract-formatted lines found ({} rejected). Expected 'category/key: value' per line.",
959 rejected.len()
960 );
961 }
962
963 let session_label = current_agent_id.unwrap_or("subagent");
964 let policy = match crate::tools::knowledge_shared::load_policy_or_error() {
965 Ok(p) => p,
966 Err(e) => return e,
967 };
968 let count = facts.len();
969 let res = lean_ctx::core::knowledge::ProjectKnowledge::mutate_locked(
970 project_root,
971 |knowledge| {
972 for f in &facts {
973 knowledge.remember(
974 &f.category,
975 &f.key,
976 &f.value,
977 session_label,
978 0.8,
979 &policy,
980 );
981 }
982 },
983 );
984 match res {
985 Ok(_) => {
986 let mut out = format!(
987 "Return synthesis: {count} fact(s) distilled into parent knowledge"
988 );
989 if !rejected.is_empty() {
990 out.push_str(&format!(
991 "\n{} line(s) rejected (not 'category/key: value'):",
992 rejected.len()
993 ));
994 for r in rejected.iter().take(5) {
995 out.push_str(&format!("\n ✗ {r}"));
996 }
997 }
998 out
999 }
1000 Err(e) => format!("Error: knowledge store update failed: {e}"),
1001 }
1002 }
1003
1004 "sync" => {
1005 let registry = AgentRegistry::mutate_locked(|registry| {
1006 registry.cleanup_stale(presence_ttl_hours());
1007 })
1008 .map(|(registry, ())| registry)
1009 .unwrap_or_else(|_| AgentRegistry::load_or_create());
1010 let pending_count = current_agent_id.map_or(0, |id| {
1011 registry
1012 .scratchpad
1013 .iter()
1014 .filter(|e| {
1015 !e.read_by.contains(&id.to_string())
1016 && e.from_agent != id
1017 && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(id))
1018 && e.project_root.as_deref().is_some_and(|own| {
1019 lean_ctx::core::pathutil::same_project(own, project_root)
1020 })
1021 })
1022 .count()
1023 });
1024 let agents = registry.list_active(Some(project_root));
1025
1026 if agents.is_empty() {
1027 return "No active agents to sync with.".to_string();
1028 }
1029
1030 let shared_dir = lean_ctx::core::data_dir::lean_ctx_data_dir()
1032 .unwrap_or_default()
1033 .join("agents")
1034 .join("shared")
1035 .join(lean_ctx::core::project_hash::hash_project_root(
1036 project_root,
1037 ));
1038
1039 let shared_count = if shared_dir.exists() {
1040 std::fs::read_dir(&shared_dir).map_or(0, std::iter::Iterator::count)
1041 } else {
1042 0
1043 };
1044
1045 let mut out = "Multi-Agent Sync Status:\n".to_string();
1046 out.push_str(&format!(" Active agents: {}\n", agents.len()));
1047 for a in &agents {
1048 let role = a.role.as_deref().unwrap_or("-");
1049 let age = (chrono::Utc::now() - a.last_active).num_minutes();
1050 out.push_str(&format!(
1051 " {} [{}] role={} ({}m ago)\n",
1052 a.agent_id, a.agent_type, role, age
1053 ));
1054 }
1055 out.push_str(&format!(" Pending messages: {pending_count}\n"));
1056 out.push_str(&format!(" Shared contexts: {shared_count}\n"));
1057
1058 let scents = lean_ctx::core::scent_field::sync_block(project_root);
1061 if !scents.is_empty() {
1062 out.push_str(&scents);
1063 }
1064 out
1065 }
1066
1067 "control_status" => control_status(project_root, current_agent_id),
1072
1073 "export" => {
1074 let Some(agent_id) = current_agent_id else {
1075 return "Error: agent must be registered first (use action=register)".to_string();
1076 };
1077
1078 fn privacy_label(p: &PrivacyLevel) -> &'static str {
1079 match p {
1080 PrivacyLevel::Public => "public",
1081 PrivacyLevel::Team => "team",
1082 PrivacyLevel::Private => "private",
1083 }
1084 }
1085
1086 fn priority_label(p: &MessagePriority) -> &'static str {
1087 match p {
1088 MessagePriority::Low => "low",
1089 MessagePriority::Normal => "normal",
1090 MessagePriority::High => "high",
1091 MessagePriority::Critical => "critical",
1092 }
1093 }
1094
1095 fn maybe_redact(s: &str, should_redact: bool) -> String {
1096 if should_redact {
1097 lean_ctx::core::redaction::redact_text(s)
1098 } else {
1099 s.to_string()
1100 }
1101 }
1102
1103 #[derive(serde::Serialize)]
1104 struct ExportAgentV1 {
1105 agent_id: String,
1106 agent_type: String,
1107 role: Option<String>,
1108 status: String,
1109 status_message: Option<String>,
1110 started_at: String,
1111 last_active: String,
1112 pid: u32,
1113 }
1114
1115 #[derive(serde::Serialize)]
1116 struct ExportMessageV1 {
1117 id: String,
1118 from_agent: String,
1119 to_agent: Option<String>,
1120 category: String,
1121 privacy: String,
1122 priority: String,
1123 message: String,
1124 metadata: std::collections::BTreeMap<String, String>,
1125 timestamp: String,
1126 expires_at: Option<String>,
1127 read_by_count: usize,
1128 }
1129
1130 #[derive(serde::Serialize)]
1131 struct ExportTaskV1 {
1132 id: String,
1133 from_agent: String,
1134 to_agent: String,
1135 state: String,
1136 description: String,
1137 created_at: String,
1138 updated_at: String,
1139 messages: usize,
1140 artifacts: usize,
1141 transitions: usize,
1142 }
1143
1144 #[derive(serde::Serialize)]
1145 struct ExportDiaryEntryV1 {
1146 entry_type: String,
1147 content: String,
1148 context: Option<String>,
1149 timestamp: String,
1150 }
1151
1152 #[derive(serde::Serialize)]
1153 struct ExportDiaryV1 {
1154 agent_id: String,
1155 agent_type: String,
1156 project_root: String,
1157 updated_at: String,
1158 entries: Vec<ExportDiaryEntryV1>,
1159 }
1160
1161 #[derive(serde::Serialize)]
1162 struct A2ASnapshotV1 {
1163 schema_version: u32,
1164 created_at: String,
1165 project_root: String,
1166 agent_id: String,
1167 agents: Vec<ExportAgentV1>,
1168 messages: Vec<ExportMessageV1>,
1169 tasks: Vec<ExportTaskV1>,
1170 diary: Option<ExportDiaryV1>,
1171 }
1172
1173 let privacy_mode = privacy.unwrap_or("redacted");
1174 let allow_full = privacy_mode == "full"
1175 && !lean_ctx::core::redaction::redaction_enabled_for_active_role();
1176 let should_redact = !allow_full;
1177
1178 let now = chrono::Utc::now();
1179 let mut registry = AgentRegistry::load_or_create();
1180 registry.cleanup_stale(presence_ttl_hours());
1181
1182 let mut agents: Vec<ExportAgentV1> = registry
1183 .list_active(Some(project_root))
1184 .into_iter()
1185 .map(|a| ExportAgentV1 {
1186 agent_id: a.agent_id.clone(),
1187 agent_type: a.agent_type.clone(),
1188 role: a.role.clone(),
1189 status: a.status.to_string(),
1190 status_message: a.status_message.clone(),
1191 started_at: a.started_at.to_rfc3339(),
1192 last_active: a.last_active.to_rfc3339(),
1193 pid: a.pid,
1194 })
1195 .collect();
1196 agents.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
1197
1198 let mut messages: Vec<ExportMessageV1> = registry
1199 .scratchpad
1200 .iter()
1201 .filter(|e| {
1202 e.project_root.as_deref().is_some_and(|own| {
1203 lean_ctx::core::pathutil::same_project(own, project_root)
1204 }) && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
1205 })
1206 .take(200)
1207 .map(|m| ExportMessageV1 {
1208 id: m.id.clone(),
1209 from_agent: m.from_agent.clone(),
1210 to_agent: m.to_agent.clone(),
1211 category: m.category.clone(),
1212 privacy: privacy_label(&m.privacy).to_string(),
1213 priority: priority_label(&m.priority).to_string(),
1214 message: maybe_redact(&m.served_message(Some(project_root)), should_redact),
1215 metadata: m
1217 .metadata
1218 .iter()
1219 .filter(|(k, _)| k.as_str() != lean_ctx::core::agents::BINDING_METADATA_KEY)
1220 .map(|(k, v)| (k.clone(), maybe_redact(v, should_redact)))
1221 .collect(),
1222 timestamp: m.timestamp.to_rfc3339(),
1223 expires_at: m.expires_at.map(|t| t.to_rfc3339()),
1224 read_by_count: m.read_by.len(),
1225 })
1226 .collect();
1227 messages.sort_by(|a, b| a.timestamp.cmp(&b.timestamp).then_with(|| a.id.cmp(&b.id)));
1228
1229 const EXPORT_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
1234 let tasks = TaskStore::default_path().and_then(|path| {
1235 TaskStore::mutate_locked_within(&path, EXPORT_LOCK_WAIT, |task_store| {
1236 task_store.cleanup_old(72);
1237 Ok::<_, std::io::Error>(
1238 task_store
1239 .tasks_for_agent(agent_id)
1240 .into_iter()
1241 .take(200)
1242 .map(|t| ExportTaskV1 {
1243 id: t.id.clone(),
1244 from_agent: t.from_agent.clone(),
1245 to_agent: t.to_agent.clone(),
1246 state: t.state.to_string(),
1247 description: maybe_redact(&t.served_description(), should_redact),
1248 created_at: t.created_at.to_rfc3339(),
1249 updated_at: t.updated_at.to_rfc3339(),
1250 messages: t.messages.len(),
1251 artifacts: t.artifacts.len(),
1252 transitions: t.history.len(),
1253 })
1254 .collect::<Vec<ExportTaskV1>>(),
1255 )
1256 })
1257 });
1258 let mut tasks = match tasks {
1259 Ok(tasks) => tasks,
1260 Err(error) => {
1261 tracing::warn!("lean-ctx: failed to read task store: {error}");
1262 return "Error: task storage unavailable".to_string();
1263 }
1264 };
1265 tasks.sort_by(|a, b| {
1266 b.updated_at
1267 .cmp(&a.updated_at)
1268 .then_with(|| a.id.cmp(&b.id))
1269 });
1270
1271 let diary =
1272 AgentDiary::load_for_project(agent_id, project_root).map(|d| ExportDiaryV1 {
1273 agent_id: d.agent_id,
1274 agent_type: d.agent_type,
1275 project_root: d.project_root,
1276 updated_at: d.updated_at.to_rfc3339(),
1277 entries: d
1278 .entries
1279 .iter()
1280 .rev()
1281 .take(25)
1282 .rev()
1283 .map(|e| ExportDiaryEntryV1 {
1284 entry_type: e.entry_type.to_string(),
1285 content: maybe_redact(&e.content, should_redact),
1286 context: e.context.as_deref().map(|c| maybe_redact(c, should_redact)),
1287 timestamp: e.timestamp.to_rfc3339(),
1288 })
1289 .collect(),
1290 });
1291
1292 let payload = A2ASnapshotV1 {
1293 schema_version: lean_ctx::core::contracts::A2A_SNAPSHOT_V1_SCHEMA_VERSION,
1294 created_at: now.to_rfc3339(),
1295 project_root: project_root.to_string(),
1296 agent_id: agent_id.to_string(),
1297 agents,
1298 messages,
1299 tasks,
1300 diary,
1301 };
1302
1303 let json = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string());
1304
1305 if write {
1306 let name = filename.map_or_else(
1307 || format!("a2a-snapshot-v1_{}.json", now.format("%Y%m%d_%H%M%S")),
1308 str::to_string,
1309 );
1310 let out_path =
1311 match write_a2a_snapshot(std::path::Path::new(project_root), &name, &json) {
1312 Ok(path) => path,
1313 Err(e) => return format!("Error: write snapshot: {e}"),
1314 };
1315
1316 return format!(
1317 "A2A snapshot exported: {}\n agents: {}\n messages: {}\n tasks: {}",
1318 out_path.display(),
1319 payload.agents.len(),
1320 payload.messages.len(),
1321 payload.tasks.len()
1322 );
1323 }
1324
1325 match format.unwrap_or("json") {
1326 "text" => format!(
1327 "A2A snapshot (v1)\n agents: {}\n messages: {}\n tasks: {}",
1328 payload.agents.len(),
1329 payload.messages.len(),
1330 payload.tasks.len()
1331 ),
1332 _ => json,
1333 }
1334 }
1335
1336 "diary" => {
1337 let Some(agent_id) = current_agent_id else {
1338 return "Error: agent must be registered first".to_string();
1339 };
1340 let Some(content) = message else {
1341 return "Error: message is required for diary entry".to_string();
1342 };
1343 let entry_type = match category.unwrap_or("progress") {
1344 "discovery" | "found" => DiaryEntryType::Discovery,
1345 "decision" | "decided" => DiaryEntryType::Decision,
1346 "blocker" | "blocked" => DiaryEntryType::Blocker,
1347 "progress" | "done" => DiaryEntryType::Progress,
1348 "insight" => DiaryEntryType::Insight,
1349 other => {
1350 return format!(
1351 "Unknown diary type: {other}. Use: discovery, decision, blocker, progress, insight"
1352 );
1353 }
1354 };
1355 let atype = agent_type.unwrap_or("unknown");
1356 let mut diary = AgentDiary::load_or_create(agent_id, atype, project_root);
1357 let context_str = to_agent;
1358 diary.add_entry(entry_type.clone(), content, context_str);
1359 match diary.save() {
1360 Ok(()) => format!("Diary entry [{entry_type}] added: {content}"),
1361 Err(e) => format!("Diary entry added but save failed: {e}"),
1362 }
1363 }
1364
1365 "recall_diary" | "diary_recall" => {
1366 let Some(agent_id) = current_agent_id else {
1367 let diaries = AgentDiary::list_for_project(project_root);
1368 if diaries.is_empty() {
1369 return "No agent diaries found.".to_string();
1370 }
1371 let mut out = format!("Agent Diaries ({}):\n", diaries.len());
1372 for (id, count, updated) in &diaries {
1373 let age = (chrono::Utc::now() - *updated).num_minutes();
1374 out.push_str(&format!(" {id}: {count} entries ({age}m ago)\n"));
1375 }
1376 return out;
1377 };
1378 match AgentDiary::load_for_project(agent_id, project_root) {
1379 Some(diary) => diary.format_summary(),
1380 None => format!("No diary found for agent '{agent_id}'."),
1381 }
1382 }
1383
1384 "diaries" => {
1385 let diaries = AgentDiary::list_for_project(project_root);
1386 if diaries.is_empty() {
1387 return "No agent diaries found.".to_string();
1388 }
1389 let mut out = format!("Agent Diaries ({}):\n", diaries.len());
1390 for (id, count, updated) in &diaries {
1391 let age = (chrono::Utc::now() - *updated).num_minutes();
1392 out.push_str(&format!(" {id}: {count} entries ({age}m ago)\n"));
1393 }
1394 out
1395 }
1396
1397 "share_knowledge" => {
1398 let cat = category.unwrap_or("general");
1399 let Some(msg_text) = message else {
1400 return "Error: message required (format: key1=value1;key2=value2)".to_string();
1401 };
1402 let facts: Vec<(String, String)> = msg_text
1403 .split(';')
1404 .filter_map(|kv| {
1405 let (k, v) = kv.split_once('=')?;
1406 Some((k.trim().to_string(), v.trim().to_string()))
1407 })
1408 .collect();
1409 if facts.is_empty() {
1410 return "Error: no valid key=value pairs found".to_string();
1411 }
1412 let Some(from) = current_agent_id else {
1413 return "Error: agent must be registered first".to_string();
1414 };
1415 match AgentRegistry::mutate_locked(|registry| {
1417 registry.require_active_for_presence(from, "sharing knowledge")?;
1418 registry.share_knowledge(project_root, from, cat, &facts)
1419 })
1420 .and_then(|(_, shared)| shared)
1421 {
1422 Ok(count) => format!("Shared {count} facts in category '{cat}' with this project"),
1423 Err(e) => format!("Share failed: {e}"),
1424 }
1425 }
1426
1427 "receive_knowledge" => {
1428 let Some(agent_id) = current_agent_id else {
1429 return "Error: agent must be registered first".to_string();
1430 };
1431 let facts = match AgentRegistry::mutate_locked(|registry| {
1432 registry.require_active_for_presence(agent_id, "receiving knowledge")?;
1433 registry.receive_shared_knowledge(project_root, agent_id)
1434 })
1435 .and_then(|(_, facts)| facts)
1436 {
1437 Ok(facts) => facts,
1438 Err(e) => return format!("Receive failed: {e}"),
1439 };
1440 if facts.is_empty() {
1441 return "No new shared knowledge.".to_string();
1442 }
1443 let mut out = format!("Received {} facts:\n", facts.len());
1444 for f in &facts {
1445 let age = (chrono::Utc::now() - f.timestamp).num_minutes();
1446 out.push_str(&format!(
1447 " [{}] {} (from {}, {}m ago)\n",
1448 f.category, f.text, f.from_agent, age
1449 ));
1450 }
1451 out
1452 }
1453
1454 "poll_events" => {
1455 let Some(agent_id) = current_agent_id else {
1456 return "Error: agent must be registered first".to_string();
1457 };
1458 let workspace_id = to_agent.unwrap_or(project_root);
1459 let channel_id = category.unwrap_or("default");
1460 let since_id: i64 = message.and_then(|s| s.parse().ok()).unwrap_or(0);
1461 let limit: usize = _ttl_hours.unwrap_or(50) as usize;
1462
1463 let rt = lean_ctx::core::context_os::runtime();
1464 let events = rt.bus.read(workspace_id, channel_id, since_id, limit);
1465
1466 let filter = lean_ctx::core::context_os::TopicFilter {
1467 agent_id: Some(agent_id.to_string()),
1468 kinds: privacy.and_then(|s| {
1469 let kinds: Vec<_> = s
1470 .split(',')
1471 .map(|k| lean_ctx::core::context_os::ContextEventKindV1::parse(k.trim()))
1472 .collect();
1473 if kinds.is_empty() { None } else { Some(kinds) }
1474 }),
1475 ..Default::default()
1476 };
1477
1478 let filtered: Vec<_> = events.into_iter().filter(|e| filter.matches(e)).collect();
1479 if filtered.is_empty() {
1480 return format!("No new events since id={since_id} for {agent_id}.");
1481 }
1482
1483 let mut out = format!("Events ({}, since={since_id}):\n", filtered.len());
1484 for ev in &filtered {
1485 let actor = ev.actor.as_deref().unwrap_or("-");
1486 out.push_str(&format!(
1487 " #{} [{}] actor={} cl={} ({})\n",
1488 ev.id,
1489 ev.kind,
1490 actor,
1491 ev.consistency_level,
1492 ev.timestamp.format("%H:%M:%S")
1493 ));
1494 }
1495 if let Some(last) = filtered.last() {
1496 out.push_str(&format!("cursor={}", last.id));
1497 }
1498 out
1499 }
1500
1501 "lease_acquire" => lease::acquire(project_root, current_agent_id, message, _ttl_hours),
1503 "lease_release" => lease::release(project_root, current_agent_id, message, category),
1504 "lease_renew" => lease::renew(
1505 project_root,
1506 current_agent_id,
1507 message,
1508 category,
1509 _ttl_hours,
1510 ),
1511
1512 _ => format!("Unknown action: {action}. Use: {}", ACTIONS.join(", ")),
1513 }
1514}
1515
1516fn write_a2a_snapshot(
1520 project_root: &std::path::Path,
1521 name: &str,
1522 json: &str,
1523) -> Result<std::path::PathBuf, String> {
1524 lean_ctx::core::context_proof::proof_destination(project_root, name)?.write(json)
1525}
1526
1527#[cfg(test)]
1528#[path = "ctx_agent_tests.rs"]
1529mod tests;
1530