AutoAgent: persistent across runs, run() vs run_forked()
AutoAgent holds config + walked state. Backend is ephemeral per run:
- run(): standalone, global API client (oneshot CLI)
- run_forked(): forks conscious agent, resolves prompt templates
with current memory_keys and walked state
Mind creates AutoAgents once at startup, takes them out for spawned
tasks, puts them back on completion (preserving walked state).
Removes {{seen_previous}}, {{input:walked}}, {{memory_ratio}} from
subconscious agent prompts. Walked keys are now a Vec on AutoAgent,
resolved via {{walked}} from in-memory state.
Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
parent
ba62e0a767
commit
94ddf7b189
5 changed files with 238 additions and 247 deletions
|
|
@ -46,30 +46,24 @@ pub struct AutoStep {
|
||||||
|
|
||||||
/// An autonomous agent that runs a sequence of prompts with tool dispatch.
|
/// An autonomous agent that runs a sequence of prompts with tool dispatch.
|
||||||
///
|
///
|
||||||
/// Two backends:
|
/// Persistent across runs — holds config, tools, steps, and inter-run
|
||||||
/// - Standalone: bare message list + global API client (oneshot CLI agents)
|
/// state (walked keys). The conversation backend is ephemeral per run.
|
||||||
/// - Agent-backed: forked Agent whose ContextState is the conversation
|
|
||||||
/// (subconscious agents, KV cache sharing with conscious agent)
|
|
||||||
pub struct AutoAgent {
|
pub struct AutoAgent {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
backend: Backend,
|
pub tools: Vec<agent_tools::Tool>,
|
||||||
steps: Vec<AutoStep>,
|
pub steps: Vec<AutoStep>,
|
||||||
next_step: usize,
|
|
||||||
sampling: super::api::SamplingParams,
|
sampling: super::api::SamplingParams,
|
||||||
priority: i32,
|
priority: i32,
|
||||||
|
/// Memory keys the surface agent was exploring — persists between runs.
|
||||||
|
pub walked: Vec<String>,
|
||||||
// Observable status
|
// Observable status
|
||||||
pub current_phase: String,
|
pub current_phase: String,
|
||||||
pub turn: usize,
|
pub turn: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-run conversation backend — created fresh by run() or run_forked().
|
||||||
enum Backend {
|
enum Backend {
|
||||||
/// Standalone: raw message list, no Agent context.
|
Standalone { client: ApiClient, messages: Vec<Message> },
|
||||||
Standalone {
|
|
||||||
client: ApiClient,
|
|
||||||
tools: Vec<agent_tools::Tool>,
|
|
||||||
messages: Vec<Message>,
|
|
||||||
},
|
|
||||||
/// Backed by a forked Agent — conversation lives in ContextState.
|
|
||||||
Forked(Agent),
|
Forked(Agent),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,13 +75,6 @@ impl Backend {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tools(&self) -> &[agent_tools::Tool] {
|
|
||||||
match self {
|
|
||||||
Backend::Standalone { tools, .. } => tools,
|
|
||||||
Backend::Forked(agent) => &agent.tools,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn messages(&self) -> Vec<Message> {
|
fn messages(&self) -> Vec<Message> {
|
||||||
match self {
|
match self {
|
||||||
Backend::Standalone { messages, .. } => messages.clone(),
|
Backend::Standalone { messages, .. } => messages.clone(),
|
||||||
|
|
@ -113,88 +100,120 @@ impl Backend {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn log(&self, text: String) {
|
fn log(&self, text: String) {
|
||||||
match self {
|
if let Backend::Forked(agent) = self {
|
||||||
Backend::Forked(agent) => {
|
|
||||||
if let Some(ref log) = agent.conversation_log {
|
if let Some(ref log) = agent.conversation_log {
|
||||||
let entry = super::context::ConversationEntry::Log(text);
|
let entry = super::context::ConversationEntry::Log(text);
|
||||||
log.append(&entry).ok();
|
log.append(&entry).ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve {{placeholder}} templates in subconscious agent prompts.
|
||||||
|
fn resolve_prompt(template: &str, memory_keys: &[String], walked: &[String]) -> 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" => format_key_list(memory_keys),
|
||||||
|
"walked" => format_key_list(walked),
|
||||||
|
_ => {
|
||||||
|
result.push_str("{{");
|
||||||
|
result.push_str(&after[..end + 2]);
|
||||||
|
rest = &after[end + 2..];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
result.push_str(&replacement);
|
||||||
|
rest = &after[end + 2..];
|
||||||
|
} else {
|
||||||
|
result.push_str("{{");
|
||||||
|
rest = after;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push_str(rest);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_key_list(keys: &[String]) -> String {
|
||||||
|
if keys.is_empty() { "(none)".to_string() }
|
||||||
|
else { keys.iter().map(|k| format!("- {}", k)).collect::<Vec<_>>().join("\n") }
|
||||||
|
}
|
||||||
|
|
||||||
impl AutoAgent {
|
impl AutoAgent {
|
||||||
/// Create from the global API client with no initial context.
|
|
||||||
/// Used by oneshot CLI agents.
|
|
||||||
pub fn new(
|
pub fn new(
|
||||||
name: String,
|
name: String,
|
||||||
tools: Vec<agent_tools::Tool>,
|
tools: Vec<agent_tools::Tool>,
|
||||||
steps: Vec<AutoStep>,
|
steps: Vec<AutoStep>,
|
||||||
temperature: f32,
|
temperature: f32,
|
||||||
priority: i32,
|
priority: i32,
|
||||||
) -> Result<Self, String> {
|
|
||||||
let client = get_client()?.clone();
|
|
||||||
let phase = steps.first().map(|s| s.phase.clone()).unwrap_or_default();
|
|
||||||
Ok(Self {
|
|
||||||
name,
|
|
||||||
backend: Backend::Standalone {
|
|
||||||
client,
|
|
||||||
tools,
|
|
||||||
messages: Vec::new(),
|
|
||||||
},
|
|
||||||
steps,
|
|
||||||
next_step: 0,
|
|
||||||
sampling: super::api::SamplingParams {
|
|
||||||
temperature,
|
|
||||||
top_p: 0.95,
|
|
||||||
top_k: 20,
|
|
||||||
},
|
|
||||||
priority,
|
|
||||||
current_phase: phase,
|
|
||||||
turn: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fork from an existing agent for subconscious use. The forked
|
|
||||||
/// agent's ContextState holds the conversation — step prompts and
|
|
||||||
/// tool results are appended to it directly.
|
|
||||||
pub fn from_agent(
|
|
||||||
name: String,
|
|
||||||
agent: &Agent,
|
|
||||||
tools: Vec<agent_tools::Tool>,
|
|
||||||
steps: Vec<AutoStep>,
|
|
||||||
priority: i32,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let forked = agent.fork(tools);
|
|
||||||
let phase = steps.first().map(|s| s.phase.clone()).unwrap_or_default();
|
|
||||||
Self {
|
Self {
|
||||||
name,
|
name, tools, steps,
|
||||||
sampling: super::api::SamplingParams {
|
sampling: super::api::SamplingParams {
|
||||||
temperature: forked.temperature,
|
temperature, top_p: 0.95, top_k: 20,
|
||||||
top_p: forked.top_p,
|
|
||||||
top_k: forked.top_k,
|
|
||||||
},
|
},
|
||||||
backend: Backend::Forked(forked),
|
|
||||||
steps,
|
|
||||||
next_step: 0,
|
|
||||||
priority,
|
priority,
|
||||||
current_phase: phase,
|
walked: Vec::new(),
|
||||||
|
current_phase: String::new(),
|
||||||
turn: 0,
|
turn: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run all steps to completion. Returns the final text response.
|
/// Run standalone — creates a fresh message list from the global
|
||||||
|
/// API client. Used by oneshot CLI agents.
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
&mut self,
|
&mut self,
|
||||||
bail_fn: Option<&(dyn Fn(usize) -> Result<(), String> + Sync)>,
|
bail_fn: Option<&(dyn Fn(usize) -> Result<(), String> + Sync)>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
// Inject first step prompt
|
let client = get_client()?.clone();
|
||||||
if self.next_step < self.steps.len() {
|
let mut backend = Backend::Standalone {
|
||||||
self.backend.push_message(
|
client, messages: Vec::new(),
|
||||||
Message::user(&self.steps[self.next_step].prompt));
|
};
|
||||||
self.next_step += 1;
|
self.run_with_backend(&mut backend, bail_fn).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run forked from a conscious agent's context. Each call gets a
|
||||||
|
/// fresh fork for KV cache sharing. Walked state persists between runs.
|
||||||
|
///
|
||||||
|
/// `memory_keys`: keys of Memory entries in the conscious agent's
|
||||||
|
/// context, used to resolve {{seen_current}} in prompt templates.
|
||||||
|
pub async fn run_forked(
|
||||||
|
&mut self,
|
||||||
|
agent: &Agent,
|
||||||
|
memory_keys: &[String],
|
||||||
|
) -> Result<String, String> {
|
||||||
|
// Resolve prompt templates with current state
|
||||||
|
let resolved_steps: Vec<AutoStep> = self.steps.iter().map(|s| AutoStep {
|
||||||
|
prompt: resolve_prompt(&s.prompt, memory_keys, &self.walked),
|
||||||
|
phase: s.phase.clone(),
|
||||||
|
}).collect();
|
||||||
|
let orig_steps = std::mem::replace(&mut self.steps, resolved_steps);
|
||||||
|
let forked = agent.fork(self.tools.clone());
|
||||||
|
let mut backend = Backend::Forked(forked);
|
||||||
|
let result = self.run_with_backend(&mut backend, None).await;
|
||||||
|
self.steps = orig_steps; // restore templates
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_with_backend(
|
||||||
|
&mut self,
|
||||||
|
backend: &mut Backend,
|
||||||
|
bail_fn: Option<&(dyn Fn(usize) -> Result<(), String> + Sync)>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
self.turn = 0;
|
||||||
|
self.current_phase = self.steps.first()
|
||||||
|
.map(|s| s.phase.clone()).unwrap_or_default();
|
||||||
|
let mut next_step = 0;
|
||||||
|
|
||||||
|
if next_step < self.steps.len() {
|
||||||
|
backend.push_message(
|
||||||
|
Message::user(&self.steps[next_step].prompt));
|
||||||
|
next_step += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let reasoning = crate::config::get().api_reasoning.clone();
|
let reasoning = crate::config::get().api_reasoning.clone();
|
||||||
|
|
@ -202,14 +221,16 @@ impl AutoAgent {
|
||||||
|
|
||||||
for _ in 0..max_turns {
|
for _ in 0..max_turns {
|
||||||
self.turn += 1;
|
self.turn += 1;
|
||||||
let messages = self.backend.messages();
|
let messages = backend.messages();
|
||||||
self.backend.log(format!("turn {} ({} messages)",
|
backend.log(format!("turn {} ({} messages)",
|
||||||
self.turn, messages.len()));
|
self.turn, messages.len()));
|
||||||
|
|
||||||
let (msg, usage_opt) = self.api_call_with_retry(&messages, &reasoning).await?;
|
let (msg, usage_opt) = Self::api_call_with_retry(
|
||||||
|
&self.name, backend, &self.tools, &messages,
|
||||||
|
&reasoning, self.sampling, self.priority).await?;
|
||||||
|
|
||||||
if let Some(u) = &usage_opt {
|
if let Some(u) = &usage_opt {
|
||||||
self.backend.log(format!("tokens: {} prompt + {} completion",
|
backend.log(format!("tokens: {} prompt + {} completion",
|
||||||
u.prompt_tokens, u.completion_tokens));
|
u.prompt_tokens, u.completion_tokens));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,36 +238,34 @@ impl AutoAgent {
|
||||||
let has_tools = msg.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
|
let has_tools = msg.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
|
||||||
|
|
||||||
if has_tools {
|
if has_tools {
|
||||||
self.dispatch_tools(&msg).await;
|
Self::dispatch_tools(backend, &msg).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text-only response — step complete
|
|
||||||
let text = msg.content_text().to_string();
|
let text = msg.content_text().to_string();
|
||||||
if text.is_empty() && !has_content {
|
if text.is_empty() && !has_content {
|
||||||
self.backend.log("empty response, retrying".into());
|
backend.log("empty response, retrying".into());
|
||||||
self.backend.push_message(Message::user(
|
backend.push_message(Message::user(
|
||||||
"[system] Your previous response was empty. \
|
"[system] Your previous response was empty. \
|
||||||
Please respond with text or use a tool."
|
Please respond with text or use a tool."
|
||||||
));
|
));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.backend.log(format!("response: {}",
|
backend.log(format!("response: {}",
|
||||||
&text[..text.len().min(200)]));
|
&text[..text.len().min(200)]));
|
||||||
|
|
||||||
// More steps? Check bail, inject next prompt.
|
if next_step < self.steps.len() {
|
||||||
if self.next_step < self.steps.len() {
|
|
||||||
if let Some(ref check) = bail_fn {
|
if let Some(ref check) = bail_fn {
|
||||||
check(self.next_step)?;
|
check(next_step)?;
|
||||||
}
|
}
|
||||||
self.current_phase = self.steps[self.next_step].phase.clone();
|
self.current_phase = self.steps[next_step].phase.clone();
|
||||||
self.backend.push_message(Message::assistant(&text));
|
backend.push_message(Message::assistant(&text));
|
||||||
self.backend.push_message(
|
backend.push_message(
|
||||||
Message::user(&self.steps[self.next_step].prompt));
|
Message::user(&self.steps[next_step].prompt));
|
||||||
self.next_step += 1;
|
next_step += 1;
|
||||||
self.backend.log(format!("step {}/{}",
|
backend.log(format!("step {}/{}",
|
||||||
self.next_step, self.steps.len()));
|
next_step, self.steps.len()));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -257,24 +276,23 @@ impl AutoAgent {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn api_call_with_retry(
|
async fn api_call_with_retry(
|
||||||
&self,
|
name: &str,
|
||||||
|
backend: &Backend,
|
||||||
|
tools: &[agent_tools::Tool],
|
||||||
messages: &[Message],
|
messages: &[Message],
|
||||||
reasoning: &str,
|
reasoning: &str,
|
||||||
|
sampling: super::api::SamplingParams,
|
||||||
|
priority: i32,
|
||||||
) -> Result<(Message, Option<Usage>), String> {
|
) -> Result<(Message, Option<Usage>), String> {
|
||||||
let client = self.backend.client();
|
let client = backend.client();
|
||||||
let tools = self.backend.tools();
|
|
||||||
let mut last_err = None;
|
let mut last_err = None;
|
||||||
for attempt in 0..5 {
|
for attempt in 0..5 {
|
||||||
match client.chat_completion_stream_temp(
|
match client.chat_completion_stream_temp(
|
||||||
messages,
|
messages, tools, reasoning, sampling, Some(priority),
|
||||||
tools,
|
|
||||||
reasoning,
|
|
||||||
self.sampling,
|
|
||||||
Some(self.priority),
|
|
||||||
).await {
|
).await {
|
||||||
Ok((msg, usage)) => {
|
Ok((msg, usage)) => {
|
||||||
if let Some(ref e) = last_err {
|
if let Some(ref e) = last_err {
|
||||||
self.backend.log(format!(
|
backend.log(format!(
|
||||||
"succeeded after retry (previous: {})", e));
|
"succeeded after retry (previous: {})", e));
|
||||||
}
|
}
|
||||||
return Ok((msg, usage));
|
return Ok((msg, usage));
|
||||||
|
|
@ -287,7 +305,7 @@ impl AutoAgent {
|
||||||
|| err_str.contains("timed out")
|
|| err_str.contains("timed out")
|
||||||
|| err_str.contains("Connection refused");
|
|| err_str.contains("Connection refused");
|
||||||
if is_transient && attempt < 4 {
|
if is_transient && attempt < 4 {
|
||||||
self.backend.log(format!(
|
backend.log(format!(
|
||||||
"transient error (attempt {}): {}, retrying",
|
"transient error (attempt {}): {}, retrying",
|
||||||
attempt + 1, err_str));
|
attempt + 1, err_str));
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(2 << attempt)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(2 << attempt)).await;
|
||||||
|
|
@ -295,11 +313,10 @@ impl AutoAgent {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let msg_bytes: usize = messages.iter()
|
let msg_bytes: usize = messages.iter()
|
||||||
.map(|m| m.content_text().len())
|
.map(|m| m.content_text().len()).sum();
|
||||||
.sum();
|
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"{}: API error on turn {} (~{}KB, {} messages, {} attempts): {}",
|
"{}: API error (~{}KB, {} messages, {} attempts): {}",
|
||||||
self.name, self.turn, msg_bytes / 1024,
|
name, msg_bytes / 1024,
|
||||||
messages.len(), attempt + 1, e));
|
messages.len(), attempt + 1, e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -307,28 +324,28 @@ impl AutoAgent {
|
||||||
unreachable!()
|
unreachable!()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn dispatch_tools(&mut self, msg: &Message) {
|
async fn dispatch_tools(backend: &mut Backend, msg: &Message) {
|
||||||
let mut sanitized = msg.clone();
|
let mut sanitized = msg.clone();
|
||||||
if let Some(ref mut calls) = sanitized.tool_calls {
|
if let Some(ref mut calls) = sanitized.tool_calls {
|
||||||
for call in calls {
|
for call in calls {
|
||||||
if serde_json::from_str::<serde_json::Value>(&call.function.arguments).is_err() {
|
if serde_json::from_str::<serde_json::Value>(&call.function.arguments).is_err() {
|
||||||
self.backend.log(format!(
|
backend.log(format!(
|
||||||
"sanitizing malformed args for {}: {}",
|
"sanitizing malformed args for {}: {}",
|
||||||
call.function.name, &call.function.arguments));
|
call.function.name, &call.function.arguments));
|
||||||
call.function.arguments = "{}".to_string();
|
call.function.arguments = "{}".to_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.backend.push_raw(sanitized);
|
backend.push_raw(sanitized);
|
||||||
|
|
||||||
for call in msg.tool_calls.as_ref().unwrap() {
|
for call in msg.tool_calls.as_ref().unwrap() {
|
||||||
self.backend.log(format!("tool: {}({})",
|
backend.log(format!("tool: {}({})",
|
||||||
call.function.name, &call.function.arguments));
|
call.function.name, &call.function.arguments));
|
||||||
|
|
||||||
let args: serde_json::Value = match serde_json::from_str(&call.function.arguments) {
|
let args: serde_json::Value = match serde_json::from_str(&call.function.arguments) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
self.backend.push_raw(Message::tool_result(
|
backend.push_raw(Message::tool_result(
|
||||||
&call.id,
|
&call.id,
|
||||||
"Error: your tool call had malformed JSON arguments. \
|
"Error: your tool call had malformed JSON arguments. \
|
||||||
Please retry with valid JSON.",
|
Please retry with valid JSON.",
|
||||||
|
|
@ -338,9 +355,8 @@ impl AutoAgent {
|
||||||
};
|
};
|
||||||
|
|
||||||
let output = agent_tools::dispatch(&call.function.name, &args).await;
|
let output = agent_tools::dispatch(&call.function.name, &args).await;
|
||||||
self.backend.log(format!("result: {} chars", output.len()));
|
backend.log(format!("result: {} chars", output.len()));
|
||||||
|
backend.push_raw(Message::tool_result(&call.id, &output));
|
||||||
self.backend.push_raw(Message::tool_result(&call.id, &output));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -498,7 +514,7 @@ pub async fn call_api_with_tools(
|
||||||
steps,
|
steps,
|
||||||
temperature.unwrap_or(0.6),
|
temperature.unwrap_or(0.6),
|
||||||
priority,
|
priority,
|
||||||
)?;
|
);
|
||||||
auto.run(bail_fn).await
|
auto.run(bail_fn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
214
src/mind/mod.rs
214
src/mind/mod.rs
|
|
@ -33,13 +33,12 @@ use crate::subconscious::{defs, learn};
|
||||||
|
|
||||||
/// A subconscious agent managed by Mind.
|
/// A subconscious agent managed by Mind.
|
||||||
struct SubconsciousAgent {
|
struct SubconsciousAgent {
|
||||||
name: String,
|
auto: AutoAgent,
|
||||||
def: defs::AgentDef,
|
|
||||||
/// Conversation bytes at last trigger.
|
/// Conversation bytes at last trigger.
|
||||||
last_trigger_bytes: u64,
|
last_trigger_bytes: u64,
|
||||||
/// When the agent last ran.
|
/// When the agent last ran.
|
||||||
last_run: Option<Instant>,
|
last_run: Option<Instant>,
|
||||||
/// Running task handle + AutoAgent for status.
|
/// Running task handle.
|
||||||
handle: Option<tokio::task::JoinHandle<Result<String, String>>>,
|
handle: Option<tokio::task::JoinHandle<Result<String, String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,11 +50,30 @@ const SUBCONSCIOUS_AGENTS: &[(&str, u64)] = &[
|
||||||
];
|
];
|
||||||
|
|
||||||
impl SubconsciousAgent {
|
impl SubconsciousAgent {
|
||||||
fn new(name: &str, interval_bytes: u64) -> Option<Self> {
|
fn new(name: &str, _interval_bytes: u64) -> Option<Self> {
|
||||||
let def = defs::get_def(name)?;
|
let def = defs::get_def(name)?;
|
||||||
|
|
||||||
|
let all_tools = crate::agent::tools::memory_and_journal_tools();
|
||||||
|
let tools: Vec<crate::agent::tools::Tool> = if def.tools.is_empty() {
|
||||||
|
all_tools.to_vec()
|
||||||
|
} else {
|
||||||
|
all_tools.into_iter()
|
||||||
|
.filter(|t| def.tools.iter().any(|w| w == t.name))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let steps: Vec<AutoStep> = def.steps.iter().map(|s| AutoStep {
|
||||||
|
prompt: s.prompt.clone(),
|
||||||
|
phase: s.phase.clone(),
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
let auto = AutoAgent::new(
|
||||||
|
name.to_string(), tools, steps,
|
||||||
|
def.temperature.unwrap_or(0.6), def.priority,
|
||||||
|
);
|
||||||
|
|
||||||
Some(Self {
|
Some(Self {
|
||||||
name: name.to_string(),
|
auto,
|
||||||
def,
|
|
||||||
last_trigger_bytes: 0,
|
last_trigger_bytes: 0,
|
||||||
last_run: None,
|
last_run: None,
|
||||||
handle: None,
|
handle: None,
|
||||||
|
|
@ -68,60 +86,11 @@ impl SubconsciousAgent {
|
||||||
|
|
||||||
fn should_trigger(&self, conversation_bytes: u64, interval: u64) -> bool {
|
fn should_trigger(&self, conversation_bytes: u64, interval: u64) -> bool {
|
||||||
if self.is_running() { return false; }
|
if self.is_running() { return false; }
|
||||||
if interval == 0 { return true; } // trigger every time
|
if interval == 0 { return true; }
|
||||||
conversation_bytes.saturating_sub(self.last_trigger_bytes) >= interval
|
conversation_bytes.saturating_sub(self.last_trigger_bytes) >= interval
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve {{placeholder}} templates in subconscious agent prompts.
|
|
||||||
/// Handles: seen_current, seen_previous, input:KEY.
|
|
||||||
/// Resolve {{placeholder}} templates in subconscious agent prompts.
|
|
||||||
fn resolve_prompt(
|
|
||||||
template: &str,
|
|
||||||
memory_keys: &[String],
|
|
||||||
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" | "seen_previous" => {
|
|
||||||
if memory_keys.is_empty() {
|
|
||||||
"(none)".to_string()
|
|
||||||
} else {
|
|
||||||
memory_keys.iter()
|
|
||||||
.map(|k| format!("- {}", k))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ 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 {
|
|
||||||
result.push_str("{{");
|
|
||||||
rest = after;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.push_str(rest);
|
|
||||||
result
|
|
||||||
}
|
|
||||||
/// Which pane streaming text should go to.
|
/// Which pane streaming text should go to.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum StreamTarget {
|
pub enum StreamTarget {
|
||||||
|
|
@ -296,7 +265,7 @@ pub struct Mind {
|
||||||
pub agent: Arc<tokio::sync::Mutex<Agent>>,
|
pub agent: Arc<tokio::sync::Mutex<Agent>>,
|
||||||
pub shared: Arc<SharedMindState>,
|
pub shared: Arc<SharedMindState>,
|
||||||
pub config: SessionConfig,
|
pub config: SessionConfig,
|
||||||
subconscious: tokio::sync::Mutex<Vec<SubconsciousAgent>>,
|
subconscious: Arc<tokio::sync::Mutex<Vec<SubconsciousAgent>>>,
|
||||||
turn_tx: mpsc::Sender<(Result<TurnResult>, StreamTarget)>,
|
turn_tx: mpsc::Sender<(Result<TurnResult>, StreamTarget)>,
|
||||||
turn_watch: tokio::sync::watch::Sender<bool>,
|
turn_watch: tokio::sync::watch::Sender<bool>,
|
||||||
bg_tx: mpsc::UnboundedSender<BgEvent>,
|
bg_tx: mpsc::UnboundedSender<BgEvent>,
|
||||||
|
|
@ -341,7 +310,7 @@ impl Mind {
|
||||||
sup.load_config();
|
sup.load_config();
|
||||||
sup.ensure_running();
|
sup.ensure_running();
|
||||||
|
|
||||||
Self { agent, shared, config, subconscious: tokio::sync::Mutex::new(subconscious),
|
Self { agent, shared, config, subconscious: Arc::new(tokio::sync::Mutex::new(subconscious)),
|
||||||
turn_tx, turn_watch, bg_tx,
|
turn_tx, turn_watch, bg_tx,
|
||||||
bg_rx: std::sync::Mutex::new(Some(bg_rx)), _supervisor: sup }
|
bg_rx: std::sync::Mutex::new(Some(bg_rx)), _supervisor: sup }
|
||||||
}
|
}
|
||||||
|
|
@ -447,24 +416,25 @@ impl Mind {
|
||||||
/// their output into the conscious agent's context.
|
/// their output into the conscious agent's context.
|
||||||
async fn collect_subconscious_results(&self) {
|
async fn collect_subconscious_results(&self) {
|
||||||
// Collect finished handles without holding the lock across await
|
// Collect finished handles without holding the lock across await
|
||||||
let finished: Vec<(String, tokio::task::JoinHandle<Result<String, String>>)> = {
|
let finished: Vec<(usize, tokio::task::JoinHandle<Result<String, String>>)> = {
|
||||||
let mut subs = self.subconscious.lock().await;
|
let mut subs = self.subconscious.lock().await;
|
||||||
subs.iter_mut().filter_map(|sub| {
|
subs.iter_mut().enumerate().filter_map(|(i, sub)| {
|
||||||
if sub.handle.as_ref().is_some_and(|h| h.is_finished()) {
|
if sub.handle.as_ref().is_some_and(|h| h.is_finished()) {
|
||||||
sub.last_run = Some(Instant::now());
|
sub.last_run = Some(Instant::now());
|
||||||
Some((sub.name.clone(), sub.handle.take().unwrap()))
|
Some((i, sub.handle.take().unwrap()))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}).collect()
|
}).collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
for (name, handle) in finished {
|
for (idx, handle) in finished {
|
||||||
match handle.await {
|
let name = self.subconscious.lock().await[idx].auto.name.clone();
|
||||||
Ok(Ok(_output)) => {
|
|
||||||
let output_dir = crate::store::memory_dir()
|
let output_dir = crate::store::memory_dir()
|
||||||
.join("agent-output").join(&name);
|
.join("agent-output").join(&name);
|
||||||
|
|
||||||
|
match handle.await {
|
||||||
|
Ok(Ok(_output)) => {
|
||||||
// Surfaced memories
|
// Surfaced memories
|
||||||
let surface_path = output_dir.join("surface");
|
let surface_path = output_dir.join("surface");
|
||||||
if let Ok(content) = std::fs::read_to_string(&surface_path) {
|
if let Ok(content) = std::fs::read_to_string(&surface_path) {
|
||||||
|
|
@ -486,6 +456,21 @@ impl Mind {
|
||||||
std::fs::remove_file(&surface_path).ok();
|
std::fs::remove_file(&surface_path).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Walked keys — store for next run
|
||||||
|
let walked_path = output_dir.join("walked");
|
||||||
|
if let Ok(content) = std::fs::read_to_string(&walked_path) {
|
||||||
|
let walked: Vec<String> = content.lines()
|
||||||
|
.map(|l| l.trim().to_string())
|
||||||
|
.filter(|l| !l.is_empty())
|
||||||
|
.collect();
|
||||||
|
// Store on all subconscious agents (shared state)
|
||||||
|
let mut subs = self.subconscious.lock().await;
|
||||||
|
for sub in subs.iter_mut() {
|
||||||
|
sub.auto.walked = walked.clone();
|
||||||
|
}
|
||||||
|
std::fs::remove_file(&walked_path).ok();
|
||||||
|
}
|
||||||
|
|
||||||
// Reflection
|
// Reflection
|
||||||
let reflect_path = output_dir.join("reflection");
|
let reflect_path = output_dir.join("reflection");
|
||||||
if let Ok(content) = std::fs::read_to_string(&reflect_path) {
|
if let Ok(content) = std::fs::read_to_string(&reflect_path) {
|
||||||
|
|
@ -511,89 +496,82 @@ impl Mind {
|
||||||
async fn trigger_subconscious(&self) {
|
async fn trigger_subconscious(&self) {
|
||||||
if self.config.no_agents { return; }
|
if self.config.no_agents { return; }
|
||||||
|
|
||||||
// Estimate conversation size from the conscious agent's entries
|
// Get conversation size + memory keys from conscious agent
|
||||||
let conversation_bytes = {
|
let (conversation_bytes, memory_keys) = {
|
||||||
let ag = self.agent.lock().await;
|
let ag = self.agent.lock().await;
|
||||||
ag.context.entries.iter()
|
let bytes = ag.context.entries.iter()
|
||||||
.filter(|e| !e.is_log() && !e.is_memory())
|
.filter(|e| !e.is_log() && !e.is_memory())
|
||||||
.map(|e| e.message().content_text().len() as u64)
|
.map(|e| e.message().content_text().len() as u64)
|
||||||
.sum::<u64>()
|
.sum::<u64>();
|
||||||
};
|
let keys: Vec<String> = ag.context.entries.iter().filter_map(|e| {
|
||||||
|
|
||||||
// Get memory keys from conscious agent for placeholder resolution
|
|
||||||
let memory_keys: Vec<String> = {
|
|
||||||
let ag = self.agent.lock().await;
|
|
||||||
ag.context.entries.iter().filter_map(|e| {
|
|
||||||
if let crate::agent::context::ConversationEntry::Memory { key, .. } = e {
|
if let crate::agent::context::ConversationEntry::Memory { key, .. } = e {
|
||||||
Some(key.clone())
|
Some(key.clone())
|
||||||
} else {
|
} else { None }
|
||||||
None
|
}).collect();
|
||||||
}
|
(bytes, keys)
|
||||||
}).collect()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Collect which agents to trigger (can't hold lock across await)
|
// Find which agents to trigger, take their AutoAgents out
|
||||||
let to_trigger: Vec<(usize, Vec<AutoStep>, Vec<crate::agent::tools::Tool>, String, i32)> = {
|
let mut to_run: Vec<(usize, AutoAgent)> = Vec::new();
|
||||||
|
{
|
||||||
let mut subs = self.subconscious.lock().await;
|
let mut subs = self.subconscious.lock().await;
|
||||||
let mut result = Vec::new();
|
|
||||||
for (i, &(_name, interval)) in SUBCONSCIOUS_AGENTS.iter().enumerate() {
|
for (i, &(_name, interval)) in SUBCONSCIOUS_AGENTS.iter().enumerate() {
|
||||||
if i >= subs.len() { continue; }
|
if i >= subs.len() { continue; }
|
||||||
if !subs[i].should_trigger(conversation_bytes, interval) { continue; }
|
if !subs[i].should_trigger(conversation_bytes, interval) { continue; }
|
||||||
|
subs[i].last_trigger_bytes = conversation_bytes;
|
||||||
|
|
||||||
let sub = &mut subs[i];
|
// Take the AutoAgent out — task owns it, returns it when done
|
||||||
sub.last_trigger_bytes = conversation_bytes;
|
let auto = std::mem::replace(&mut subs[i].auto,
|
||||||
|
AutoAgent::new(String::new(), vec![], vec![], 0.0, 0));
|
||||||
// The output dir for this agent — used for input: placeholders
|
to_run.push((i, auto));
|
||||||
// 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| {
|
|
||||||
let prompt = resolve_prompt(&s.prompt, &memory_keys, &output_dir);
|
|
||||||
AutoStep { prompt, phase: s.phase.clone() }
|
|
||||||
}).collect();
|
|
||||||
|
|
||||||
let all_tools = crate::agent::tools::memory_and_journal_tools();
|
|
||||||
let tools: Vec<crate::agent::tools::Tool> = if sub.def.tools.is_empty() {
|
|
||||||
all_tools.to_vec()
|
|
||||||
} else {
|
|
||||||
all_tools.into_iter()
|
|
||||||
.filter(|t| sub.def.tools.iter().any(|w| w == t.name))
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
|
|
||||||
result.push((i, steps, tools, sub.name.clone(), sub.def.priority));
|
|
||||||
}
|
}
|
||||||
result
|
|
||||||
};
|
|
||||||
|
|
||||||
if to_trigger.is_empty() { return; }
|
if to_run.is_empty() { return; }
|
||||||
|
|
||||||
// Fork from conscious agent (one lock acquisition for all)
|
// Fork from conscious agent and spawn tasks
|
||||||
let conscious = self.agent.lock().await;
|
let conscious = self.agent.lock().await;
|
||||||
let mut spawns = Vec::new();
|
let mut spawns = Vec::new();
|
||||||
for (idx, steps, tools, name, priority) in to_trigger {
|
for (idx, mut auto) in to_run {
|
||||||
let output_dir = crate::store::memory_dir()
|
let output_dir = crate::store::memory_dir()
|
||||||
.join("agent-output").join(&name);
|
.join("agent-output").join(&auto.name);
|
||||||
std::fs::create_dir_all(&output_dir).ok();
|
std::fs::create_dir_all(&output_dir).ok();
|
||||||
|
|
||||||
let mut auto = AutoAgent::from_agent(
|
dbglog!("[mind] triggering {}", auto.name);
|
||||||
name.clone(), &conscious, tools, steps, priority);
|
|
||||||
dbglog!("[mind] triggering {}", name);
|
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
let forked = conscious.fork(auto.tools.clone());
|
||||||
|
let keys = memory_keys.clone();
|
||||||
|
let handle: tokio::task::JoinHandle<(AutoAgent, Result<String, String>)> =
|
||||||
|
tokio::spawn(async move {
|
||||||
unsafe { std::env::set_var("POC_AGENT_OUTPUT_DIR", &output_dir); }
|
unsafe { std::env::set_var("POC_AGENT_OUTPUT_DIR", &output_dir); }
|
||||||
auto.run(None).await
|
let result = auto.run_forked(&forked, &keys).await;
|
||||||
|
(auto, result)
|
||||||
});
|
});
|
||||||
spawns.push((idx, handle));
|
spawns.push((idx, handle));
|
||||||
}
|
}
|
||||||
drop(conscious);
|
drop(conscious);
|
||||||
|
|
||||||
// Store handles
|
// Store handles (type-erased — we'll extract AutoAgent on completion)
|
||||||
let mut subs = self.subconscious.lock().await;
|
// We need to store the JoinHandle that returns (AutoAgent, Result)
|
||||||
|
// but SubconsciousAgent.handle expects JoinHandle<Result<String, String>>.
|
||||||
|
// Wrap: spawn an outer task that extracts the result and puts back the AutoAgent.
|
||||||
|
let subconscious = self.subconscious.clone();
|
||||||
for (idx, handle) in spawns {
|
for (idx, handle) in spawns {
|
||||||
|
let subs = subconscious.clone();
|
||||||
|
let outer = tokio::spawn(async move {
|
||||||
|
let (auto, result) = handle.await.unwrap_or_else(
|
||||||
|
|e| (AutoAgent::new(String::new(), vec![], vec![], 0.0, 0),
|
||||||
|
Err(format!("task panicked: {}", e))));
|
||||||
|
// Put the AutoAgent back
|
||||||
|
let mut locked = subs.lock().await;
|
||||||
|
if idx < locked.len() {
|
||||||
|
locked[idx].auto = auto;
|
||||||
|
}
|
||||||
|
result
|
||||||
|
});
|
||||||
|
let mut subs = self.subconscious.lock().await;
|
||||||
if idx < subs.len() {
|
if idx < subs.len() {
|
||||||
subs[idx].handle = Some(handle);
|
subs[idx].handle = Some(outer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ The full conversation is in context above — use it to understand what your
|
||||||
conscious self is doing and thinking about.
|
conscious self is doing and thinking about.
|
||||||
|
|
||||||
Nodes your subconscious recently touched (for linking, not duplicating):
|
Nodes your subconscious recently touched (for linking, not duplicating):
|
||||||
{{input:walked}}
|
{{walked}}
|
||||||
|
|
||||||
**Your tools:** journal_tail, journal_new, journal_update, memory_link_add,
|
**Your tools:** journal_tail, journal_new, journal_update, memory_link_add,
|
||||||
memory_search, memory_render, memory_used. Do NOT use memory_write — creating
|
memory_search, memory_render, memory_used. Do NOT use memory_write — creating
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ The full conversation is in context above — use it to understand what your
|
||||||
conscious self is doing and thinking about.
|
conscious self is doing and thinking about.
|
||||||
|
|
||||||
Memories your surface agent was exploring:
|
Memories your surface agent was exploring:
|
||||||
{{input:walked}}
|
{{walked}}
|
||||||
|
|
||||||
Start from the nodes surface-observe was walking. Render one or two that
|
Start from the nodes surface-observe was walking. Render one or two that
|
||||||
catch your attention — then ask "what does this mean?" Follow the links in
|
catch your attention — then ask "what does this mean?" Follow the links in
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,8 @@ for graph walks — new relevant memories are often nearby.
|
||||||
Already in current context (don't re-surface unless the conversation has shifted):
|
Already in current context (don't re-surface unless the conversation has shifted):
|
||||||
{{seen_current}}
|
{{seen_current}}
|
||||||
|
|
||||||
Surfaced before compaction (context was reset — re-surface if still relevant):
|
|
||||||
{{seen_previous}}
|
|
||||||
|
|
||||||
Memories you were exploring last time but hadn't surfaced yet:
|
Memories you were exploring last time but hadn't surfaced yet:
|
||||||
{{input:walked}}
|
{{walked}}
|
||||||
|
|
||||||
How focused is the current conversation? If it's more focused, look for the
|
How focused is the current conversation? If it's more focused, look for the
|
||||||
useful and relevant memories, When considering relevance, don't just look for
|
useful and relevant memories, When considering relevance, don't just look for
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue