AutoAgent: forked backend operates on Agent's ContextState directly

Instead of snapshotting assemble_api_messages() at construction, the
forked backend pushes step prompts and tool results into the agent's
context.entries and reassembles messages each turn. Standalone backend
(oneshot CLI) keeps the bare message list.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-07 01:12:54 -04:00
parent 0084b71bbf
commit 7c0d8b79d9

View file

@ -46,17 +46,13 @@ 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.
/// ///
/// For oneshot agents: created from the global API client with prompts /// Two backends:
/// from .agent files. Messages start empty. /// - Standalone: bare message list + global API client (oneshot CLI agents)
/// /// - Agent-backed: forked Agent whose ContextState is the conversation
/// For subconscious agents: created from a forked Agent whose context /// (subconscious agents, KV cache sharing with conscious agent)
/// provides the conversation prefix for KV cache sharing. Messages
/// start from the agent's assembled context.
pub struct AutoAgent { pub struct AutoAgent {
pub name: String, pub name: String,
client: ApiClient, backend: Backend,
tools: Vec<agent_tools::Tool>,
messages: Vec<Message>,
steps: Vec<AutoStep>, steps: Vec<AutoStep>,
next_step: usize, next_step: usize,
sampling: super::api::SamplingParams, sampling: super::api::SamplingParams,
@ -66,6 +62,57 @@ pub struct AutoAgent {
pub turn: usize, pub turn: usize,
} }
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.
Forked(Agent),
}
impl Backend {
fn client(&self) -> &ApiClient {
match self {
Backend::Standalone { client, .. } => client,
Backend::Forked(agent) => &agent.client,
}
}
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(),
Backend::Forked(agent) => agent.assemble_api_messages(),
}
}
fn push_message(&mut self, msg: Message) {
match self {
Backend::Standalone { messages, .. } => messages.push(msg),
Backend::Forked(agent) => agent.push_message(msg),
}
}
fn push_raw(&mut self, msg: Message) {
match self {
Backend::Standalone { messages, .. } => messages.push(msg),
Backend::Forked(agent) => {
agent.context.entries.push(
super::context::ConversationEntry::Message(msg));
}
}
}
}
impl AutoAgent { impl AutoAgent {
/// Create from the global API client with no initial context. /// Create from the global API client with no initial context.
/// Used by oneshot CLI agents. /// Used by oneshot CLI agents.
@ -80,9 +127,11 @@ impl AutoAgent {
let phase = steps.first().map(|s| s.phase.clone()).unwrap_or_default(); let phase = steps.first().map(|s| s.phase.clone()).unwrap_or_default();
Ok(Self { Ok(Self {
name, name,
backend: Backend::Standalone {
client, client,
tools, tools,
messages: Vec::new(), messages: Vec::new(),
},
steps, steps,
next_step: 0, next_step: 0,
sampling: super::api::SamplingParams { sampling: super::api::SamplingParams {
@ -96,9 +145,9 @@ impl AutoAgent {
}) })
} }
/// Fork from an existing agent for subconscious use. Clones the /// Fork from an existing agent for subconscious use. The forked
/// agent's context (system + personality + journal + entries) so /// agent's ContextState holds the conversation — step prompts and
/// the API sees the same token prefix → KV cache sharing. /// tool results are appended to it directly.
pub fn from_agent( pub fn from_agent(
name: String, name: String,
agent: &Agent, agent: &Agent,
@ -110,16 +159,14 @@ impl AutoAgent {
let phase = steps.first().map(|s| s.phase.clone()).unwrap_or_default(); let phase = steps.first().map(|s| s.phase.clone()).unwrap_or_default();
Self { Self {
name, name,
client: forked.client_clone(),
tools: forked.tools.clone(),
messages: forked.assemble_api_messages(),
steps,
next_step: 0,
sampling: super::api::SamplingParams { sampling: super::api::SamplingParams {
temperature: forked.temperature, temperature: forked.temperature,
top_p: forked.top_p, top_p: forked.top_p,
top_k: forked.top_k, top_k: forked.top_k,
}, },
backend: Backend::Forked(forked),
steps,
next_step: 0,
priority, priority,
current_phase: phase, current_phase: phase,
turn: 0, turn: 0,
@ -134,7 +181,8 @@ impl AutoAgent {
) -> Result<String, String> { ) -> Result<String, String> {
// Inject first step prompt // Inject first step prompt
if self.next_step < self.steps.len() { if self.next_step < self.steps.len() {
self.messages.push(Message::user(&self.steps[self.next_step].prompt)); self.backend.push_message(
Message::user(&self.steps[self.next_step].prompt));
self.next_step += 1; self.next_step += 1;
} }
@ -143,11 +191,13 @@ impl AutoAgent {
for _ in 0..max_turns { for _ in 0..max_turns {
self.turn += 1; self.turn += 1;
let messages = self.backend.messages();
log(&format!("\n=== TURN {} ({} messages) ===\n", log(&format!("\n=== TURN {} ({} messages) ===\n",
self.turn, self.messages.len())); self.turn, messages.len()));
// API call with retries // API call with retries
let (msg, usage_opt) = self.api_call_with_retry(&reasoning, log).await?; let (msg, usage_opt) = self.api_call_with_retry(
&messages, &reasoning, log).await?;
if let Some(u) = &usage_opt { if let Some(u) = &usage_opt {
log(&format!("tokens: {} prompt + {} completion", log(&format!("tokens: {} prompt + {} completion",
@ -166,7 +216,7 @@ impl AutoAgent {
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 {
log("empty response, retrying"); log("empty response, retrying");
self.messages.push(Message::user( self.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."
)); ));
@ -181,8 +231,9 @@ impl AutoAgent {
check(self.next_step)?; check(self.next_step)?;
} }
self.current_phase = self.steps[self.next_step].phase.clone(); self.current_phase = self.steps[self.next_step].phase.clone();
self.messages.push(Message::assistant(&text)); self.backend.push_message(Message::assistant(&text));
self.messages.push(Message::user(&self.steps[self.next_step].prompt)); self.backend.push_message(
Message::user(&self.steps[self.next_step].prompt));
self.next_step += 1; self.next_step += 1;
log(&format!("\n=== STEP {}/{} ===\n", log(&format!("\n=== STEP {}/{} ===\n",
self.next_step, self.steps.len())); self.next_step, self.steps.len()));
@ -197,14 +248,17 @@ impl AutoAgent {
async fn api_call_with_retry( async fn api_call_with_retry(
&self, &self,
messages: &[Message],
reasoning: &str, reasoning: &str,
log: &dyn Fn(&str), log: &dyn Fn(&str),
) -> Result<(Message, Option<Usage>), String> { ) -> Result<(Message, Option<Usage>), String> {
let client = self.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 self.client.chat_completion_stream_temp( match client.chat_completion_stream_temp(
&self.messages, messages,
&self.tools, tools,
reasoning, reasoning,
self.sampling, self.sampling,
Some(self.priority), Some(self.priority),
@ -229,13 +283,13 @@ impl AutoAgent {
last_err = Some(e); last_err = Some(e);
continue; continue;
} }
let msg_bytes: usize = self.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 on turn {} (~{}KB, {} messages, {} attempts): {}",
self.name, self.turn, msg_bytes / 1024, self.name, self.turn, msg_bytes / 1024,
self.messages.len(), attempt + 1, e)); messages.len(), attempt + 1, e));
} }
} }
} }
@ -254,7 +308,7 @@ impl AutoAgent {
} }
} }
} }
self.messages.push(sanitized); self.backend.push_raw(sanitized);
for call in msg.tool_calls.as_ref().unwrap() { for call in msg.tool_calls.as_ref().unwrap() {
log(&format!("\nTOOL CALL: {}({})", log(&format!("\nTOOL CALL: {}({})",
@ -264,7 +318,7 @@ impl AutoAgent {
Ok(v) => v, Ok(v) => v,
Err(_) => { Err(_) => {
log(&format!("malformed tool call args: {}", &call.function.arguments)); log(&format!("malformed tool call args: {}", &call.function.arguments));
self.messages.push(Message::tool_result( self.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.",
@ -283,7 +337,7 @@ impl AutoAgent {
log(&format!("Result: {}", preview)); log(&format!("Result: {}", preview));
} }
self.messages.push(Message::tool_result(&call.id, &output)); self.backend.push_raw(Message::tool_result(&call.id, &output));
} }
} }
} }