Resolve subconscious prompt placeholders in Mind

Lightweight resolver handles {{seen_current}}, {{seen_previous}}, and
{{input:KEY}} using the session_id and output_dir directly instead of
env vars. Runs in trigger_subconscious before creating AutoAgent.

Removes {{memory_ratio}} from surface-observe prompt — redundant with
existing budget mechanisms.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-07 01:41:11 -04:00
parent 2678d64b77
commit e2e0371726
2 changed files with 85 additions and 7 deletions

View file

@ -72,6 +72,76 @@ impl SubconsciousAgent {
conversation_bytes.saturating_sub(self.last_trigger_bytes) >= interval
}
}
/// Resolve {{placeholder}} templates in subconscious agent prompts.
/// Handles: seen_current, seen_previous, input:KEY.
fn resolve_prompt(template: &str, session_id: &str, output_dir: &std::path::Path) -> String {
let mut result = String::with_capacity(template.len());
let mut rest = template;
while let Some(start) = rest.find("{{") {
result.push_str(&rest[..start]);
let after = &rest[start + 2..];
if let Some(end) = after.find("}}") {
let name = after[..end].trim();
let replacement = match name {
"seen_current" => resolve_seen_list(session_id, ""),
"seen_previous" => resolve_seen_list(session_id, "-prev"),
_ if name.starts_with("input:") => {
let key = &name[6..];
std::fs::read_to_string(output_dir.join(key))
.unwrap_or_default()
}
_ => {
// Unknown placeholder — leave as-is
result.push_str("{{");
result.push_str(&after[..end + 2]);
rest = &after[end + 2..];
continue;
}
};
result.push_str(&replacement);
rest = &after[end + 2..];
} else {
// Unclosed {{ — pass through
result.push_str("{{");
rest = after;
}
}
result.push_str(rest);
result
}
fn resolve_seen_list(session_id: &str, suffix: &str) -> String {
if session_id.is_empty() { return "(no session)".to_string(); }
let path = crate::store::memory_dir()
.join("sessions")
.join(format!("seen{}-{}", suffix, session_id));
let entries: Vec<(String, String)> = std::fs::read_to_string(&path).ok()
.map(|content| {
content.lines()
.filter(|s| !s.is_empty())
.filter_map(|line| {
let (ts, key) = line.split_once('\t')?;
Some((ts.to_string(), key.to_string()))
})
.collect()
})
.unwrap_or_default();
if entries.is_empty() { return "(none)".to_string(); }
let mut sorted = entries;
sorted.sort_by(|a, b| b.0.cmp(&a.0));
let mut seen = std::collections::HashSet::new();
sorted.into_iter()
.filter(|(_, key)| seen.insert(key.clone()))
.take(20)
.map(|(ts, key)| format!("- {} ({})", key, ts))
.collect::<Vec<_>>()
.join("\n")
}
/// Which pane streaming text should go to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamTarget {
@ -470,6 +540,12 @@ impl Mind {
.sum::<u64>()
};
// Get session_id for placeholder resolution
let session_id = {
let ag = self.agent.lock().await;
ag.session_id.clone()
};
// Collect which agents to trigger (can't hold lock across await)
let to_trigger: Vec<(usize, Vec<AutoStep>, Vec<crate::agent::tools::Tool>, String, i32)> = {
let mut subs = self.subconscious.lock().await;
@ -481,9 +557,14 @@ impl Mind {
let sub = &mut subs[i];
sub.last_trigger_bytes = conversation_bytes;
// The output dir for this agent — used for input: placeholders
// and the output() tool at runtime
let output_dir = crate::store::memory_dir()
.join("agent-output").join(&sub.name);
let steps: Vec<AutoStep> = sub.def.steps.iter().map(|s| {
// TODO: resolve remaining placeholders (seen_current, input:walked, etc.)
AutoStep { prompt: s.prompt.clone(), phase: s.phase.clone() }
let prompt = resolve_prompt(&s.prompt, &session_id, &output_dir);
AutoStep { prompt, phase: s.phase.clone() }
}).collect();
let all_tools = crate::agent::tools::memory_and_journal_tools();