From 7c0d8b79d9868e8c4ff84b55a0a75d6b57ead3ff Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Tue, 7 Apr 2026 01:12:54 -0400 Subject: [PATCH] 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 --- src/agent/oneshot.rs | 122 +++++++++++++++++++++++++++++++------------ 1 file changed, 88 insertions(+), 34 deletions(-) diff --git a/src/agent/oneshot.rs b/src/agent/oneshot.rs index 6250dd4..36788e5 100644 --- a/src/agent/oneshot.rs +++ b/src/agent/oneshot.rs @@ -46,17 +46,13 @@ pub struct AutoStep { /// An autonomous agent that runs a sequence of prompts with tool dispatch. /// -/// For oneshot agents: created from the global API client with prompts -/// from .agent files. Messages start empty. -/// -/// For subconscious agents: created from a forked Agent whose context -/// provides the conversation prefix for KV cache sharing. Messages -/// start from the agent's assembled context. +/// 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) pub struct AutoAgent { pub name: String, - client: ApiClient, - tools: Vec, - messages: Vec, + backend: Backend, steps: Vec, next_step: usize, sampling: super::api::SamplingParams, @@ -66,6 +62,57 @@ pub struct AutoAgent { pub turn: usize, } +enum Backend { + /// Standalone: raw message list, no Agent context. + Standalone { + client: ApiClient, + tools: Vec, + messages: Vec, + }, + /// 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 { + 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 { /// Create from the global API client with no initial context. /// Used by oneshot CLI agents. @@ -80,9 +127,11 @@ impl AutoAgent { let phase = steps.first().map(|s| s.phase.clone()).unwrap_or_default(); Ok(Self { name, - client, - tools, - messages: Vec::new(), + backend: Backend::Standalone { + client, + tools, + messages: Vec::new(), + }, steps, next_step: 0, sampling: super::api::SamplingParams { @@ -96,9 +145,9 @@ impl AutoAgent { }) } - /// Fork from an existing agent for subconscious use. Clones the - /// agent's context (system + personality + journal + entries) so - /// the API sees the same token prefix → KV cache sharing. + /// 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, @@ -110,16 +159,14 @@ impl AutoAgent { let phase = steps.first().map(|s| s.phase.clone()).unwrap_or_default(); Self { name, - client: forked.client_clone(), - tools: forked.tools.clone(), - messages: forked.assemble_api_messages(), - steps, - next_step: 0, sampling: super::api::SamplingParams { temperature: forked.temperature, top_p: forked.top_p, top_k: forked.top_k, }, + backend: Backend::Forked(forked), + steps, + next_step: 0, priority, current_phase: phase, turn: 0, @@ -134,7 +181,8 @@ impl AutoAgent { ) -> Result { // Inject first step prompt 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; } @@ -143,11 +191,13 @@ impl AutoAgent { for _ in 0..max_turns { self.turn += 1; + let messages = self.backend.messages(); log(&format!("\n=== TURN {} ({} messages) ===\n", - self.turn, self.messages.len())); + self.turn, messages.len())); // 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 { log(&format!("tokens: {} prompt + {} completion", @@ -166,7 +216,7 @@ impl AutoAgent { let text = msg.content_text().to_string(); if text.is_empty() && !has_content { log("empty response, retrying"); - self.messages.push(Message::user( + self.backend.push_message(Message::user( "[system] Your previous response was empty. \ Please respond with text or use a tool." )); @@ -181,8 +231,9 @@ impl AutoAgent { check(self.next_step)?; } self.current_phase = self.steps[self.next_step].phase.clone(); - self.messages.push(Message::assistant(&text)); - self.messages.push(Message::user(&self.steps[self.next_step].prompt)); + self.backend.push_message(Message::assistant(&text)); + self.backend.push_message( + Message::user(&self.steps[self.next_step].prompt)); self.next_step += 1; log(&format!("\n=== STEP {}/{} ===\n", self.next_step, self.steps.len())); @@ -197,14 +248,17 @@ impl AutoAgent { async fn api_call_with_retry( &self, + messages: &[Message], reasoning: &str, log: &dyn Fn(&str), ) -> Result<(Message, Option), String> { + let client = self.backend.client(); + let tools = self.backend.tools(); let mut last_err = None; for attempt in 0..5 { - match self.client.chat_completion_stream_temp( - &self.messages, - &self.tools, + match client.chat_completion_stream_temp( + messages, + tools, reasoning, self.sampling, Some(self.priority), @@ -229,13 +283,13 @@ impl AutoAgent { last_err = Some(e); continue; } - let msg_bytes: usize = self.messages.iter() + let msg_bytes: usize = messages.iter() .map(|m| m.content_text().len()) .sum(); return Err(format!( "{}: API error on turn {} (~{}KB, {} messages, {} attempts): {}", 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() { log(&format!("\nTOOL CALL: {}({})", @@ -264,7 +318,7 @@ impl AutoAgent { Ok(v) => v, Err(_) => { log(&format!("malformed tool call args: {}", &call.function.arguments)); - self.messages.push(Message::tool_result( + self.backend.push_raw(Message::tool_result( &call.id, "Error: your tool call had malformed JSON arguments. \ Please retry with valid JSON.", @@ -283,7 +337,7 @@ impl AutoAgent { log(&format!("Result: {}", preview)); } - self.messages.push(Message::tool_result(&call.id, &output)); + self.backend.push_raw(Message::tool_result(&call.id, &output)); } } }