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