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.
This commit is contained in:
ProofOfConcept 2026-03-11 16:56:17 -04:00 committed by Kent Overstreet
parent 0e971dee61
commit 7199c89518
5 changed files with 171 additions and 5 deletions

View file

@ -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}}

View file

@ -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

View file

@ -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>, 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<String, Vec<String>> = 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<String>)> = 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)
}

View file

@ -235,6 +235,7 @@ fn agent_base_depth(agent: &str) -> Option<i32> {
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);

View file

@ -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<String, String> {
pub(crate) fn call_model(agent: &str, model: &str, prompt: &str) -> Result<String, String> {
// 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()));