Files
clawhdf5/crates/clawhdf5-agent/src/agents_md.rs
T

313 lines
10 KiB
Rust

//! AGENTS.md generation from live HDF5Memory state.
//!
//! Produces a structured Markdown self-description of the agent's memory file,
//! suitable for session initialization or introspection.
use crate::MemoryConfig;
use crate::cache::MemoryCache;
use crate::knowledge::KnowledgeCache;
use crate::session::SessionCache;
/// Generate AGENTS.md content from memory state components.
pub fn generate(
config: &MemoryConfig,
cache: &MemoryCache,
sessions: &SessionCache,
knowledge: &KnowledgeCache,
) -> String {
let mut md = String::with_capacity(2048);
// Header
md.push_str(&format!("# Agent Memory — {}\n\n", config.agent_id));
// Identity section
md.push_str("## Identity\n");
md.push_str(&format!("- Agent ID: {}\n", config.agent_id));
md.push_str(&format!("- Memory file: {}\n", config.path.display()));
md.push_str("- Schema version: 1.0\n");
md.push_str(&format!("- Created: {}\n", config.created_at));
md.push_str(&format!(
"- Embedder: {} ({}d)\n",
config.embedder, config.embedding_dim
));
md.push('\n');
// Memory Store section
let active = cache.count_active();
let total = cache.len();
let tombstoned = total - active;
let compression = if config.compression {
format!("gzip({})", config.compression_level)
} else {
"none".to_owned()
};
md.push_str("## Memory Store\n");
md.push_str(&format!("- Active memories: {active}\n"));
md.push_str(&format!("- Deleted (tombstoned): {tombstoned}\n"));
md.push_str(&format!("- Total entries: {total}\n"));
md.push_str(&format!(
"- Storage: {}, compression={compression}\n",
if config.float16 { "float16" } else { "float32" }
));
md.push('\n');
// Sessions section
let session_count = sessions.len();
let latest = sessions.latest_session_id().unwrap_or("none").to_owned();
md.push_str("## Sessions\n");
md.push_str(&format!("- Total sessions: {session_count}\n"));
md.push_str(&format!("- Most recent session: {latest}\n"));
md.push('\n');
// Knowledge Graph section
let entity_count = knowledge.entities.len();
let relation_count = knowledge.relations.len();
let top_entities = if knowledge.entities.is_empty() {
"none".to_owned()
} else {
knowledge
.entities
.iter()
.take(5)
.map(|e| e.name.as_str())
.collect::<Vec<_>>()
.join(", ")
};
md.push_str("## Knowledge Graph\n");
md.push_str(&format!("- Entities: {entity_count}\n"));
md.push_str(&format!("- Relations: {relation_count}\n"));
md.push_str(&format!("- Top entities: {top_entities}\n"));
md.push('\n');
// Search Capabilities section
md.push_str("## Search Capabilities\n");
md.push_str("- Vector search: cosine similarity with SIMD acceleration\n");
md.push_str("- Hybrid search: vector + BM25 (RRF)\n");
md.push_str(&format!(
"- Hebbian activation: enabled (boost={}, decay={})\n",
config.hebbian_boost, config.decay_factor
));
md
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AgentMemory, HDF5Memory, MemoryEntry};
use std::path::PathBuf;
use tempfile::TempDir;
fn test_config(path: PathBuf) -> MemoryConfig {
MemoryConfig {
path,
agent_id: "test-agent".to_string(),
embedder: "openai:text-embedding-3-small".to_string(),
embedding_dim: 384,
chunk_size: 512,
overlap: 50,
float16: false,
compression: false,
compression_level: 0,
compact_threshold: 0.3,
hebbian_boost: 0.15,
decay_factor: 0.98,
created_at: "2025-01-01T00:00:00Z".to_string(),
wal_enabled: false,
wal_max_entries: 500,
}
}
fn empty_cache(dim: usize) -> MemoryCache {
MemoryCache::new(dim)
}
fn empty_sessions() -> SessionCache {
SessionCache::new()
}
fn empty_knowledge() -> KnowledgeCache {
KnowledgeCache::new()
}
#[test]
fn test_generate_contains_agent_id() {
let config = test_config(PathBuf::from("/tmp/test.h5"));
let cache = empty_cache(384);
let sessions = empty_sessions();
let knowledge = empty_knowledge();
let md = generate(&config, &cache, &sessions, &knowledge);
assert!(md.contains("test-agent"), "should contain agent_id");
assert!(
md.contains("# Agent Memory — test-agent"),
"should have header with agent_id"
);
}
#[test]
fn test_generate_contains_memory_counts() {
let config = test_config(PathBuf::from("/tmp/test.h5"));
let mut cache = empty_cache(4);
let sessions = empty_sessions();
let knowledge = empty_knowledge();
// Add 5 entries, tombstone 2
for i in 0..5 {
cache.push(
format!("chunk {i}"),
vec![i as f32, 0.0, 0.0, 0.0],
"test".into(),
1000.0,
"s1".into(),
"".into(),
);
}
cache.mark_deleted(1);
cache.mark_deleted(3);
let md = generate(&config, &cache, &sessions, &knowledge);
assert!(md.contains("Active memories: 3"), "md = {md}");
assert!(md.contains("Deleted (tombstoned): 2"), "md = {md}");
assert!(md.contains("Total entries: 5"), "md = {md}");
}
#[test]
fn test_generate_contains_embedding_info() {
let config = test_config(PathBuf::from("/tmp/test.h5"));
let cache = empty_cache(384);
let sessions = empty_sessions();
let knowledge = empty_knowledge();
let md = generate(&config, &cache, &sessions, &knowledge);
assert!(
md.contains("openai:text-embedding-3-small"),
"should contain embedder name"
);
assert!(md.contains("384d"), "should contain dimension");
}
#[test]
fn test_generate_contains_entity_count() {
let config = test_config(PathBuf::from("/tmp/test.h5"));
let cache = empty_cache(384);
let sessions = empty_sessions();
let mut knowledge = empty_knowledge();
knowledge.add_entity("Alice", "person", -1);
knowledge.add_entity("Bob", "person", -1);
knowledge.add_entity("Rust", "language", -1);
let md = generate(&config, &cache, &sessions, &knowledge);
assert!(md.contains("Entities: 3"), "md = {md}");
}
#[test]
fn test_generate_contains_session_info() {
let config = test_config(PathBuf::from("/tmp/test.h5"));
let cache = empty_cache(384);
let mut sessions = empty_sessions();
let knowledge = empty_knowledge();
sessions.add("sess-1", 0, 5, "api", "first session");
sessions.add("sess-2", 6, 10, "slack", "second session");
let md = generate(&config, &cache, &sessions, &knowledge);
assert!(md.contains("Total sessions: 2"), "md = {md}");
assert!(md.contains("Most recent session: sess-2"), "md = {md}");
}
#[test]
fn test_generate_empty_state() {
let config = test_config(PathBuf::from("/tmp/test.h5"));
let cache = empty_cache(384);
let sessions = empty_sessions();
let knowledge = empty_knowledge();
let md = generate(&config, &cache, &sessions, &knowledge);
assert!(md.contains("Active memories: 0"), "md = {md}");
assert!(md.contains("Deleted (tombstoned): 0"), "md = {md}");
assert!(md.contains("Total entries: 0"), "md = {md}");
assert!(md.contains("Total sessions: 0"), "md = {md}");
assert!(md.contains("Entities: 0"), "md = {md}");
assert!(md.contains("Relations: 0"), "md = {md}");
assert!(md.contains("Most recent session: none"), "md = {md}");
assert!(md.contains("Top entities: none"), "md = {md}");
// Valid markdown: has headers
assert!(md.contains("# Agent Memory"));
assert!(md.contains("## Identity"));
assert!(md.contains("## Memory Store"));
assert!(md.contains("## Sessions"));
assert!(md.contains("## Knowledge Graph"));
assert!(md.contains("## Search Capabilities"));
}
#[test]
fn test_generate_top_entities() {
let config = test_config(PathBuf::from("/tmp/test.h5"));
let cache = empty_cache(384);
let sessions = empty_sessions();
let mut knowledge = empty_knowledge();
// Add 7 entities
for name in &["Alpha", "Beta", "Gamma", "Delta", "Epsilon", "Zeta", "Eta"] {
knowledge.add_entity(name, "test", -1);
}
let md = generate(&config, &cache, &sessions, &knowledge);
// Should list first 5 only
assert!(
md.contains("Top entities: Alpha, Beta, Gamma, Delta, Epsilon"),
"md = {md}"
);
// Should NOT contain Zeta or Eta in the top entities line
let top_line = md
.lines()
.find(|l| l.starts_with("- Top entities:"))
.unwrap();
assert!(!top_line.contains("Zeta"), "top_line = {top_line}");
assert!(!top_line.contains("Eta"), "top_line = {top_line}");
}
#[test]
fn test_write_and_read_agents_md() {
let dir = TempDir::new().unwrap();
let config = MemoryConfig::new(dir.path().join("test.h5"), "write-read-agent", 4);
let mut mem = HDF5Memory::create(config).unwrap();
mem.save(MemoryEntry {
chunk: "hello".into(),
embedding: vec![1.0, 0.0, 0.0, 0.0],
source_channel: "api".into(),
timestamp: 1000.0,
session_id: "s1".into(),
tags: "".into(),
})
.unwrap();
mem.write_agents_md().unwrap();
let read_back = HDF5Memory::read_agents_md(&dir.path().join("test.h5")).unwrap();
let generated = mem.generate_agents_md();
assert_eq!(read_back, generated);
assert!(read_back.contains("write-read-agent"));
}
#[test]
fn test_generate_hebbian_config() {
let mut config = test_config(PathBuf::from("/tmp/test.h5"));
config.hebbian_boost = 0.25;
config.decay_factor = 0.95;
let cache = empty_cache(384);
let sessions = empty_sessions();
let knowledge = empty_knowledge();
let md = generate(&config, &cache, &sessions, &knowledge);
assert!(md.contains("boost=0.25"), "md = {md}");
assert!(md.contains("decay=0.95"), "md = {md}");
}
}