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.
|
||||
///
|
||||
/// Two backends:
|
||||
/// - Standalone: bare message list + global API client (oneshot CLI agents)
|
||||
/// - Agent-backed: forked Agent whose ContextState is the conversation
|
||||
/// (subconscious agents, KV cache sharing with conscious agent)
|
||||
/// Persistent across runs — holds config, tools, steps, and inter-run
|
||||
/// state (walked keys). The conversation backend is ephemeral per run.
|
||||
pub struct AutoAgent {
|
||||
pub name: String,
|
||||
backend: Backend,
|
||||
steps: Vec<AutoStep>,
|
||||
next_step: usize,
|
||||
pub tools: Vec<agent_tools::Tool>,
|
||||
pub steps: Vec<AutoStep>,
|
||||
sampling: super::api::SamplingParams,
|
||||
priority: i32,
|
||||
/// Memory keys the surface agent was exploring — persists between runs.
|
||||
pub walked: Vec<String>,
|
||||
// Observable status
|
||||
pub current_phase: String,
|
||||
pub turn: usize,
|
||||
}
|
||||
|
||||
/// Per-run conversation backend — created fresh by run() or run_forked().
|
||||
enum Backend {
|
||||
/// Standalone: raw message list, no Agent context.
|
||||
Standalone {
|
||||
client: ApiClient,
|
||||
tools: Vec<agent_tools::Tool>,
|
||||
messages: Vec<Message>,
|
||||
},
|
||||
/// Backed by a forked Agent — conversation lives in ContextState.
|
||||
Standalone { client: ApiClient, messages: Vec<Message> },
|
||||
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> {
|
||||
match self {
|
||||
Backend::Standalone { messages, .. } => messages.clone(),
|
||||
|
|
@ -113,88 +100,120 @@ impl Backend {
|
|||
}
|
||||
|
||||
fn log(&self, text: String) {
|
||||
match self {
|
||||
Backend::Forked(agent) => {
|
||||
if let Some(ref log) = agent.conversation_log {
|
||||
let entry = super::context::ConversationEntry::Log(text);
|
||||
log.append(&entry).ok();
|
||||
}
|
||||
if let Backend::Forked(agent) = self {
|
||||
if let Some(ref log) = agent.conversation_log {
|
||||
let entry = super::context::ConversationEntry::Log(text);
|
||||
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 {
|
||||
/// Create from the global API client with no initial context.
|
||||
/// Used by oneshot CLI agents.
|
||||
pub fn new(
|
||||
name: String,
|
||||
tools: Vec<agent_tools::Tool>,
|
||||
steps: Vec<AutoStep>,
|
||||
temperature: f32,
|
||||
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 {
|
||||
let forked = agent.fork(tools);
|
||||
let phase = steps.first().map(|s| s.phase.clone()).unwrap_or_default();
|
||||
Self {
|
||||
name,
|
||||
name, tools, steps,
|
||||
sampling: super::api::SamplingParams {
|
||||
temperature: forked.temperature,
|
||||
top_p: forked.top_p,
|
||||
top_k: forked.top_k,
|
||||
temperature, top_p: 0.95, top_k: 20,
|
||||
},
|
||||
backend: Backend::Forked(forked),
|
||||
steps,
|
||||
next_step: 0,
|
||||
priority,
|
||||
current_phase: phase,
|
||||
walked: Vec::new(),
|
||||
current_phase: String::new(),
|
||||
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(
|
||||
&mut self,
|
||||
bail_fn: Option<&(dyn Fn(usize) -> Result<(), String> + Sync)>,
|
||||
) -> Result<String, String> {
|
||||
// Inject first step prompt
|
||||
if self.next_step < self.steps.len() {
|
||||
self.backend.push_message(
|
||||
Message::user(&self.steps[self.next_step].prompt));
|
||||
self.next_step += 1;
|
||||
let client = get_client()?.clone();
|
||||
let mut backend = Backend::Standalone {
|
||||
client, messages: Vec::new(),
|
||||
};
|
||||
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();
|
||||
|
|
@ -202,14 +221,16 @@ impl AutoAgent {
|
|||
|
||||
for _ in 0..max_turns {
|
||||
self.turn += 1;
|
||||
let messages = self.backend.messages();
|
||||
self.backend.log(format!("turn {} ({} messages)",
|
||||
let messages = backend.messages();
|
||||
backend.log(format!("turn {} ({} messages)",
|
||||
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 {
|
||||
self.backend.log(format!("tokens: {} prompt + {} completion",
|
||||
backend.log(format!("tokens: {} prompt + {} completion",
|
||||
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());
|
||||
|
||||
if has_tools {
|
||||
self.dispatch_tools(&msg).await;
|
||||
Self::dispatch_tools(backend, &msg).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Text-only response — step complete
|
||||
let text = msg.content_text().to_string();
|
||||
if text.is_empty() && !has_content {
|
||||
self.backend.log("empty response, retrying".into());
|
||||
self.backend.push_message(Message::user(
|
||||
backend.log("empty response, retrying".into());
|
||||
backend.push_message(Message::user(
|
||||
"[system] Your previous response was empty. \
|
||||
Please respond with text or use a tool."
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
self.backend.log(format!("response: {}",
|
||||
backend.log(format!("response: {}",
|
||||
&text[..text.len().min(200)]));
|
||||
|
||||
// More steps? Check bail, inject next prompt.
|
||||
if self.next_step < self.steps.len() {
|
||||
if next_step < self.steps.len() {
|
||||
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.backend.push_message(Message::assistant(&text));
|
||||
self.backend.push_message(
|
||||
Message::user(&self.steps[self.next_step].prompt));
|
||||
self.next_step += 1;
|
||||
self.backend.log(format!("step {}/{}",
|
||||
self.next_step, self.steps.len()));
|
||||
self.current_phase = self.steps[next_step].phase.clone();
|
||||
backend.push_message(Message::assistant(&text));
|
||||
backend.push_message(
|
||||
Message::user(&self.steps[next_step].prompt));
|
||||
next_step += 1;
|
||||
backend.log(format!("step {}/{}",
|
||||
next_step, self.steps.len()));
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -257,24 +276,23 @@ impl AutoAgent {
|
|||
}
|
||||
|
||||
async fn api_call_with_retry(
|
||||
&self,
|
||||
name: &str,
|
||||
backend: &Backend,
|
||||
tools: &[agent_tools::Tool],
|
||||
messages: &[Message],
|
||||
reasoning: &str,
|
||||
sampling: super::api::SamplingParams,
|
||||
priority: i32,
|
||||
) -> Result<(Message, Option<Usage>), String> {
|
||||
let client = self.backend.client();
|
||||
let tools = self.backend.tools();
|
||||
let client = backend.client();
|
||||
let mut last_err = None;
|
||||
for attempt in 0..5 {
|
||||
match client.chat_completion_stream_temp(
|
||||
messages,
|
||||
tools,
|
||||
reasoning,
|
||||
self.sampling,
|
||||
Some(self.priority),
|
||||
messages, tools, reasoning, sampling, Some(priority),
|
||||
).await {
|
||||
Ok((msg, usage)) => {
|
||||
if let Some(ref e) = last_err {
|
||||
self.backend.log(format!(
|
||||
backend.log(format!(
|
||||
"succeeded after retry (previous: {})", e));
|
||||
}
|
||||
return Ok((msg, usage));
|
||||
|
|
@ -287,7 +305,7 @@ impl AutoAgent {
|
|||
|| err_str.contains("timed out")
|
||||
|| err_str.contains("Connection refused");
|
||||
if is_transient && attempt < 4 {
|
||||
self.backend.log(format!(
|
||||
backend.log(format!(
|
||||
"transient error (attempt {}): {}, retrying",
|
||||
attempt + 1, err_str));
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2 << attempt)).await;
|
||||
|
|
@ -295,11 +313,10 @@ impl AutoAgent {
|
|||
continue;
|
||||
}
|
||||
let msg_bytes: usize = messages.iter()
|
||||
.map(|m| m.content_text().len())
|
||||
.sum();
|
||||
.map(|m| m.content_text().len()).sum();
|
||||
return Err(format!(
|
||||
"{}: API error on turn {} (~{}KB, {} messages, {} attempts): {}",
|
||||
self.name, self.turn, msg_bytes / 1024,
|
||||
"{}: API error (~{}KB, {} messages, {} attempts): {}",
|
||||
name, msg_bytes / 1024,
|
||||
messages.len(), attempt + 1, e));
|
||||
}
|
||||
}
|
||||
|
|
@ -307,28 +324,28 @@ impl AutoAgent {
|
|||
unreachable!()
|
||||
}
|
||||
|
||||
async fn dispatch_tools(&mut self, msg: &Message) {
|
||||
async fn dispatch_tools(backend: &mut Backend, msg: &Message) {
|
||||
let mut sanitized = msg.clone();
|
||||
if let Some(ref mut calls) = sanitized.tool_calls {
|
||||
for call in calls {
|
||||
if serde_json::from_str::<serde_json::Value>(&call.function.arguments).is_err() {
|
||||
self.backend.log(format!(
|
||||
backend.log(format!(
|
||||
"sanitizing malformed args for {}: {}",
|
||||
call.function.name, &call.function.arguments));
|
||||
call.function.arguments = "{}".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.backend.push_raw(sanitized);
|
||||
backend.push_raw(sanitized);
|
||||
|
||||
for call in msg.tool_calls.as_ref().unwrap() {
|
||||
self.backend.log(format!("tool: {}({})",
|
||||
backend.log(format!("tool: {}({})",
|
||||
call.function.name, &call.function.arguments));
|
||||
|
||||
let args: serde_json::Value = match serde_json::from_str(&call.function.arguments) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
self.backend.push_raw(Message::tool_result(
|
||||
backend.push_raw(Message::tool_result(
|
||||
&call.id,
|
||||
"Error: your tool call had malformed JSON arguments. \
|
||||
Please retry with valid JSON.",
|
||||
|
|
@ -338,9 +355,8 @@ impl AutoAgent {
|
|||
};
|
||||
|
||||
let output = agent_tools::dispatch(&call.function.name, &args).await;
|
||||
self.backend.log(format!("result: {} chars", output.len()));
|
||||
|
||||
self.backend.push_raw(Message::tool_result(&call.id, &output));
|
||||
backend.log(format!("result: {} chars", output.len()));
|
||||
backend.push_raw(Message::tool_result(&call.id, &output));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -498,7 +514,7 @@ pub async fn call_api_with_tools(
|
|||
steps,
|
||||
temperature.unwrap_or(0.6),
|
||||
priority,
|
||||
)?;
|
||||
);
|
||||
auto.run(bail_fn).await
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue