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:
parent
0e971dee61
commit
7199c89518
5 changed files with 171 additions and 5 deletions
96
poc-memory/agents/generalize.agent
Normal file
96
poc-memory/agents/generalize.agent
Normal 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}}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
{"agent":"naming","query":"","model":"haiku","schedule":""}
|
{"agent":"naming","query":"","model":"sonnet","schedule":""}
|
||||||
# Naming Agent — Node Key Resolution
|
# Naming Agent — Node Key Resolution
|
||||||
|
|
||||||
You are given a proposed new node (key + content) and a list of existing
|
You are given a proposed new node (key + content) and a list of existing
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,11 @@ fn resolve(
|
||||||
Some(Resolved { text, keys: vec![] })
|
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/context: aliases for challenger-style presentation
|
||||||
"targets" => {
|
"targets" => {
|
||||||
let items = keys_to_replay_items(store, keys, graph);
|
let items = keys_to_replay_items(store, keys, graph);
|
||||||
|
|
@ -269,3 +274,66 @@ pub fn keys_to_replay_items(
|
||||||
})
|
})
|
||||||
.collect()
|
.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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,7 @@ fn agent_base_depth(agent: &str) -> Option<i32> {
|
||||||
match agent {
|
match agent {
|
||||||
"observation" => Some(1),
|
"observation" => Some(1),
|
||||||
"extractor" => Some(2),
|
"extractor" => Some(2),
|
||||||
|
"generalize" => Some(3),
|
||||||
"connector" => Some(3),
|
"connector" => Some(3),
|
||||||
"challenger" => None,
|
"challenger" => None,
|
||||||
_ => Some(2),
|
_ => Some(2),
|
||||||
|
|
@ -343,6 +344,7 @@ fn agent_provenance(agent: &str) -> String {
|
||||||
match agent {
|
match agent {
|
||||||
"observation" => "agent:knowledge-observation".to_string(),
|
"observation" => "agent:knowledge-observation".to_string(),
|
||||||
"extractor" | "pattern" => "agent:knowledge-pattern".to_string(),
|
"extractor" | "pattern" => "agent:knowledge-pattern".to_string(),
|
||||||
|
"generalize" => "agent:knowledge-generalize".to_string(),
|
||||||
"connector" => "agent:knowledge-connector".to_string(),
|
"connector" => "agent:knowledge-connector".to_string(),
|
||||||
"challenger" => "agent:knowledge-challenger".to_string(),
|
"challenger" => "agent:knowledge-challenger".to_string(),
|
||||||
_ => format!("agent:{}", agent),
|
_ => format!("agent:{}", agent),
|
||||||
|
|
@ -476,7 +478,7 @@ pub fn resolve_naming(
|
||||||
let conflicts = find_conflicts(store, proposed_key, proposed_content, 5);
|
let conflicts = find_conflicts(store, proposed_key, proposed_content, 5);
|
||||||
let prompt = format_naming_prompt(proposed_key, proposed_content, &conflicts);
|
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) => {
|
Ok(response) => {
|
||||||
match parse_naming_response(&response) {
|
match parse_naming_response(&response) {
|
||||||
Some(resolution) => resolution,
|
Some(resolution) => resolution,
|
||||||
|
|
@ -582,7 +584,7 @@ pub fn run_one_agent(
|
||||||
.ok_or_else(|| format!("no .agent file for {}", agent_name))?;
|
.ok_or_else(|| format!("no .agent file for {}", agent_name))?;
|
||||||
let agent_batch = super::defs::run_agent(store, &def, batch_size)?;
|
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
|
// Store raw output for audit trail
|
||||||
let ts = store::compact_timestamp();
|
let ts = store::compact_timestamp();
|
||||||
|
|
@ -856,7 +858,7 @@ fn run_cycle(
|
||||||
let mut total_applied = 0;
|
let mut total_applied = 0;
|
||||||
|
|
||||||
// Run each agent via .agent file dispatch
|
// 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 {
|
for agent_name in &agent_names {
|
||||||
eprintln!("\n --- {} (n={}) ---", agent_name, config.batch_size);
|
eprintln!("\n --- {} (n={}) ---", agent_name, config.batch_size);
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// Sets PR_SET_PDEATHSIG on the child so it gets SIGTERM if the
|
||||||
/// parent daemon exits — no more orphaned claude processes.
|
/// parent daemon exits — no more orphaned claude processes.
|
||||||
/// Times out after 5 minutes to prevent blocking the daemon forever.
|
/// 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)
|
// Write prompt to temp file (claude CLI needs file input for large prompts)
|
||||||
let tmp = std::env::temp_dir().join(format!("poc-llm-{}-{:?}.txt",
|
let tmp = std::env::temp_dir().join(format!("poc-llm-{}-{:?}.txt",
|
||||||
std::process::id(), std::thread::current().id()));
|
std::process::id(), std::thread::current().id()));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue