// Agent definitions: self-contained files with query + prompt template. // // Each agent is a file in the agents/ directory: // - First line: JSON header (agent, query, model, schedule) // - After blank line: prompt template with {{placeholder}} lookups // // Placeholders are resolved at runtime: // {{topology}} — graph topology header // {{nodes}} — query results formatted as node sections // {{episodes}} — alias for {{nodes}} // {{health}} — graph health report // {{pairs}} — interference pairs from detect_interference // {{rename}} — rename candidates // {{split}} — split detail for the first query result // // The query selects what to operate on; placeholders pull in context. use crate::graph::Graph; use crate::neuro::{consolidation_priority, ReplayItem}; use crate::search; use crate::store::Store; use serde::Deserialize; use std::path::PathBuf; /// Agent definition: config (from JSON header) + prompt (raw markdown body). #[derive(Clone, Debug)] pub struct AgentDef { pub agent: String, pub query: String, pub prompt: String, pub model: String, pub schedule: String, } /// The JSON header portion (first line of the file). #[derive(Deserialize)] struct AgentHeader { agent: String, #[serde(default)] query: String, #[serde(default = "default_model")] model: String, #[serde(default)] schedule: String, } fn default_model() -> String { "sonnet".into() } /// Parse an agent file: first line is JSON config, rest is the prompt. fn parse_agent_file(content: &str) -> Option { let (first_line, rest) = content.split_once('\n')?; let header: AgentHeader = serde_json::from_str(first_line.trim()).ok()?; // Skip optional blank line between header and prompt body let prompt = rest.strip_prefix('\n').unwrap_or(rest); Some(AgentDef { agent: header.agent, query: header.query, prompt: prompt.to_string(), model: header.model, schedule: header.schedule, }) } fn agents_dir() -> PathBuf { let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("agents"); if repo.is_dir() { return repo; } crate::store::memory_dir().join("agents") } /// Load all agent definitions. pub fn load_defs() -> Vec { let dir = agents_dir(); let Ok(entries) = std::fs::read_dir(&dir) else { return Vec::new() }; entries .filter_map(|e| e.ok()) .filter(|e| { let p = e.path(); p.extension().map(|x| x == "agent" || x == "md").unwrap_or(false) }) .filter_map(|e| { let content = std::fs::read_to_string(e.path()).ok()?; parse_agent_file(&content) }) .collect() } /// Look up a single agent definition by name. pub fn get_def(name: &str) -> Option { let dir = agents_dir(); for ext in ["agent", "md"] { let path = dir.join(format!("{}.{}", name, ext)); if let Ok(content) = std::fs::read_to_string(&path) { if let Some(def) = parse_agent_file(&content) { return Some(def); } } } load_defs().into_iter().find(|d| d.agent == name) } /// Result of resolving a placeholder: text + any affected node keys. struct Resolved { text: String, keys: Vec, } /// Resolve a single {{placeholder}} by name. /// Returns the replacement text and any node keys it produced (for visit tracking). fn resolve( name: &str, store: &Store, graph: &Graph, keys: &[String], count: usize, ) -> Option { match name { "topology" => Some(Resolved { text: super::prompts::format_topology_header(graph), keys: vec![], }), "nodes" | "episodes" => { let items = keys_to_replay_items(store, keys, graph); Some(Resolved { text: super::prompts::format_nodes_section(store, &items, graph), keys: vec![], // keys already tracked from query }) } "health" => Some(Resolved { text: super::prompts::format_health_section(store, graph), keys: vec![], }), "pairs" => { let mut pairs = crate::neuro::detect_interference(store, graph, 0.5); pairs.truncate(count); let pair_keys: Vec = pairs.iter() .flat_map(|(a, b, _)| vec![a.clone(), b.clone()]) .collect(); Some(Resolved { text: super::prompts::format_pairs_section(&pairs, store, graph), keys: pair_keys, }) } "rename" => { let (rename_keys, section) = super::prompts::format_rename_candidates(store, count); Some(Resolved { text: section, keys: rename_keys }) } "split" => { let key = keys.first()?; Some(Resolved { text: super::prompts::format_split_plan_node(store, graph, key), keys: vec![], // key already tracked from query }) } "conversations" => { let fragments = super::knowledge::select_conversation_fragments(count); let text = fragments.iter() .map(|(id, text)| format!("### Session {}\n\n{}", id, text)) .collect::>() .join("\n\n---\n\n"); Some(Resolved { text, keys: vec![] }) } // targets/context: aliases for challenger-style presentation "targets" => { let items = keys_to_replay_items(store, keys, graph); Some(Resolved { text: super::prompts::format_nodes_section(store, &items, graph), keys: vec![], }) } _ => None, } } /// Resolve all {{placeholder}} patterns in a prompt template. /// Returns the resolved text and all node keys collected from placeholders. fn resolve_placeholders( template: &str, store: &Store, graph: &Graph, keys: &[String], count: usize, ) -> (String, Vec) { let mut result = template.to_string(); let mut extra_keys = Vec::new(); loop { let Some(start) = result.find("{{") else { break }; let Some(end) = result[start + 2..].find("}}") else { break }; let end = start + 2 + end; let name = result[start + 2..end].trim().to_lowercase(); match resolve(&name, store, graph, keys, count) { Some(resolved) => { extra_keys.extend(resolved.keys); result.replace_range(start..end + 2, &resolved.text); } None => { let msg = format!("(unknown: {})", name); result.replace_range(start..end + 2, &msg); } } } (result, extra_keys) } /// Run a config-driven agent: query → resolve placeholders → prompt. pub fn run_agent( store: &Store, def: &AgentDef, count: usize, ) -> Result { let graph = store.build_graph(); // Run the query if present let keys = if !def.query.is_empty() { let mut stages = search::Stage::parse_pipeline(&def.query)?; let has_limit = stages.iter().any(|s| matches!(s, search::Stage::Transform(search::Transform::Limit(_)))); if !has_limit { stages.push(search::Stage::Transform(search::Transform::Limit(count))); } let results = search::run_query(&stages, vec![], &graph, store, false, count); if results.is_empty() { return Err(format!("{}: query returned no results", def.agent)); } results.into_iter().map(|(k, _)| k).collect::>() } else { vec![] }; let (prompt, extra_keys) = resolve_placeholders(&def.prompt, store, &graph, &keys, count); // Merge query keys with any keys produced by placeholder resolution let mut all_keys = keys; all_keys.extend(extra_keys); Ok(super::prompts::AgentBatch { prompt, node_keys: all_keys }) } /// Convert a list of keys to ReplayItems with priority and graph metrics. pub fn keys_to_replay_items( store: &Store, keys: &[String], graph: &Graph, ) -> Vec { keys.iter() .filter_map(|key| { let node = store.nodes.get(key)?; let priority = consolidation_priority(store, key, graph, None); let cc = graph.clustering_coefficient(key); Some(ReplayItem { key: key.clone(), priority, interval_days: node.spaced_repetition_interval, emotion: node.emotion, cc, classification: "unknown", outlier_score: 0.0, }) }) .collect() }