Skip to main content

lean_ctx/core/
agents.rs

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            // Mark as finished if process is no longer running (regardless of age)
285            if !is_process_alive(agent.pid) {
286                agent.status = AgentStatus::Finished;
287            }
288        }
289
290        // Remove finished agents older than the cutoff to keep recent history visible.
291        // Drop each retired agent's budget entry too — a finished/dead agent can't read
292        // again, so removing its budget loses no live enforcement and bounds BUDGETS.
293        self.agents.retain(|a| {
294            let retire = a.status == AgentStatus::Finished && a.last_active < cutoff;
295            if retire {
296                crate::core::agent_budget::remove(&a.agent_id);
297            }
298            !retire
299        });
300
301        self.updated_at = Utc::now();
302    }
303
304    pub fn save(&self) -> Result<(), String> {
305        let dir = agents_dir()?;
306        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
307
308        let path = dir.join("registry.json");
309        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
310
311        let lock_path = dir.join("registry.lock");
312        let _lock = FileLock::acquire(&lock_path)?;
313
314        std::fs::write(&path, json).map_err(|e| e.to_string())
315    }
316
317    pub fn load() -> Option<Self> {
318        let dir = agents_dir().ok()?;
319        let path = dir.join("registry.json");
320        let content = std::fs::read_to_string(&path).ok()?;
321        serde_json::from_str(&content).ok()
322    }
323
324    pub fn load_or_create() -> Self {
325        Self::load().unwrap_or_default()
326    }
327}
328
329impl Default for AgentRegistry {
330    fn default() -> Self {
331        Self::new()
332    }
333}
334
335impl AgentDiary {
336    pub fn new(agent_id: &str, agent_type: &str, project_root: &str) -> Self {
337        let now = Utc::now();
338        Self {
339            agent_id: agent_id.to_string(),
340            agent_type: agent_type.to_string(),
341            project_root: project_root.to_string(),
342            entries: Vec::new(),
343            created_at: now,
344            updated_at: now,
345        }
346    }
347
348    pub fn add_entry(&mut self, entry_type: DiaryEntryType, content: &str, context: Option<&str>) {
349        self.entries.push(DiaryEntry {
350            entry_type,
351            content: content.to_string(),
352            context: context.map(std::string::ToString::to_string),
353            timestamp: Utc::now(),
354        });
355        if self.entries.len() > MAX_DIARY_ENTRIES {
356            self.entries
357                .drain(0..self.entries.len() - MAX_DIARY_ENTRIES);
358        }
359        self.updated_at = Utc::now();
360    }
361
362    pub fn format_summary(&self) -> String {
363        if self.entries.is_empty() {
364            return format!("Diary [{}]: empty", self.agent_id);
365        }
366        let mut out = format!(
367            "Diary [{}] ({} entries):\n",
368            self.agent_id,
369            self.entries.len()
370        );
371        for e in self.entries.iter().rev().take(10) {
372            let age = (Utc::now() - e.timestamp).num_minutes();
373            let prefix = match e.entry_type {
374                DiaryEntryType::Discovery => "FOUND",
375                DiaryEntryType::Decision => "DECIDED",
376                DiaryEntryType::Blocker => "BLOCKED",
377                DiaryEntryType::Progress => "DONE",
378                DiaryEntryType::Insight => "INSIGHT",
379            };
380            let ctx = e
381                .context
382                .as_deref()
383                .map(|c| format!(" [{c}]"))
384                .unwrap_or_default();
385            out.push_str(&format!("  [{prefix}] {}{ctx} ({age}m ago)\n", e.content));
386        }
387        out
388    }
389
390    pub fn format_compact(&self) -> String {
391        if self.entries.is_empty() {
392            return String::new();
393        }
394        let items: Vec<String> = self
395            .entries
396            .iter()
397            .rev()
398            .take(5)
399            .map(|e| {
400                let prefix = match e.entry_type {
401                    DiaryEntryType::Discovery => "F",
402                    DiaryEntryType::Decision => "D",
403                    DiaryEntryType::Blocker => "B",
404                    DiaryEntryType::Progress => "P",
405                    DiaryEntryType::Insight => "I",
406                };
407                format!("{prefix}:{}", truncate(&e.content, 50))
408            })
409            .collect();
410        format!("diary:{}|{}", self.agent_id, items.join("|"))
411    }
412
413    pub fn save(&self) -> Result<(), String> {
414        let dir = diary_dir()?;
415        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
416        let path = dir.join(format!("{}.json", sanitize_filename(&self.agent_id)));
417        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
418        std::fs::write(&path, json).map_err(|e| e.to_string())
419    }
420
421    pub fn load(agent_id: &str) -> Option<Self> {
422        let dir = diary_dir().ok()?;
423        let path = dir.join(format!("{}.json", sanitize_filename(agent_id)));
424        let content = std::fs::read_to_string(&path).ok()?;
425        serde_json::from_str(&content).ok()
426    }
427
428    pub fn load_or_create(agent_id: &str, agent_type: &str, project_root: &str) -> Self {
429        Self::load(agent_id).unwrap_or_else(|| Self::new(agent_id, agent_type, project_root))
430    }
431
432    pub fn list_all() -> Vec<(String, usize, DateTime<Utc>)> {
433        let Ok(dir) = diary_dir() else {
434            return Vec::new();
435        };
436        if !dir.exists() {
437            return Vec::new();
438        }
439        let mut results = Vec::new();
440        if let Ok(entries) = std::fs::read_dir(&dir) {
441            for entry in entries.flatten() {
442                if entry.path().extension().and_then(|e| e.to_str()) == Some("json") {
443                    if let Ok(content) = std::fs::read_to_string(entry.path()) {
444                        if let Ok(diary) = serde_json::from_str::<AgentDiary>(&content) {
445                            results.push((diary.agent_id, diary.entries.len(), diary.updated_at));
446                        }
447                    }
448                }
449            }
450        }
451        results.sort_by_key(|x| std::cmp::Reverse(x.2));
452        results
453    }
454
455    /// Load every diary whose `project_root` matches `project_root`, most
456    /// recently updated first. Used by skillify to mine a project's decisions
457    /// and insights across all its agents (#290).
458    pub fn load_all_for_project(project_root: &str) -> Vec<AgentDiary> {
459        let Ok(dir) = diary_dir() else {
460            return Vec::new();
461        };
462        if !dir.exists() {
463            return Vec::new();
464        }
465        let want = project_root.trim_end_matches('/');
466        let mut diaries: Vec<AgentDiary> = Vec::new();
467        if let Ok(entries) = std::fs::read_dir(&dir) {
468            for entry in entries.flatten() {
469                if entry.path().extension().and_then(|e| e.to_str()) != Some("json") {
470                    continue;
471                }
472                if let Ok(content) = std::fs::read_to_string(entry.path()) {
473                    if let Ok(diary) = serde_json::from_str::<AgentDiary>(&content) {
474                        if diary.project_root.trim_end_matches('/') == want {
475                            diaries.push(diary);
476                        }
477                    }
478                }
479            }
480        }
481        diaries.sort_by_key(|d| std::cmp::Reverse(d.updated_at));
482        diaries
483    }
484}
485
486impl std::fmt::Display for DiaryEntryType {
487    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
488        match self {
489            DiaryEntryType::Discovery => write!(f, "discovery"),
490            DiaryEntryType::Decision => write!(f, "decision"),
491            DiaryEntryType::Blocker => write!(f, "blocker"),
492            DiaryEntryType::Progress => write!(f, "progress"),
493            DiaryEntryType::Insight => write!(f, "insight"),
494        }
495    }
496}
497
498fn diary_dir() -> Result<PathBuf, String> {
499    let dir = crate::core::data_dir::lean_ctx_data_dir()?;
500    Ok(dir.join("agents").join("diaries"))
501}
502
503fn sanitize_filename(name: &str) -> String {
504    name.chars()
505        .map(|c| {
506            if c.is_alphanumeric() || c == '-' || c == '_' {
507                c
508            } else {
509                '_'
510            }
511        })
512        .collect()
513}
514
515fn truncate(s: &str, max: usize) -> String {
516    if s.len() <= max {
517        s.to_string()
518    } else {
519        format!("{}...", &s[..s.floor_char_boundary(max.saturating_sub(3))])
520    }
521}
522
523fn agents_dir() -> Result<PathBuf, String> {
524    let dir = crate::core::data_dir::lean_ctx_data_dir()?;
525    Ok(dir.join("agents"))
526}
527
528fn generate_short_id() -> String {
529    use std::collections::hash_map::DefaultHasher;
530    use std::hash::{Hash, Hasher};
531    use std::time::SystemTime;
532
533    let mut hasher = DefaultHasher::new();
534    SystemTime::now().hash(&mut hasher);
535    std::process::id().hash(&mut hasher);
536    format!("{:08x}", hasher.finish() as u32)
537}
538
539pub fn is_process_alive(pid: u32) -> bool {
540    #[cfg(unix)]
541    {
542        std::process::Command::new("kill")
543            .args(["-0", &pid.to_string()])
544            .output()
545            .is_ok_and(|o| o.status.success())
546    }
547    #[cfg(not(unix))]
548    {
549        let _ = pid;
550        true
551    }
552}
553
554pub(crate) struct FileLock {
555    path: PathBuf,
556}
557
558impl FileLock {
559    pub(crate) fn acquire(path: &std::path::Path) -> Result<Self, String> {
560        for _ in 0..50 {
561            if std::fs::OpenOptions::new()
562                .write(true)
563                .create_new(true)
564                .open(path)
565                .is_ok()
566            {
567                return Ok(Self {
568                    path: path.to_path_buf(),
569                });
570            }
571            if let Ok(metadata) = std::fs::metadata(path) {
572                if let Ok(modified) = metadata.modified() {
573                    if modified.elapsed().unwrap_or_default().as_secs() > 5 {
574                        let _ = std::fs::remove_file(path);
575                        continue;
576                    }
577                }
578            }
579            std::thread::sleep(std::time::Duration::from_millis(100));
580        }
581        Err("Could not acquire lock after 5 seconds".to_string())
582    }
583}
584
585impl Drop for FileLock {
586    fn drop(&mut self) {
587        let _ = std::fs::remove_file(&self.path);
588    }
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize)]
592pub struct SharedFact {
593    pub from_agent: String,
594    pub category: String,
595    pub key: String,
596    pub value: String,
597    pub timestamp: DateTime<Utc>,
598    #[serde(default)]
599    pub received_by: Vec<String>,
600}
601
602impl AgentRegistry {
603    pub fn share_knowledge(&mut self, from: &str, category: &str, facts: &[(String, String)]) {
604        for (key, value) in facts {
605            self.scratchpad.push(ScratchpadEntry {
606                id: format!("knowledge-{}", chrono::Utc::now().timestamp_millis()),
607                from_agent: from.to_string(),
608                to_agent: None,
609                task_id: None,
610                category: category.to_string(),
611                priority: MessagePriority::default(),
612                privacy: PrivacyLevel::Team,
613                message: format!("[knowledge] {key}={value}"),
614                metadata: HashMap::new(),
615                project_root: None,
616                timestamp: Utc::now(),
617                read_by: Vec::new(),
618                expires_at: None,
619            });
620        }
621        let shared_path = Self::shared_knowledge_path();
622        let mut existing: Vec<SharedFact> = std::fs::read_to_string(&shared_path)
623            .ok()
624            .and_then(|s| serde_json::from_str(&s).ok())
625            .unwrap_or_default();
626
627        for (key, value) in facts {
628            existing.push(SharedFact {
629                from_agent: from.to_string(),
630                category: category.to_string(),
631                key: key.clone(),
632                value: value.clone(),
633                timestamp: Utc::now(),
634                received_by: Vec::new(),
635            });
636        }
637
638        if existing.len() > 500 {
639            existing.drain(..existing.len() - 500);
640        }
641        if let Ok(json) = serde_json::to_string_pretty(&existing) {
642            let _ = std::fs::write(&shared_path, json);
643        }
644    }
645
646    pub fn receive_shared_knowledge(&mut self, agent_id: &str) -> Vec<SharedFact> {
647        let shared_path = Self::shared_knowledge_path();
648        let mut all: Vec<SharedFact> = std::fs::read_to_string(&shared_path)
649            .ok()
650            .and_then(|s| serde_json::from_str(&s).ok())
651            .unwrap_or_default();
652
653        let mut new_facts = Vec::new();
654        for fact in &mut all {
655            if fact.from_agent != agent_id && !fact.received_by.contains(&agent_id.to_string()) {
656                fact.received_by.push(agent_id.to_string());
657                new_facts.push(fact.clone());
658            }
659        }
660
661        if !new_facts.is_empty() {
662            if let Ok(json) = serde_json::to_string_pretty(&all) {
663                let _ = std::fs::write(&shared_path, json);
664            }
665        }
666        new_facts
667    }
668
669    fn shared_knowledge_path() -> PathBuf {
670        dirs::home_dir()
671            .unwrap_or_else(|| PathBuf::from("."))
672            .join(".lean-ctx")
673            .join("shared_knowledge.json")
674    }
675}
676
677#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
678#[serde(rename_all = "snake_case")]
679pub enum AgentRole {
680    Coder,
681    Reviewer,
682    Planner,
683    Explorer,
684    Debugger,
685    Tester,
686    Orchestrator,
687}
688
689impl AgentRole {
690    pub fn from_str_loose(s: &str) -> Self {
691        match s.to_lowercase().as_str() {
692            "review" | "reviewer" | "code_review" => Self::Reviewer,
693            "plan" | "planner" | "architect" => Self::Planner,
694            "explore" | "explorer" | "research" => Self::Explorer,
695            "debug" | "debugger" => Self::Debugger,
696            "test" | "tester" | "qa" => Self::Tester,
697            "orchestrator" | "coordinator" | "manager" => Self::Orchestrator,
698            _ => Self::Coder,
699        }
700    }
701}
702
703#[derive(Debug, Clone)]
704pub struct ContextDepthConfig {
705    pub max_files_full: usize,
706    pub max_files_signatures: usize,
707    pub preferred_mode: &'static str,
708    pub include_graph: bool,
709    pub include_knowledge: bool,
710    pub include_gotchas: bool,
711    pub context_budget_ratio: f64,
712}
713
714impl ContextDepthConfig {
715    pub fn for_role(role: AgentRole) -> Self {
716        match role {
717            AgentRole::Coder => Self {
718                max_files_full: 5,
719                max_files_signatures: 15,
720                preferred_mode: "full",
721                include_graph: true,
722                include_knowledge: true,
723                include_gotchas: true,
724                context_budget_ratio: 0.7,
725            },
726            AgentRole::Reviewer => Self {
727                max_files_full: 3,
728                max_files_signatures: 20,
729                preferred_mode: "signatures",
730                include_graph: true,
731                include_knowledge: true,
732                include_gotchas: true,
733                context_budget_ratio: 0.5,
734            },
735            AgentRole::Planner => Self {
736                max_files_full: 1,
737                max_files_signatures: 10,
738                preferred_mode: "map",
739                include_graph: true,
740                include_knowledge: true,
741                include_gotchas: false,
742                context_budget_ratio: 0.3,
743            },
744            AgentRole::Explorer => Self {
745                max_files_full: 2,
746                max_files_signatures: 8,
747                preferred_mode: "map",
748                include_graph: true,
749                include_knowledge: false,
750                include_gotchas: false,
751                context_budget_ratio: 0.4,
752            },
753            AgentRole::Debugger => Self {
754                max_files_full: 8,
755                max_files_signatures: 5,
756                preferred_mode: "full",
757                include_graph: false,
758                include_knowledge: true,
759                include_gotchas: true,
760                context_budget_ratio: 0.8,
761            },
762            AgentRole::Tester => Self {
763                max_files_full: 4,
764                max_files_signatures: 10,
765                preferred_mode: "full",
766                include_graph: false,
767                include_knowledge: false,
768                include_gotchas: true,
769                context_budget_ratio: 0.6,
770            },
771            AgentRole::Orchestrator => Self {
772                max_files_full: 0,
773                max_files_signatures: 5,
774                preferred_mode: "map",
775                include_graph: true,
776                include_knowledge: true,
777                include_gotchas: false,
778                context_budget_ratio: 0.2,
779            },
780        }
781    }
782
783    pub fn mode_for_rank(&self, rank: usize) -> &'static str {
784        if rank < self.max_files_full {
785            "full"
786        } else if rank < self.max_files_full + self.max_files_signatures {
787            "signatures"
788        } else {
789            "map"
790        }
791    }
792}
793
794impl From<ScratchpadEntry> for A2AMessage {
795    fn from(entry: ScratchpadEntry) -> Self {
796        Self {
797            id: entry.id,
798            from_agent: entry.from_agent,
799            to_agent: entry.to_agent,
800            task_id: entry.task_id,
801            category: MessageCategory::parse_str(&entry.category),
802            priority: entry.priority,
803            privacy: entry.privacy,
804            content: entry.message,
805            metadata: entry.metadata,
806            project_root: entry.project_root,
807            timestamp: entry.timestamp,
808            read_by: entry.read_by,
809            expires_at: entry.expires_at,
810        }
811    }
812}
813
814impl From<A2AMessage> for ScratchpadEntry {
815    fn from(msg: A2AMessage) -> Self {
816        Self {
817            id: msg.id,
818            from_agent: msg.from_agent,
819            to_agent: msg.to_agent,
820            task_id: msg.task_id,
821            category: msg.category.to_string(),
822            priority: msg.priority,
823            privacy: msg.privacy,
824            message: msg.content,
825            metadata: msg.metadata,
826            project_root: msg.project_root,
827            timestamp: msg.timestamp,
828            read_by: msg.read_by,
829            expires_at: msg.expires_at,
830        }
831    }
832}
833
834#[cfg(test)]
835mod tests {
836    use super::*;
837
838    #[test]
839    fn register_and_list() {
840        let mut reg = AgentRegistry::new();
841        let id = reg.register("cursor", Some("dev"), "/tmp/project");
842        assert!(!id.is_empty());
843        assert_eq!(reg.list_active(None).len(), 1);
844        assert_eq!(reg.list_active(None)[0].agent_type, "cursor");
845    }
846
847    #[test]
848    fn reregister_same_pid() {
849        let mut reg = AgentRegistry::new();
850        let id1 = reg.register("cursor", Some("dev"), "/tmp/project");
851        let id2 = reg.register("cursor", Some("review"), "/tmp/project");
852        assert_eq!(id1, id2);
853        assert_eq!(reg.agents.len(), 1);
854        assert_eq!(reg.agents[0].role, Some("review".to_string()));
855    }
856
857    #[test]
858    fn post_and_read_messages() {
859        let mut reg = AgentRegistry::new();
860        reg.post_message("agent-a", None, "finding", "Found a bug in auth.rs");
861        reg.post_message("agent-b", Some("agent-a"), "request", "Please review");
862
863        let msgs = reg.read_unread("agent-a");
864        assert_eq!(msgs.len(), 1);
865        assert_eq!(msgs[0].category, "request");
866    }
867
868    #[test]
869    fn set_status() {
870        let mut reg = AgentRegistry::new();
871        let id = reg.register("claude", None, "/tmp/project");
872        reg.set_status(&id, AgentStatus::Idle, Some("waiting for review"));
873        assert_eq!(reg.agents[0].status, AgentStatus::Idle);
874        assert_eq!(
875            reg.agents[0].status_message,
876            Some("waiting for review".to_string())
877        );
878    }
879
880    #[test]
881    fn broadcast_message() {
882        let mut reg = AgentRegistry::new();
883        reg.post_message("agent-a", None, "status", "Starting refactor");
884
885        let msgs_b = reg.read_unread("agent-b");
886        assert_eq!(msgs_b.len(), 1);
887        assert_eq!(msgs_b[0].message, "Starting refactor");
888
889        let msgs_a = reg.read_unread("agent-a");
890        assert!(msgs_a.is_empty());
891    }
892
893    #[test]
894    fn diary_add_and_format() {
895        let mut diary = AgentDiary::new("test-agent-001", "cursor", "/tmp/project");
896        diary.add_entry(
897            DiaryEntryType::Discovery,
898            "Found auth module at src/auth.rs",
899            Some("auth"),
900        );
901        diary.add_entry(
902            DiaryEntryType::Decision,
903            "Use JWT RS256 for token signing",
904            None,
905        );
906        diary.add_entry(
907            DiaryEntryType::Progress,
908            "Implemented login endpoint",
909            Some("auth"),
910        );
911
912        assert_eq!(diary.entries.len(), 3);
913
914        let summary = diary.format_summary();
915        assert!(summary.contains("test-agent-001"));
916        assert!(summary.contains("FOUND"));
917        assert!(summary.contains("DECIDED"));
918        assert!(summary.contains("DONE"));
919    }
920
921    #[test]
922    fn diary_compact_format() {
923        let mut diary = AgentDiary::new("test-agent-002", "claude", "/tmp/project");
924        diary.add_entry(DiaryEntryType::Insight, "DB queries are N+1", None);
925        diary.add_entry(
926            DiaryEntryType::Blocker,
927            "Missing API credentials",
928            Some("deploy"),
929        );
930
931        let compact = diary.format_compact();
932        assert!(compact.contains("diary:test-agent-002"));
933        assert!(compact.contains("B:Missing API credentials"));
934        assert!(compact.contains("I:DB queries are N+1"));
935    }
936
937    #[test]
938    fn diary_entry_types() {
939        let types = vec![
940            DiaryEntryType::Discovery,
941            DiaryEntryType::Decision,
942            DiaryEntryType::Blocker,
943            DiaryEntryType::Progress,
944            DiaryEntryType::Insight,
945        ];
946        for t in types {
947            assert!(!format!("{t}").is_empty());
948        }
949    }
950
951    #[test]
952    fn diary_truncation() {
953        let mut diary = AgentDiary::new("test-agent", "cursor", "/tmp");
954        for i in 0..150 {
955            diary.add_entry(DiaryEntryType::Progress, &format!("Step {i}"), None);
956        }
957        assert!(diary.entries.len() <= 100);
958    }
959
960    #[test]
961    fn truncate_utf8_emoji_no_panic() {
962        let result = truncate("Agent 🤖 Name ist lang genug", 15);
963        assert!(result.ends_with("..."));
964    }
965
966    #[test]
967    fn truncate_utf8_cyrillic_no_panic() {
968        let result = truncate("агент выполняет длинную задачу", 15);
969        assert!(result.ends_with("..."));
970    }
971
972    #[test]
973    fn truncate_short_utf8_unchanged() {
974        assert_eq!(truncate("短い", 20), "短い");
975    }
976}