1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6use crate::core::a2a::message::{A2AMessage, MessageCategory, MessagePriority, PrivacyLevel};
7
8const MAX_SCRATCHPAD_ENTRIES: usize = 200;
9const MAX_DIARY_ENTRIES: usize = 100;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct AgentRegistry {
13 pub agents: Vec<AgentEntry>,
14 pub scratchpad: Vec<ScratchpadEntry>,
15 #[serde(default)]
16 pub logical_sessions: Vec<LogicalSessionPresence>,
17 #[serde(default)]
18 pub logical_session_telemetry_seen: bool,
19 pub updated_at: DateTime<Utc>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23pub struct LogicalSessionPresence {
24 pub source: String,
25 pub workspace: String,
26 pub session_id: String,
27 pub opened_at: DateTime<Utc>,
28 pub last_heartbeat: DateTime<Utc>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct AgentDiary {
33 pub agent_id: String,
34 pub agent_type: String,
35 pub project_root: String,
36 pub entries: Vec<DiaryEntry>,
37 pub created_at: DateTime<Utc>,
38 pub updated_at: DateTime<Utc>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct DiaryEntry {
43 pub entry_type: DiaryEntryType,
44 pub content: String,
45 pub context: Option<String>,
46 pub timestamp: DateTime<Utc>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
50pub enum DiaryEntryType {
51 Discovery,
52 Decision,
53 Blocker,
54 Progress,
55 Insight,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct AgentEntry {
60 pub agent_id: String,
61 pub agent_type: String,
62 pub role: Option<String>,
63 pub project_root: String,
64 pub started_at: DateTime<Utc>,
65 pub last_active: DateTime<Utc>,
66 pub pid: u32,
67 pub status: AgentStatus,
68 pub status_message: Option<String>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
72pub enum AgentStatus {
73 Active,
74 Idle,
75 Finished,
76}
77
78impl std::fmt::Display for AgentStatus {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 match self {
81 AgentStatus::Active => write!(f, "active"),
82 AgentStatus::Idle => write!(f, "idle"),
83 AgentStatus::Finished => write!(f, "finished"),
84 }
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ScratchpadEntry {
90 pub id: String,
91 pub from_agent: String,
92 pub to_agent: Option<String>,
93 #[serde(default)]
94 pub task_id: Option<String>,
95 pub category: String,
96 #[serde(default)]
97 pub priority: MessagePriority,
98 #[serde(default)]
99 pub privacy: PrivacyLevel,
100 pub message: String,
101 #[serde(default)]
102 pub metadata: HashMap<String, String>,
103 #[serde(default)]
104 pub project_root: Option<String>,
105 pub timestamp: DateTime<Utc>,
106 pub read_by: Vec<String>,
107 #[serde(default)]
108 pub expires_at: Option<DateTime<Utc>>,
109}
110
111impl AgentRegistry {
112 pub fn new() -> Self {
113 Self {
114 agents: Vec::new(),
115 scratchpad: Vec::new(),
116 logical_sessions: Vec::new(),
117 logical_session_telemetry_seen: false,
118 updated_at: Utc::now(),
119 }
120 }
121
122 pub fn register(&mut self, agent_type: &str, role: Option<&str>, project_root: &str) -> String {
123 self.register_process(agent_type, role, project_root, std::process::id())
124 }
125
126 fn register_process(
127 &mut self,
128 agent_type: &str,
129 role: Option<&str>,
130 project_root: &str,
131 pid: u32,
132 ) -> String {
133 let agent_id = format!("{}-{}-{}", agent_type, pid, generate_short_id());
134
135 if let Some(existing) = self.agents.iter_mut().find(|a| a.pid == pid) {
136 existing.last_active = Utc::now();
137 existing.status = AgentStatus::Active;
138 existing.agent_type = agent_type.to_string();
139 existing.project_root = project_root.to_string();
140 if let Some(r) = role {
141 existing.role = Some(r.to_string());
142 }
143 return existing.agent_id.clone();
144 }
145
146 self.agents.push(AgentEntry {
147 agent_id: agent_id.clone(),
148 agent_type: agent_type.to_string(),
149 role: role.map(std::string::ToString::to_string),
150 project_root: project_root.to_string(),
151 started_at: Utc::now(),
152 last_active: Utc::now(),
153 pid,
154 status: AgentStatus::Active,
155 status_message: None,
156 });
157
158 self.updated_at = Utc::now();
159 crate::core::events::emit_agent_action(&agent_id, "register", None);
160 agent_id
161 }
162
163 pub fn register_mcp_process(project_root: &str) -> Result<String, String> {
165 mutate_persistent(|registry| {
166 registry.cleanup_stale(24);
167 registry.register("mcp", Some("context-engine"), project_root)
168 })
169 }
170
171 pub fn heartbeat_persistent(agent_id: &str) -> Result<(), String> {
173 mutate_persistent(|registry| registry.update_heartbeat(agent_id))
174 }
175
176 pub fn finish_persistent(agent_id: &str) -> Result<(), String> {
178 mutate_persistent(|registry| {
179 registry.set_status(agent_id, AgentStatus::Finished, Some("connection closed"));
180 })
181 }
182
183 pub fn update_heartbeat(&mut self, agent_id: &str) {
184 if let Some(agent) = self.agents.iter_mut().find(|a| a.agent_id == agent_id) {
185 agent.last_active = Utc::now();
186 }
187 }
188
189 pub fn set_status(&mut self, agent_id: &str, status: AgentStatus, message: Option<&str>) {
190 if let Some(agent) = self.agents.iter_mut().find(|a| a.agent_id == agent_id) {
191 agent.status = status;
192 agent.status_message = message.map(std::string::ToString::to_string);
193 agent.last_active = Utc::now();
194 }
195 self.updated_at = Utc::now();
196 }
197 pub fn open_or_heartbeat_logical_session(
200 &mut self,
201 source: &str,
202 workspace: &str,
203 session_id: &str,
204 ) {
205 let now = Utc::now();
206 self.logical_session_telemetry_seen = true;
207 if let Some(session) = self.logical_sessions.iter_mut().find(|session| {
208 session.source == source
209 && session.workspace == workspace
210 && session.session_id == session_id
211 }) {
212 session.last_heartbeat = now;
213 } else {
214 self.logical_sessions.push(LogicalSessionPresence {
215 source: source.to_string(),
216 workspace: workspace.to_string(),
217 session_id: session_id.to_string(),
218 opened_at: now,
219 last_heartbeat: now,
220 });
221 }
222 self.updated_at = now;
223 }
224
225 pub fn close_logical_session(
226 &mut self,
227 source: &str,
228 workspace: &str,
229 session_id: &str,
230 ) -> bool {
231 self.logical_session_telemetry_seen = true;
232 let previous_len = self.logical_sessions.len();
233 self.logical_sessions.retain(|session| {
234 session.source != source
235 || session.workspace != workspace
236 || session.session_id != session_id
237 });
238 let removed = self.logical_sessions.len() != previous_len;
239 self.updated_at = Utc::now();
240 removed
241 }
242
243 pub fn cleanup_stale_logical_sessions(&mut self, max_age_seconds: u64) {
244 let seconds = i64::try_from(max_age_seconds).unwrap_or(i64::MAX);
245 let cutoff = Utc::now() - chrono::Duration::seconds(seconds);
246 self.logical_sessions
247 .retain(|session| session.last_heartbeat >= cutoff);
248 self.updated_at = Utc::now();
249 }
250
251 pub fn list_active(&self, project_root: Option<&str>) -> Vec<&AgentEntry> {
252 self.agents
253 .iter()
254 .filter(|a| {
255 if let Some(root) = project_root {
256 a.project_root == root && a.status != AgentStatus::Finished
257 } else {
258 a.status != AgentStatus::Finished
259 }
260 })
261 .collect()
262 }
263
264 pub fn list_all(&self) -> &[AgentEntry] {
265 &self.agents
266 }
267
268 pub fn post_message(
269 &mut self,
270 from_agent: &str,
271 to_agent: Option<&str>,
272 category: &str,
273 message: &str,
274 ) -> String {
275 self.post_message_full(
276 from_agent,
277 to_agent,
278 category,
279 message,
280 PrivacyLevel::default(),
281 MessagePriority::default(),
282 None,
283 )
284 }
285
286 pub fn post_message_full(
287 &mut self,
288 from_agent: &str,
289 to_agent: Option<&str>,
290 category: &str,
291 message: &str,
292 privacy: PrivacyLevel,
293 priority: MessagePriority,
294 ttl_hours: Option<u64>,
295 ) -> String {
296 let id = generate_short_id();
297 let expires_at = ttl_hours.map(|h| Utc::now() + chrono::Duration::hours(h as i64));
298 self.scratchpad.push(ScratchpadEntry {
299 id: id.clone(),
300 from_agent: from_agent.to_string(),
301 to_agent: to_agent.map(std::string::ToString::to_string),
302 task_id: None,
303 category: category.to_string(),
304 priority,
305 privacy,
306 message: message.to_string(),
307 metadata: HashMap::new(),
308 project_root: None,
309 timestamp: Utc::now(),
310 read_by: vec![from_agent.to_string()],
311 expires_at,
312 });
313
314 if self.scratchpad.len() > MAX_SCRATCHPAD_ENTRIES {
315 self.scratchpad
316 .drain(0..self.scratchpad.len() - MAX_SCRATCHPAD_ENTRIES);
317 }
318
319 self.updated_at = Utc::now();
320 id
321 }
322
323 pub fn read_messages(&mut self, agent_id: &str) -> Vec<&ScratchpadEntry> {
324 let unread: Vec<usize> = self
325 .scratchpad
326 .iter()
327 .enumerate()
328 .filter(|(_, e)| {
329 !e.read_by.contains(&agent_id.to_string())
330 && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
331 })
332 .map(|(i, _)| i)
333 .collect();
334
335 for i in &unread {
336 self.scratchpad[*i].read_by.push(agent_id.to_string());
337 }
338
339 self.scratchpad
340 .iter()
341 .filter(|e| e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
342 .filter(|e| e.from_agent != agent_id)
343 .collect()
344 }
345
346 pub fn read_unread(&mut self, agent_id: &str) -> Vec<&ScratchpadEntry> {
347 let unread_indices: Vec<usize> = self
348 .scratchpad
349 .iter()
350 .enumerate()
351 .filter(|(_, e)| {
352 !e.read_by.contains(&agent_id.to_string())
353 && e.from_agent != agent_id
354 && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
355 })
356 .map(|(i, _)| i)
357 .collect();
358
359 for i in &unread_indices {
360 self.scratchpad[*i].read_by.push(agent_id.to_string());
361 }
362
363 self.updated_at = Utc::now();
364
365 self.scratchpad
366 .iter()
367 .filter(|e| {
368 e.from_agent != agent_id
369 && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
370 && e.read_by.contains(&agent_id.to_string())
371 && e.read_by.iter().filter(|r| *r == agent_id).count() == 1
372 })
373 .collect()
374 }
375
376 pub fn cleanup_stale(&mut self, max_age_hours: u64) {
377 let cutoff = Utc::now() - chrono::Duration::hours(max_age_hours as i64);
378
379 for agent in &mut self.agents {
380 if agent.status == AgentStatus::Finished {
381 continue;
382 }
383 if !is_process_alive(agent.pid) {
384 agent.status = AgentStatus::Finished;
385 }
386 }
387
388 self.agents.retain(|a| {
392 let retire = a.status == AgentStatus::Finished && a.last_active < cutoff;
393 if retire {
394 crate::core::agent_budget::remove(&a.agent_id);
395 }
396 !retire
397 });
398
399 self.updated_at = Utc::now();
400 }
401
402 pub fn save(&self) -> Result<(), String> {
403 let dir = agents_dir()?;
404 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
405
406 let lock_path = dir.join("registry.lock");
407 let _lock = FileLock::acquire(&lock_path)?;
408
409 self.save_locked(&dir)
410 }
411
412 fn save_locked(&self, dir: &std::path::Path) -> Result<(), String> {
413 let path = dir.join("registry.json");
414 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
415 std::fs::write(&path, json).map_err(|e| e.to_string())
416 }
417
418 pub fn load() -> Option<Self> {
419 let dir = agents_dir().ok()?;
420 let path = dir.join("registry.json");
421 let content = std::fs::read_to_string(&path).ok()?;
422 serde_json::from_str(&content).ok()
423 }
424
425 pub fn load_or_create() -> Self {
426 Self::load().unwrap_or_default()
427 }
428
429 pub fn mutate_locked<T>(f: impl FnOnce(&mut Self) -> T) -> Result<(Self, T), String> {
439 let dir = agents_dir()?;
440 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
441
442 let lock_path = dir.join("registry.lock");
443 let _lock = FileLock::acquire(&lock_path)?;
444
445 let mut registry = Self::load().unwrap_or_default();
446 let out = f(&mut registry);
447 registry.save_locked(&dir)?;
448 Ok((registry, out))
449 }
450}
451
452impl Default for AgentRegistry {
453 fn default() -> Self {
454 Self::new()
455 }
456}
457
458impl AgentDiary {
459 pub fn new(agent_id: &str, agent_type: &str, project_root: &str) -> Self {
460 let now = Utc::now();
461 Self {
462 agent_id: agent_id.to_string(),
463 agent_type: agent_type.to_string(),
464 project_root: project_root.to_string(),
465 entries: Vec::new(),
466 created_at: now,
467 updated_at: now,
468 }
469 }
470
471 pub fn add_entry(&mut self, entry_type: DiaryEntryType, content: &str, context: Option<&str>) {
472 self.entries.push(DiaryEntry {
473 entry_type,
474 content: content.to_string(),
475 context: context.map(std::string::ToString::to_string),
476 timestamp: Utc::now(),
477 });
478 if self.entries.len() > MAX_DIARY_ENTRIES {
479 self.entries
480 .drain(0..self.entries.len() - MAX_DIARY_ENTRIES);
481 }
482 self.updated_at = Utc::now();
483 }
484
485 pub fn format_summary(&self) -> String {
486 if self.entries.is_empty() {
487 return format!("Diary [{}]: empty", self.agent_id);
488 }
489 let mut out = format!(
490 "Diary [{}] ({} entries):\n",
491 self.agent_id,
492 self.entries.len()
493 );
494 let now = Utc::now();
495 for e in self.entries.iter().rev().take(10) {
496 let age = (now - e.timestamp).num_minutes();
497 let prefix = match e.entry_type {
498 DiaryEntryType::Discovery => "FOUND",
499 DiaryEntryType::Decision => "DECIDED",
500 DiaryEntryType::Blocker => "BLOCKED",
501 DiaryEntryType::Progress => "DONE",
502 DiaryEntryType::Insight => "INSIGHT",
503 };
504 let ctx = e
505 .context
506 .as_deref()
507 .map(|c| format!(" [{c}]"))
508 .unwrap_or_default();
509 out.push_str(&format!(" [{prefix}] {}{ctx} ({age}m ago)\n", e.content));
510 }
511 out
512 }
513
514 pub fn format_compact(&self) -> String {
515 if self.entries.is_empty() {
516 return String::new();
517 }
518 let items: Vec<String> = self
519 .entries
520 .iter()
521 .rev()
522 .take(5)
523 .map(|e| {
524 let prefix = match e.entry_type {
525 DiaryEntryType::Discovery => "F",
526 DiaryEntryType::Decision => "D",
527 DiaryEntryType::Blocker => "B",
528 DiaryEntryType::Progress => "P",
529 DiaryEntryType::Insight => "I",
530 };
531 format!("{prefix}:{}", truncate(&e.content, 50))
532 })
533 .collect();
534 format!("diary:{}|{}", self.agent_id, items.join("|"))
535 }
536
537 pub fn save(&self) -> Result<(), String> {
538 let dir = diary_dir()?;
539 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
540 let path = dir.join(format!("{}.json", sanitize_filename(&self.agent_id)));
541 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
542 std::fs::write(&path, json).map_err(|e| e.to_string())
543 }
544
545 pub fn load(agent_id: &str) -> Option<Self> {
546 let dir = diary_dir().ok()?;
547 let path = dir.join(format!("{}.json", sanitize_filename(agent_id)));
548 let content = std::fs::read_to_string(&path).ok()?;
549 serde_json::from_str(&content).ok()
550 }
551
552 pub fn load_or_create(agent_id: &str, agent_type: &str, project_root: &str) -> Self {
553 Self::load(agent_id).unwrap_or_else(|| Self::new(agent_id, agent_type, project_root))
554 }
555
556 pub fn list_all() -> Vec<(String, usize, DateTime<Utc>)> {
557 let Ok(dir) = diary_dir() else {
558 return Vec::new();
559 };
560 if !dir.exists() {
561 return Vec::new();
562 }
563 let mut results = Vec::new();
564 if let Ok(entries) = std::fs::read_dir(&dir) {
565 for entry in entries.flatten() {
566 if entry.path().extension().and_then(|e| e.to_str()) == Some("json")
567 && let Ok(content) = std::fs::read_to_string(entry.path())
568 && let Ok(diary) = serde_json::from_str::<AgentDiary>(&content)
569 {
570 results.push((diary.agent_id, diary.entries.len(), diary.updated_at));
571 }
572 }
573 }
574 results.sort_by_key(|x| std::cmp::Reverse(x.2));
575 results
576 }
577
578 pub fn load_all_for_project(project_root: &str) -> Vec<AgentDiary> {
582 let Ok(dir) = diary_dir() else {
583 return Vec::new();
584 };
585 if !dir.exists() {
586 return Vec::new();
587 }
588 let want = project_root.trim_end_matches('/');
589 let mut diaries: Vec<AgentDiary> = Vec::new();
590 if let Ok(entries) = std::fs::read_dir(&dir) {
591 for entry in entries.flatten() {
592 if entry.path().extension().and_then(|e| e.to_str()) != Some("json") {
593 continue;
594 }
595 if let Ok(content) = std::fs::read_to_string(entry.path())
596 && let Ok(diary) = serde_json::from_str::<AgentDiary>(&content)
597 && diary.project_root.trim_end_matches('/') == want
598 {
599 diaries.push(diary);
600 }
601 }
602 }
603 diaries.sort_by_key(|d| std::cmp::Reverse(d.updated_at));
604 diaries
605 }
606}
607
608impl std::fmt::Display for DiaryEntryType {
609 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
610 match self {
611 DiaryEntryType::Discovery => write!(f, "discovery"),
612 DiaryEntryType::Decision => write!(f, "decision"),
613 DiaryEntryType::Blocker => write!(f, "blocker"),
614 DiaryEntryType::Progress => write!(f, "progress"),
615 DiaryEntryType::Insight => write!(f, "insight"),
616 }
617 }
618}
619
620fn diary_dir() -> Result<PathBuf, String> {
621 let dir = crate::core::data_dir::lean_ctx_data_dir()?;
622 Ok(dir.join("agents").join("diaries"))
623}
624
625fn sanitize_filename(name: &str) -> String {
626 name.chars()
627 .map(|c| {
628 if c.is_alphanumeric() || c == '-' || c == '_' {
629 c
630 } else {
631 '_'
632 }
633 })
634 .collect()
635}
636
637fn truncate(s: &str, max: usize) -> String {
638 if s.len() <= max {
639 s.to_string()
640 } else {
641 format!("{}...", &s[..s.floor_char_boundary(max.saturating_sub(3))])
642 }
643}
644
645fn agents_dir() -> Result<PathBuf, String> {
646 let dir = crate::core::data_dir::lean_ctx_data_dir()?;
647 Ok(dir.join("agents"))
648}
649
650fn mutate_persistent<T>(mutate: impl FnOnce(&mut AgentRegistry) -> T) -> Result<T, String> {
651 let dir = agents_dir()?;
652 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
653 let _lock = FileLock::acquire(&dir.join("registry.lock"))?;
654 let path = dir.join("registry.json");
655 let mut registry = std::fs::read_to_string(&path)
656 .ok()
657 .and_then(|content| serde_json::from_str(&content).ok())
658 .unwrap_or_default();
659 let result = mutate(&mut registry);
660 let json = serde_json::to_string_pretty(®istry).map_err(|e| e.to_string())?;
661 std::fs::write(path, json).map_err(|e| e.to_string())?;
662 Ok(result)
663}
664
665fn generate_short_id() -> String {
666 use std::collections::hash_map::DefaultHasher;
667 use std::hash::{Hash, Hasher};
668 use std::time::SystemTime;
669
670 let mut hasher = DefaultHasher::new();
671 SystemTime::now().hash(&mut hasher);
672 std::process::id().hash(&mut hasher);
673 format!("{:08x}", hasher.finish() as u32)
674}
675
676pub fn is_process_alive(pid: u32) -> bool {
684 crate::ipc::process::is_alive(pid)
685}
686
687pub(crate) struct FileLock {
688 path: PathBuf,
689}
690
691impl FileLock {
692 pub(crate) fn acquire(path: &std::path::Path) -> Result<Self, String> {
693 for _ in 0..50 {
694 if std::fs::OpenOptions::new()
695 .write(true)
696 .create_new(true)
697 .open(path)
698 .is_ok()
699 {
700 return Ok(Self {
701 path: path.to_path_buf(),
702 });
703 }
704 if let Ok(metadata) = std::fs::metadata(path)
705 && let Ok(modified) = metadata.modified()
706 && modified.elapsed().unwrap_or_default().as_secs() > 5
707 {
708 let _ = std::fs::remove_file(path);
709 continue;
710 }
711 std::thread::sleep(std::time::Duration::from_millis(100));
712 }
713 Err("Could not acquire lock after 5 seconds".to_string())
714 }
715}
716
717impl Drop for FileLock {
718 fn drop(&mut self) {
719 let _ = std::fs::remove_file(&self.path);
720 }
721}
722
723#[derive(Debug, Clone, Serialize, Deserialize)]
724pub struct SharedFact {
725 pub from_agent: String,
726 pub category: String,
727 pub key: String,
728 pub value: String,
729 pub timestamp: DateTime<Utc>,
730 #[serde(default)]
731 pub received_by: Vec<String>,
732}
733
734impl AgentRegistry {
735 pub fn share_knowledge(&mut self, from: &str, category: &str, facts: &[(String, String)]) {
736 for (key, value) in facts {
737 self.scratchpad.push(ScratchpadEntry {
738 id: format!("knowledge-{}", chrono::Utc::now().timestamp_millis()),
739 from_agent: from.to_string(),
740 to_agent: None,
741 task_id: None,
742 category: category.to_string(),
743 priority: MessagePriority::default(),
744 privacy: PrivacyLevel::Team,
745 message: format!("[knowledge] {key}={value}"),
746 metadata: HashMap::new(),
747 project_root: None,
748 timestamp: Utc::now(),
749 read_by: Vec::new(),
750 expires_at: None,
751 });
752 }
753 let shared_path = Self::shared_knowledge_path();
754 let mut existing: Vec<SharedFact> = std::fs::read_to_string(&shared_path)
755 .ok()
756 .and_then(|s| serde_json::from_str(&s).ok())
757 .unwrap_or_default();
758
759 for (key, value) in facts {
760 existing.push(SharedFact {
761 from_agent: from.to_string(),
762 category: category.to_string(),
763 key: key.clone(),
764 value: value.clone(),
765 timestamp: Utc::now(),
766 received_by: Vec::new(),
767 });
768 }
769
770 if existing.len() > 500 {
771 existing.drain(..existing.len() - 500);
772 }
773 if let Ok(json) = serde_json::to_string_pretty(&existing) {
774 let _ = std::fs::write(&shared_path, json);
775 }
776 }
777
778 pub fn receive_shared_knowledge(&mut self, agent_id: &str) -> Vec<SharedFact> {
779 let shared_path = Self::shared_knowledge_path();
780 let mut all: Vec<SharedFact> = std::fs::read_to_string(&shared_path)
781 .ok()
782 .and_then(|s| serde_json::from_str(&s).ok())
783 .unwrap_or_default();
784
785 let mut new_facts = Vec::new();
786 for fact in &mut all {
787 if fact.from_agent != agent_id && !fact.received_by.contains(&agent_id.to_string()) {
788 fact.received_by.push(agent_id.to_string());
789 new_facts.push(fact.clone());
790 }
791 }
792
793 if !new_facts.is_empty()
794 && let Ok(json) = serde_json::to_string_pretty(&all)
795 {
796 let _ = std::fs::write(&shared_path, json);
797 }
798 new_facts
799 }
800
801 fn shared_knowledge_path() -> PathBuf {
802 crate::core::paths::data_dir()
805 .unwrap_or_else(|_| PathBuf::from("."))
806 .join("shared_knowledge.json")
807 }
808}
809
810#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
811#[serde(rename_all = "snake_case")]
812pub enum AgentRole {
813 Coder,
814 Reviewer,
815 Planner,
816 Explorer,
817 Debugger,
818 Tester,
819 Orchestrator,
820}
821
822impl AgentRole {
823 pub fn from_str_loose(s: &str) -> Self {
824 match s.to_lowercase().as_str() {
825 "review" | "reviewer" | "code_review" => Self::Reviewer,
826 "plan" | "planner" | "architect" => Self::Planner,
827 "explore" | "explorer" | "research" => Self::Explorer,
828 "debug" | "debugger" => Self::Debugger,
829 "test" | "tester" | "qa" => Self::Tester,
830 "orchestrator" | "coordinator" | "manager" => Self::Orchestrator,
831 _ => Self::Coder,
832 }
833 }
834}
835
836#[derive(Debug, Clone)]
837pub struct ContextDepthConfig {
838 pub max_files_full: usize,
839 pub max_files_signatures: usize,
840 pub preferred_mode: &'static str,
841 pub include_graph: bool,
842 pub include_knowledge: bool,
843 pub include_gotchas: bool,
844 pub context_budget_ratio: f64,
845}
846
847impl ContextDepthConfig {
848 pub fn for_role(role: AgentRole) -> Self {
849 match role {
850 AgentRole::Coder => Self {
851 max_files_full: 5,
852 max_files_signatures: 15,
853 preferred_mode: "full",
854 include_graph: true,
855 include_knowledge: true,
856 include_gotchas: true,
857 context_budget_ratio: 0.7,
858 },
859 AgentRole::Reviewer => Self {
860 max_files_full: 3,
861 max_files_signatures: 20,
862 preferred_mode: "signatures",
863 include_graph: true,
864 include_knowledge: true,
865 include_gotchas: true,
866 context_budget_ratio: 0.5,
867 },
868 AgentRole::Planner => Self {
869 max_files_full: 1,
870 max_files_signatures: 10,
871 preferred_mode: "map",
872 include_graph: true,
873 include_knowledge: true,
874 include_gotchas: false,
875 context_budget_ratio: 0.3,
876 },
877 AgentRole::Explorer => Self {
878 max_files_full: 2,
879 max_files_signatures: 8,
880 preferred_mode: "map",
881 include_graph: true,
882 include_knowledge: false,
883 include_gotchas: false,
884 context_budget_ratio: 0.4,
885 },
886 AgentRole::Debugger => Self {
887 max_files_full: 8,
888 max_files_signatures: 5,
889 preferred_mode: "full",
890 include_graph: false,
891 include_knowledge: true,
892 include_gotchas: true,
893 context_budget_ratio: 0.8,
894 },
895 AgentRole::Tester => Self {
896 max_files_full: 4,
897 max_files_signatures: 10,
898 preferred_mode: "full",
899 include_graph: false,
900 include_knowledge: false,
901 include_gotchas: true,
902 context_budget_ratio: 0.6,
903 },
904 AgentRole::Orchestrator => Self {
905 max_files_full: 0,
906 max_files_signatures: 5,
907 preferred_mode: "map",
908 include_graph: true,
909 include_knowledge: true,
910 include_gotchas: false,
911 context_budget_ratio: 0.2,
912 },
913 }
914 }
915
916 pub fn mode_for_rank(&self, rank: usize) -> &'static str {
917 if rank < self.max_files_full {
918 "full"
919 } else if rank < self.max_files_full + self.max_files_signatures {
920 "signatures"
921 } else {
922 "map"
923 }
924 }
925}
926
927impl From<ScratchpadEntry> for A2AMessage {
928 fn from(entry: ScratchpadEntry) -> Self {
929 Self {
930 id: entry.id,
931 from_agent: entry.from_agent,
932 to_agent: entry.to_agent,
933 task_id: entry.task_id,
934 category: MessageCategory::parse_str(&entry.category),
935 priority: entry.priority,
936 privacy: entry.privacy,
937 content: entry.message,
938 metadata: entry.metadata,
939 project_root: entry.project_root,
940 timestamp: entry.timestamp,
941 read_by: entry.read_by,
942 expires_at: entry.expires_at,
943 }
944 }
945}
946
947impl From<A2AMessage> for ScratchpadEntry {
948 fn from(msg: A2AMessage) -> Self {
949 Self {
950 id: msg.id,
951 from_agent: msg.from_agent,
952 to_agent: msg.to_agent,
953 task_id: msg.task_id,
954 category: msg.category.to_string(),
955 priority: msg.priority,
956 privacy: msg.privacy,
957 message: msg.content,
958 metadata: msg.metadata,
959 project_root: msg.project_root,
960 timestamp: msg.timestamp,
961 read_by: msg.read_by,
962 expires_at: msg.expires_at,
963 }
964 }
965}
966
967#[cfg(test)]
968mod tests {
969 use super::*;
970
971 #[test]
972 fn register_and_list() {
973 let mut reg = AgentRegistry::new();
974 let id = reg.register("cursor", Some("dev"), "/tmp/project");
975 assert!(!id.is_empty());
976 assert_eq!(reg.list_active(None).len(), 1);
977 assert_eq!(reg.list_active(None)[0].agent_type, "cursor");
978 }
979
980 #[test]
981 fn reregister_same_pid() {
982 let mut reg = AgentRegistry::new();
983 let id1 = reg.register("cursor", Some("dev"), "/tmp/project");
984 let id2 = reg.register("cursor", Some("review"), "/tmp/project");
985 assert_eq!(id1, id2);
986 assert_eq!(reg.agents.len(), 1);
987 assert_eq!(reg.agents[0].role, Some("review".to_string()));
988 }
989
990 #[test]
991 fn post_and_read_messages() {
992 let mut reg = AgentRegistry::new();
993 reg.post_message("agent-a", None, "finding", "Found a bug in auth.rs");
994 reg.post_message("agent-b", Some("agent-a"), "request", "Please review");
995
996 let msgs = reg.read_unread("agent-a");
997 assert_eq!(msgs.len(), 1);
998 assert_eq!(msgs[0].category, "request");
999 }
1000
1001 #[test]
1002 fn set_status() {
1003 let mut reg = AgentRegistry::new();
1004 let id = reg.register("claude", None, "/tmp/project");
1005 reg.set_status(&id, AgentStatus::Idle, Some("waiting for review"));
1006 assert_eq!(reg.agents[0].status, AgentStatus::Idle);
1007 assert_eq!(
1008 reg.agents[0].status_message,
1009 Some("waiting for review".to_string())
1010 );
1011 }
1012
1013 #[test]
1014 fn broadcast_message() {
1015 let mut reg = AgentRegistry::new();
1016 reg.post_message("agent-a", None, "status", "Starting refactor");
1017
1018 let msgs_b = reg.read_unread("agent-b");
1019 assert_eq!(msgs_b.len(), 1);
1020 assert_eq!(msgs_b[0].message, "Starting refactor");
1021
1022 let msgs_a = reg.read_unread("agent-a");
1023 assert!(msgs_a.is_empty());
1024 }
1025
1026 #[test]
1027 fn diary_add_and_format() {
1028 let mut diary = AgentDiary::new("test-agent-001", "cursor", "/tmp/project");
1029 diary.add_entry(
1030 DiaryEntryType::Discovery,
1031 "Found auth module at src/auth.rs",
1032 Some("auth"),
1033 );
1034 diary.add_entry(
1035 DiaryEntryType::Decision,
1036 "Use JWT RS256 for token signing",
1037 None,
1038 );
1039 diary.add_entry(
1040 DiaryEntryType::Progress,
1041 "Implemented login endpoint",
1042 Some("auth"),
1043 );
1044
1045 assert_eq!(diary.entries.len(), 3);
1046
1047 let summary = diary.format_summary();
1048 assert!(summary.contains("test-agent-001"));
1049 assert!(summary.contains("FOUND"));
1050 assert!(summary.contains("DECIDED"));
1051 assert!(summary.contains("DONE"));
1052 }
1053
1054 #[test]
1055 fn diary_compact_format() {
1056 let mut diary = AgentDiary::new("test-agent-002", "claude", "/tmp/project");
1057 diary.add_entry(DiaryEntryType::Insight, "DB queries are N+1", None);
1058 diary.add_entry(
1059 DiaryEntryType::Blocker,
1060 "Missing API credentials",
1061 Some("deploy"),
1062 );
1063
1064 let compact = diary.format_compact();
1065 assert!(compact.contains("diary:test-agent-002"));
1066 assert!(compact.contains("B:Missing API credentials"));
1067 assert!(compact.contains("I:DB queries are N+1"));
1068 }
1069
1070 #[test]
1071 fn diary_entry_types() {
1072 let types = vec![
1073 DiaryEntryType::Discovery,
1074 DiaryEntryType::Decision,
1075 DiaryEntryType::Blocker,
1076 DiaryEntryType::Progress,
1077 DiaryEntryType::Insight,
1078 ];
1079 for t in types {
1080 assert!(!format!("{t}").is_empty());
1081 }
1082 }
1083
1084 #[test]
1085 fn diary_truncation() {
1086 let mut diary = AgentDiary::new("test-agent", "cursor", "/tmp");
1087 for i in 0..150 {
1088 diary.add_entry(DiaryEntryType::Progress, &format!("Step {i}"), None);
1089 }
1090 assert!(diary.entries.len() <= 100);
1091 }
1092
1093 #[test]
1094 fn truncate_utf8_emoji_no_panic() {
1095 let result = truncate("Agent 🤖 Name ist lang genug", 15);
1096 assert!(result.ends_with("..."));
1097 }
1098
1099 #[test]
1100 fn truncate_utf8_cyrillic_no_panic() {
1101 let result = truncate("агент выполняет длинную задачу", 15);
1102 assert!(result.ends_with("..."));
1103 }
1104
1105 #[test]
1106 fn truncate_short_utf8_unchanged() {
1107 assert_eq!(truncate("短い", 20), "短い");
1108 }
1109
1110 fn test_entry(agent_id: &str, project_root: &str, pid: u32) -> AgentEntry {
1111 let now = Utc::now();
1112 AgentEntry {
1113 agent_id: agent_id.to_string(),
1114 agent_type: "cursor".to_string(),
1115 role: Some("dev".to_string()),
1116 project_root: project_root.to_string(),
1117 started_at: now,
1118 last_active: now,
1119 pid,
1120 status: AgentStatus::Active,
1121 status_message: None,
1122 }
1123 }
1124
1125 #[test]
1129 fn list_active_scopes_to_project_root() {
1130 let mut reg = AgentRegistry::new();
1131 reg.agents
1132 .push(test_entry("a-1", "/proj/a", std::process::id()));
1133 reg.agents
1134 .push(test_entry("b-1", "/proj/b", std::process::id()));
1135
1136 let active_a = reg.list_active(Some("/proj/a"));
1137 assert_eq!(active_a.len(), 1);
1138 assert_eq!(active_a[0].agent_id, "a-1");
1139
1140 assert_eq!(reg.list_active(None).len(), 2);
1142 }
1143
1144 #[test]
1151 fn cleanup_stale_prunes_dead_pid_from_active_list() {
1152 let reaped = {
1154 let mut cmd = if cfg!(windows) {
1155 let mut c = std::process::Command::new("cmd");
1156 c.args(["/C", "exit"]);
1157 c
1158 } else {
1159 std::process::Command::new("true")
1160 };
1161 let mut child = cmd.spawn().expect("spawn short-lived helper process");
1162 let pid = child.id();
1163 child.wait().expect("reap helper process");
1164 pid
1165 };
1166
1167 let mut reg = AgentRegistry::new();
1168 reg.agents.push(test_entry("ghost", "/proj/a", reaped));
1169 reg.agents
1170 .push(test_entry("live", "/proj/a", std::process::id()));
1171
1172 reg.cleanup_stale(24);
1173
1174 let ids: Vec<&str> = reg
1175 .list_active(Some("/proj/a"))
1176 .iter()
1177 .map(|a| a.agent_id.as_str())
1178 .collect();
1179 assert!(ids.contains(&"live"), "live same-project agent must remain");
1180 assert!(
1181 !ids.contains(&"ghost"),
1182 "dead-pid agent must be pruned from the active list (#419)"
1183 );
1184 }
1185
1186 #[test]
1193 fn mutate_locked_survives_concurrent_writers() {
1194 let _iso = crate::core::data_dir::isolated_data_dir();
1195
1196 let handles: Vec<_> = (0..8)
1197 .map(|i| {
1198 std::thread::spawn(move || {
1199 AgentRegistry::mutate_locked(|registry| {
1200 registry.agents.push(AgentEntry {
1201 agent_id: format!("agent-{i}"),
1202 agent_type: "test".to_string(),
1203 role: None,
1204 project_root: "/tmp/project".to_string(),
1205 started_at: Utc::now(),
1206 last_active: Utc::now(),
1207 pid: 10_000 + i,
1208 status: AgentStatus::Active,
1209 status_message: None,
1210 });
1211 })
1212 })
1213 })
1214 .collect();
1215
1216 for h in handles {
1217 h.join()
1218 .expect("writer thread must not panic")
1219 .expect("mutate_locked must succeed");
1220 }
1221
1222 let registry = AgentRegistry::load_or_create();
1223 assert_eq!(
1224 registry.agents.len(),
1225 8,
1226 "all 8 concurrent registrations must survive, got {}",
1227 registry.agents.len()
1228 );
1229 }
1230}
1231
1232#[cfg(test)]
1233mod presence_tests {
1234 use super::*;
1235
1236 #[test]
1237 fn persistent_presence_preserves_multiple_processes_and_lifecycle() {
1238 let isolated = crate::core::data_dir::isolated_data_dir();
1239 let mut registry = AgentRegistry::new();
1240 let first = registry.register_process("mcp", Some("context-engine"), "/project", 101);
1241 let second = registry.register_process("mcp", Some("context-engine"), "/project", 202);
1242 registry.save().expect("save registry");
1243
1244 assert_ne!(first, second);
1245 assert_eq!(AgentRegistry::load().expect("registry").agents.len(), 2);
1246
1247 AgentRegistry::heartbeat_persistent(&first).expect("heartbeat");
1248 AgentRegistry::finish_persistent(&second).expect("finish");
1249 let loaded = AgentRegistry::load().expect("registry");
1250 assert_eq!(
1251 loaded
1252 .agents
1253 .iter()
1254 .find(|agent| agent.agent_id == second)
1255 .expect("second agent")
1256 .status,
1257 AgentStatus::Finished
1258 );
1259 assert!(isolated.path().join("agents/registry.json").exists());
1260 }
1261
1262 #[test]
1263 fn reregistering_process_refreshes_metadata_without_duplication() {
1264 let mut registry = AgentRegistry::new();
1265 let first = registry.register_process("unknown", None, "/old", 303);
1266 let second = registry.register_process("mcp", Some("context-engine"), "/new", 303);
1267
1268 assert_eq!(first, second);
1269 assert_eq!(registry.agents.len(), 1);
1270 assert_eq!(registry.agents[0].agent_type, "mcp");
1271 assert_eq!(registry.agents[0].project_root, "/new");
1272 assert_eq!(registry.agents[0].role.as_deref(), Some("context-engine"));
1273 }
1274
1275 #[test]
1276 fn logical_sessions_are_keyed_independently_of_transport_processes() {
1277 let mut registry = AgentRegistry::new();
1278 registry.register_process("mcp", Some("context-engine"), "/project", 303);
1279 registry.open_or_heartbeat_logical_session("vscode", "/project", "chat-a");
1280 registry.open_or_heartbeat_logical_session("vscode", "/project", "chat-b");
1281 let opened_at = registry.logical_sessions[0].opened_at;
1282
1283 registry.open_or_heartbeat_logical_session("vscode", "/project", "chat-a");
1284
1285 assert_eq!(registry.agents.len(), 1);
1286 assert_eq!(registry.logical_sessions.len(), 2);
1287 assert_eq!(registry.logical_sessions[0].opened_at, opened_at);
1288 assert!(registry.logical_session_telemetry_seen);
1289 assert!(registry.close_logical_session("vscode", "/project", "chat-b"));
1290 assert_eq!(registry.logical_sessions.len(), 1);
1291 }
1292
1293 #[test]
1294 fn logical_session_expiry_is_bounded_by_heartbeat_not_tool_activity() {
1295 let mut registry = AgentRegistry::new();
1296 registry.open_or_heartbeat_logical_session("vscode", "/project", "chat-a");
1297 registry.logical_sessions[0].last_heartbeat = Utc::now() - chrono::Duration::seconds(181);
1298
1299 registry.cleanup_stale_logical_sessions(180);
1300
1301 assert!(registry.logical_sessions.is_empty());
1302 assert!(registry.logical_session_telemetry_seen);
1303 }
1304
1305 #[test]
1306 fn legacy_registry_deserializes_without_claiming_session_telemetry() {
1307 let registry: AgentRegistry = serde_json::from_str(
1308 r#"{"agents":[],"scratchpad":[],"updated_at":"2026-01-01T00:00:00Z"}"#,
1309 )
1310 .expect("legacy registry");
1311
1312 assert!(registry.logical_sessions.is_empty());
1313 assert!(!registry.logical_session_telemetry_seen);
1314 }
1315}