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:
parent
0084b71bbf
commit
7c0d8b79d9
1 changed files with 88 additions and 34 deletions
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue