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