From 7199c8951872c2c4b5b7349de920e108bf1cb2ff Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Wed, 11 Mar 2026 16:56:17 -0400 Subject: [PATCH] agents: model dispatch from .agent file, add generalize agent WIP Make call_model pub(crate) so run_one_agent reads the model from the .agent definition instead of hardcoding sonnet. Naming agent upgraded from haiku to sonnet. Add generalize agent: finds the largest prefix-grouped cluster of nodes that hasn't been visited recently, wired into the agent cycle between extractor and connector at depth 3. New "clusters" resolver in defs.rs does prefix-based grouping with provenance filtering. --- poc-memory/agents/generalize.agent | 96 ++++++++++++++++++++++++++++++ poc-memory/agents/naming.agent | 2 +- poc-memory/src/agents/defs.rs | 68 +++++++++++++++++++++ poc-memory/src/agents/knowledge.rs | 8 ++- poc-memory/src/agents/llm.rs | 2 +- 5 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 poc-memory/agents/generalize.agent diff --git a/poc-memory/agents/generalize.agent b/poc-memory/agents/generalize.agent new file mode 100644 index 0000000..51bf366 --- /dev/null +++ b/poc-memory/agents/generalize.agent @@ -0,0 +1,96 @@ +{"agent":"generalize","query":"","model":"sonnet","schedule":"weekly"} +# Generalize & Organize Agent — Schema Synthesis + +You are a knowledge architect. You look at clusters of related leaf +nodes and synthesize them into organized schema nodes — reference +documents that capture the actual knowledge in a structured, +retrievable form. + +## The goal + +The memory graph accumulates fine-grained observations over time: +individual debugging sessions, specific pattern sightings, one-off +corrections. These are valuable as raw material but hard to use — +searching for "how does bcachefs transaction restart work" shouldn't +return 15 separate observations, it should return one well-organized +reference. + +Your job is to look at clusters of related nodes and produce +**schema nodes** — organized references that synthesize the cluster's +knowledge into something someone would actually want to find and read. + +## What you're given + +You'll receive a cluster of related nodes — typically sharing a key +prefix (like `kernel-patterns#accounting-*`) or a topic. Read them +all, understand what knowledge they collectively contain, then produce +a schema node that captures it. + +## What to produce + +**1. Schema node (WRITE_NODE):** A well-organized reference covering +the cluster's topic. Structure it for someone who needs this knowledge +in a future session: +- What is this thing / how does it work +- Key patterns and gotchas +- Examples from the raw material +- Decision rationale where available + +**2. Demotions (DEMOTE):** Leaf nodes whose useful content is now +fully captured in the schema. Don't demote nodes that contain unique +detail the schema doesn't cover — only demote what's truly redundant. + +**3. Links (LINK):** Connect the schema node to related nodes in +other parts of the graph. + +## What makes a good schema node + +**Good:** "Here's how bcachefs accounting macros work. The API has +these entry points. The common gotcha is X because Y. When debugging, +check Z first." Organized, specific, actionable. + +**Bad:** "Accounting is an important part of bcachefs. There are +several patterns to be aware of." Vague, no actual knowledge content. + +The schema should be **denser** than the sum of its parts — not a +concatenation of the leaf nodes, but a synthesis that's more useful +than any individual observation. + +## Output format + +``` +WRITE_NODE schema-key +CONFIDENCE: high +COVERS: leaf_key_1, leaf_key_2, leaf_key_3 +[organized schema content in markdown] +END_NODE + +DEMOTE fully-captured-leaf-key +DEMOTE another-redundant-leaf + +LINK schema-key related-node-in-another-domain +``` + +## Guidelines + +- **Read everything before writing.** The schema should reflect the + full cluster, not just the first few nodes. +- **Preserve unique details.** If a leaf node has a specific example, + a debugging war story, or a nuance that matters — include it or + leave the leaf node alive. +- **Use good key names.** Schema nodes should have clear, searchable + keys: `bcachefs-accounting-guide`, `async-tui-architecture`, + `transaction-restart-patterns`. +- **Don't over-abstract.** The goal is organized reference material, + not philosophy. Keep it concrete and useful. +- **It's OK to produce multiple schemas** if the cluster naturally + splits into distinct topics. +- **If the cluster is already well-organized** (e.g., one good schema + node already exists with well-organized leaves), say so: + `NO_ACTION — cluster is already well-organized`. + +{{TOPOLOGY}} + +## Cluster to organize + +{{CLUSTERS}} diff --git a/poc-memory/agents/naming.agent b/poc-memory/agents/naming.agent index 8948376..15abf28 100644 --- a/poc-memory/agents/naming.agent +++ b/poc-memory/agents/naming.agent @@ -1,4 +1,4 @@ -{"agent":"naming","query":"","model":"haiku","schedule":""} +{"agent":"naming","query":"","model":"sonnet","schedule":""} # Naming Agent — Node Key Resolution You are given a proposed new node (key + content) and a list of existing diff --git a/poc-memory/src/agents/defs.rs b/poc-memory/src/agents/defs.rs index 8cf53fc..35e1255 100644 --- a/poc-memory/src/agents/defs.rs +++ b/poc-memory/src/agents/defs.rs @@ -169,6 +169,11 @@ fn resolve( Some(Resolved { text, keys: vec![] }) } + "clusters" => { + let (cluster_keys, text) = find_largest_cluster(store, graph, count); + Some(Resolved { text, keys: cluster_keys }) + } + // targets/context: aliases for challenger-style presentation "targets" => { let items = keys_to_replay_items(store, keys, graph); @@ -269,3 +274,66 @@ pub fn keys_to_replay_items( }) .collect() } + +/// Find the largest cluster of nodes sharing a key prefix that hasn't been +/// visited by the generalize agent recently. Returns the cluster's node keys +/// and a formatted section with all their content. +fn find_largest_cluster(store: &Store, graph: &Graph, _count: usize) -> (Vec, String) { + use std::collections::HashMap; + + let min_cluster = 5; + + // Group non-internal nodes by their key prefix (before #, or first word of kebab-key) + let mut prefix_groups: HashMap> = HashMap::new(); + for key in store.nodes.keys() { + if key.starts_with('_') { continue; } + if key.starts_with("journal#") || key.starts_with("daily-") || + key.starts_with("weekly-") || key.starts_with("monthly-") { + continue; + } + + // Extract prefix: "patterns#async-tui-blocking" → "patterns#async-tui" + // Take everything up to the last hyphenated segment after # + let prefix = if let Some(hash_pos) = key.find('#') { + let after_hash = &key[hash_pos + 1..]; + if let Some(last_dash) = after_hash.rfind('-') { + format!("{}#{}", &key[..hash_pos], &after_hash[..last_dash]) + } else { + key[..hash_pos].to_string() + } + } else { + key.clone() + }; + prefix_groups.entry(prefix).or_default().push(key.clone()); + } + + // Find biggest clusters, preferring unvisited ones + let mut clusters: Vec<(String, Vec)> = prefix_groups.into_iter() + .filter(|(_, keys)| keys.len() >= min_cluster) + .collect(); + clusters.sort_by(|a, b| b.1.len().cmp(&a.1.len())); + + // Pick the first cluster where most nodes haven't been generalized + let cluster = clusters.into_iter() + .find(|(_, keys)| { + let unvisited = keys.iter() + .filter(|k| { + store.nodes.get(*k) + .map(|n| !n.provenance.contains("generalize")) + .unwrap_or(true) + }) + .count(); + unvisited > keys.len() / 2 + }); + + let Some((prefix, keys)) = cluster else { + return (vec![], "No clusters found that need generalization.".to_string()); + }; + + // Render all nodes in the cluster + let mut out = format!("### Cluster: `{}*` ({} nodes)\n\n", prefix, keys.len()); + let items = keys_to_replay_items(store, &keys, graph); + out.push_str(&super::prompts::format_nodes_section(store, &items, graph)); + + (keys, out) +} diff --git a/poc-memory/src/agents/knowledge.rs b/poc-memory/src/agents/knowledge.rs index c13d081..e6280ff 100644 --- a/poc-memory/src/agents/knowledge.rs +++ b/poc-memory/src/agents/knowledge.rs @@ -235,6 +235,7 @@ fn agent_base_depth(agent: &str) -> Option { match agent { "observation" => Some(1), "extractor" => Some(2), + "generalize" => Some(3), "connector" => Some(3), "challenger" => None, _ => Some(2), @@ -343,6 +344,7 @@ fn agent_provenance(agent: &str) -> String { match agent { "observation" => "agent:knowledge-observation".to_string(), "extractor" | "pattern" => "agent:knowledge-pattern".to_string(), + "generalize" => "agent:knowledge-generalize".to_string(), "connector" => "agent:knowledge-connector".to_string(), "challenger" => "agent:knowledge-challenger".to_string(), _ => format!("agent:{}", agent), @@ -476,7 +478,7 @@ pub fn resolve_naming( let conflicts = find_conflicts(store, proposed_key, proposed_content, 5); let prompt = format_naming_prompt(proposed_key, proposed_content, &conflicts); - match llm::call_haiku("naming", &prompt) { + match llm::call_model("naming", "sonnet", &prompt) { Ok(response) => { match parse_naming_response(&response) { Some(resolution) => resolution, @@ -582,7 +584,7 @@ pub fn run_one_agent( .ok_or_else(|| format!("no .agent file for {}", agent_name))?; let agent_batch = super::defs::run_agent(store, &def, batch_size)?; - let output = llm::call_sonnet(llm_tag, &agent_batch.prompt)?; + let output = llm::call_model(llm_tag, &def.model, &agent_batch.prompt)?; // Store raw output for audit trail let ts = store::compact_timestamp(); @@ -856,7 +858,7 @@ fn run_cycle( let mut total_applied = 0; // Run each agent via .agent file dispatch - let agent_names = ["observation", "extractor", "connector", "challenger"]; + let agent_names = ["observation", "extractor", "generalize", "connector", "challenger"]; for agent_name in &agent_names { eprintln!("\n --- {} (n={}) ---", agent_name, config.batch_size); diff --git a/poc-memory/src/agents/llm.rs b/poc-memory/src/agents/llm.rs index 81cdfc6..97bc180 100644 --- a/poc-memory/src/agents/llm.rs +++ b/poc-memory/src/agents/llm.rs @@ -46,7 +46,7 @@ const SUBPROCESS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3 /// Sets PR_SET_PDEATHSIG on the child so it gets SIGTERM if the /// parent daemon exits — no more orphaned claude processes. /// Times out after 5 minutes to prevent blocking the daemon forever. -fn call_model(agent: &str, model: &str, prompt: &str) -> Result { +pub(crate) fn call_model(agent: &str, model: &str, prompt: &str) -> Result { // Write prompt to temp file (claude CLI needs file input for large prompts) let tmp = std::env::temp_dir().join(format!("poc-llm-{}-{:?}.txt", std::process::id(), std::thread::current().id()));