naming agent: resolve node names before creation

Any time an agent creates a new node (WRITE_NODE) or the fact miner
stores extracted facts, a naming sub-agent now checks for conflicts
and ensures the key is meaningful:

- find_conflicts() searches existing nodes via component matching
- Haiku LLM decides: CREATE (good name), RENAME (better name),
  or MERGE_INTO (fold into existing node)
- WriteNode actions may be converted to Refine on MERGE_INTO

Also updates the rename agent to handle _facts-<UUID> nodes —
these are no longer skipped, and the prompt explains how to name
them based on their domain/claim content.
This commit is contained in:
ProofOfConcept 2026-03-10 23:23:14 -04:00
parent 15dedea322
commit b62fffc326
5 changed files with 281 additions and 6 deletions

View file

@ -348,6 +348,149 @@ fn agent_provenance(agent: &str) -> store::Provenance {
}
}
// ---------------------------------------------------------------------------
// Naming resolution — called before creating any new node
// ---------------------------------------------------------------------------
/// Resolution from the naming agent.
#[derive(Debug)]
pub enum NamingResolution {
/// Create with the proposed key (or a better one).
Create(String),
/// Merge content into an existing node instead.
MergeInto(String),
}
/// Find existing nodes that might conflict with a proposed new node.
/// Returns up to `limit` (key, content_preview) pairs.
fn find_conflicts(
store: &Store,
proposed_key: &str,
proposed_content: &str,
limit: usize,
) -> Vec<(String, String)> {
use std::collections::BTreeMap;
// Extract search terms from the key (split on separators) and first ~200 chars of content
let mut terms: BTreeMap<String, f64> = BTreeMap::new();
for part in proposed_key.split(|c: char| c == '-' || c == '_' || c == '#' || c == '.') {
let p = part.to_lowercase();
if p.len() >= 3 {
terms.insert(p, 1.0);
}
}
// Add a few content terms
let content_terms = crate::search::extract_query_terms(proposed_content, 5);
for term in content_terms.split_whitespace() {
terms.entry(term.to_string()).or_insert(0.5);
}
if terms.is_empty() {
return Vec::new();
}
// Use component matching to find related nodes
let (seeds, _) = crate::search::match_seeds_opts(&terms, store, true, false);
let mut results: Vec<(String, f64)> = seeds.into_iter()
.filter(|(k, _)| k != proposed_key)
.collect();
results.sort_by(|a, b| b.1.total_cmp(&a.1));
results.into_iter()
.take(limit)
.filter_map(|(key, _)| {
let node = store.nodes.get(key.as_str())?;
let preview: String = node.content.chars().take(200).collect();
Some((key, preview))
})
.collect()
}
/// Format the naming prompt for a proposed node.
fn format_naming_prompt(
proposed_key: &str,
proposed_content: &str,
conflicts: &[(String, String)],
) -> String {
let conflict_section = if conflicts.is_empty() {
"(no existing nodes found with overlapping content)".to_string()
} else {
conflicts.iter()
.map(|(key, preview)| format!("### `{}`\n\n{}", key, preview))
.collect::<Vec<_>>()
.join("\n\n")
};
// Truncate content for the prompt (don't send huge nodes to Haiku)
let content_preview: String = proposed_content.chars().take(1000).collect();
format!(
"# Naming Agent — Node Key Resolution\n\n\
You are given a proposed new node (key + content) and a list of existing\n\
nodes that might overlap with it. Decide what to do:\n\n\
1. **CREATE** the proposed key is good and there's no meaningful overlap.\n\
2. **RENAME** the content is unique but the key is bad (UUID, truncated, generic).\n\
3. **MERGE_INTO** an existing node already covers this content.\n\n\
Good keys: 2-5 words in kebab-case, optionally with `#` subtopic.\n\
Bad keys: UUIDs, single generic words, truncated auto-slugs.\n\n\
Respond with exactly ONE line: `CREATE key`, `RENAME better_key`, or `MERGE_INTO existing_key`.\n\n\
## Proposed node\n\n\
Key: `{}`\n\n\
Content:\n```\n{}\n```\n\n\
## Existing nodes that might overlap\n\n\
{}",
proposed_key, content_preview, conflict_section,
)
}
/// Parse naming agent response.
fn parse_naming_response(response: &str) -> Option<NamingResolution> {
for line in response.lines() {
let trimmed = line.trim();
if let Some(key) = trimmed.strip_prefix("CREATE ") {
return Some(NamingResolution::Create(key.trim().to_string()));
}
if let Some(key) = trimmed.strip_prefix("RENAME ") {
return Some(NamingResolution::Create(key.trim().to_string()));
}
if let Some(key) = trimmed.strip_prefix("MERGE_INTO ") {
return Some(NamingResolution::MergeInto(key.trim().to_string()));
}
}
None
}
/// Resolve naming for a proposed WriteNode action.
///
/// Searches for conflicts, calls the naming LLM (Haiku), and returns
/// either a Create (possibly with a better key) or MergeInto resolution.
/// On LLM failure, falls through to using the proposed key as-is.
pub fn resolve_naming(
store: &Store,
proposed_key: &str,
proposed_content: &str,
) -> NamingResolution {
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) {
Ok(response) => {
match parse_naming_response(&response) {
Some(resolution) => resolution,
None => {
eprintln!("naming: unparseable response, using proposed key");
NamingResolution::Create(proposed_key.to_string())
}
}
}
Err(e) => {
eprintln!("naming: LLM error ({}), using proposed key", e);
NamingResolution::Create(proposed_key.to_string())
}
}
}
// ---------------------------------------------------------------------------
// Shared agent execution
// ---------------------------------------------------------------------------
@ -360,6 +503,48 @@ pub struct AgentResult {
pub node_keys: Vec<String>,
}
/// Resolve naming for all WriteNode actions in a list.
///
/// For each WriteNode, calls the naming agent to check for conflicts and
/// get a good key. May convert WriteNode → Refine (if MERGE_INTO) or
/// update the key (if RENAME/CREATE with different key).
pub fn resolve_action_names(store: &Store, actions: Vec<Action>) -> Vec<Action> {
actions.into_iter().map(|action| {
match &action.kind {
ActionKind::WriteNode { key, content, covers } => {
match resolve_naming(store, key, content) {
NamingResolution::Create(new_key) => {
if new_key == *key {
action // keep as-is
} else {
eprintln!("naming: {}{}", key, new_key);
Action {
kind: ActionKind::WriteNode {
key: new_key,
content: content.clone(),
covers: covers.clone(),
},
..action
}
}
}
NamingResolution::MergeInto(existing_key) => {
eprintln!("naming: {} → MERGE_INTO {}", key, existing_key);
Action {
kind: ActionKind::Refine {
key: existing_key,
content: content.clone(),
},
..action
}
}
}
}
_ => action,
}
}).collect()
}
/// Run a single agent and apply its actions (no depth tracking).
///
/// Returns (total_actions, applied_count) or an error.
@ -370,14 +555,15 @@ pub fn run_and_apply(
llm_tag: &str,
) -> Result<(usize, usize), String> {
let result = run_one_agent(store, agent_name, batch_size, llm_tag)?;
let actions = resolve_action_names(store, result.actions);
let ts = store::compact_timestamp();
let mut applied = 0;
for action in &result.actions {
for action in &actions {
if apply_action(store, action, agent_name, &ts, 0) {
applied += 1;
}
}
Ok((result.actions.len(), applied))
Ok((actions.len(), applied))
}
/// Run a single agent: build prompt → call LLM → store output → parse actions → record visits.