From 3030f6eadfba472c47a14afd45a10d47b8be5b76 Mon Sep 17 00:00:00 2001 From: Tara Menon Date: Fri, 12 Jun 2026 13:00:20 +0530 Subject: [PATCH] fix(tui): stateful session with identity, memory, banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for the TUI: 1. **Raw LLM responses** — was stateless, creating fresh messages every turn. Added + methods that accumulate conversation history, inject identity, load past memory, run browser init, and compress context — identical to the interactive loop but designed for external callers. 2. **Banner flash** — removed call that fired to stdout *before* TUI entered alternate screen. TUI already shows the robot head via as initial chat content. Also added getter and field to AgentLoop. --- crates/gyandoot-agent/src/agent_loop.rs | 168 ++++++++++++++++++++++++ src/main.rs | 34 +++-- 2 files changed, 192 insertions(+), 10 deletions(-) diff --git a/crates/gyandoot-agent/src/agent_loop.rs b/crates/gyandoot-agent/src/agent_loop.rs index f6651ab..e1c6d54 100644 --- a/crates/gyandoot-agent/src/agent_loop.rs +++ b/crates/gyandoot-agent/src/agent_loop.rs @@ -146,6 +146,8 @@ pub struct AgentLoop { retry_cfg: RetryConfig, #[allow(dead_code)] browser_client: Option, + /// Accumulated messages for stateful session mode (TUI / multi-turn). + session_messages: Mutex>>, } impl AgentLoop { @@ -168,6 +170,7 @@ impl AgentLoop { status: Arc::new(Mutex::new(AgentStatus::Idle)), retry_cfg: RetryConfig::default(), browser_client: None, + session_messages: Mutex::new(None), } } @@ -383,6 +386,171 @@ impl AgentLoop { self.status.lock().await.clone() } + /// Read-only access to agent configuration. + pub fn config(&self) -> &AgentConfig { + &self.config + } + + // ── Stateful session methods (for TUI / multi-turn callers) ───────── + + /// Initialize a conversation session: browser init, identity injection, + /// memory recall, and system prompt construction. Must be called before + /// the first `session_turn()`. + pub async fn init_session(&self) -> AgentResult<()> { + // Browser init + { + let mut tool_executor = self.tool_executor.lock().await; + match crate::browser_manager::init_browser().await { + crate::browser_manager::BrowserInitResult::Ready(client) => { + info!("Camoufox browser ready — registering web_search and web_browse tools"); + crate::browser_tools::register_browser_tools(&mut tool_executor, client.clone()); + } + crate::browser_manager::BrowserInitResult::Unavailable(msg) => { + warn!("Camoufox not available: {}", msg); + } + } + } + + // Identity & memory injection + let (agent_name, agent_role, user_name, user_role) = { + let memory = self.memory.lock().await; + let (_l0, _) = memory.identity_get().unwrap_or_default(); + + let a_name = if !self.config.identity.agent_name.is_empty() { + self.config.identity.agent_name.clone() + } else { + self.config.name.clone() + }; + + let a_role = if !self.config.identity.agent_role.is_empty() { + self.config.identity.agent_role.clone() + } else { + "AI Assistant built by Gyannetra".to_string() + }; + + let u_name = if !self.config.identity.user_name.is_empty() { + self.config.identity.user_name.clone() + } else { + "the User".to_string() + }; + + let u_role = if !self.config.identity.user_role.is_empty() { + self.config.identity.user_role.clone() + } else { + "User".to_string() + }; + + (a_name, a_role, u_name, u_role) + }; + + // Build system prompt with identity + capabilities + let mut system_prompt = self.config.system_prompt.clone(); + if !agent_name.is_empty() { + system_prompt = format!( + "{}\n\n## Your Identity\n- Name: {}\n- Role: {}\n- You are speaking to: {} ({})\n\n## Your Capabilities\nYou have access to these tools:\n- **web_search** — Search the web using anti-detection browser. Use for any online query.\n- **web_browse** — Visit and interact with any website. Use for scraping pages.\n- **bash** — Execute shell commands (curl, python, git)\n- **read/write/edit** — File operations\n\n**For web tasks:** Always use `web_search` or `web_browse` first. If they fail, fall back to `bash` with Python requests.\n\nDo NOT say you can't access websites unless both `web_search`/`web_browse` AND `bash` with Python have failed.\n\n## Memory\nYou have a verbatim dialogue log. Use past conversations as context.\n", + system_prompt, + agent_name, + if agent_role.is_empty() { "AI Assistant" } else { &agent_role }, + if user_name.is_empty() { "the User" } else { &user_name }, + if user_role.is_empty() { "User" } else { &user_role }, + ); + } + + // Inject recent dialogue from memory + { + let memory = self.memory.lock().await; + if let Ok(dialogue) = memory.dialogue_read(2000).await { + if !dialogue.is_empty() { + system_prompt = format!( + "{}\n\n## Past Conversations (Verbatim Memory)\n{}\n---\nContinue the conversation naturally.", + system_prompt, dialogue + ); + } + } + } + + let messages = vec![Message::new(MessageRole::System, &system_prompt)]; + *self.session_messages.lock().await = Some(messages); + info!("Session initialised with identity and memory"); + + Ok(()) + } + + /// Run a stateful conversation turn. Accumulates messages across turns + /// (unlike `run_conversation` which is stateless). Returns the assistant + /// response text. + pub async fn session_turn(&self, user_message: &str) -> AgentResult { + let span = span!(Level::INFO, "session_turn", agent = %self.config.name); + let _guard = span.enter(); + + // Ensure session is initialised + let mut session_guard = self.session_messages.lock().await; + if session_guard.is_none() { + drop(session_guard); + self.init_session().await?; + session_guard = self.session_messages.lock().await; + } + + let mut messages = session_guard.take().unwrap_or_default(); + + // Sanitize and add user message + let clean_input = self.sanitizer.sanitize_input(user_message, &gyandoot_security::sanitizer::SanitizerConfig::default()); + messages.push(Message::new(MessageRole::User, clean_input.clone())); + + // Run the core turn + let new_messages = self.execute_turn(messages).await?; + messages = new_messages; + + // Extract assistant's last text response + let assistant_text = messages + .iter() + .rev() + .find(|m| m.role == MessageRole::Assistant) + .map(|m| m.content.clone()) + .unwrap_or_default(); + + // Store in memory + { + let memory = self.memory.lock().await; + memory + .transcript_append("user", user_message) + .await + .unwrap_or_else(|e| warn!("Failed to store user message: {e}")); + memory + .dialogue_append("user", user_message) + .await + .unwrap_or_else(|e| warn!("Failed to store user dialogue: {e}")); + if !assistant_text.is_empty() { + memory + .transcript_append("assistant", &assistant_text) + .await + .unwrap_or_else(|e| warn!("Failed to store assistant message: {e}")); + memory + .dialogue_append("assistant", &assistant_text) + .await + .unwrap_or_else(|e| warn!("Failed to store assistant dialogue: {e}")); + } + } + + // Compress context if token budget is tight + let total_tokens: usize = + messages.iter().map(|m| estimate_tokens(&m.content)).sum(); + let limit = self.config.max_tokens; + if total_tokens > (limit * 3 / 4) { + debug!( + "Context at ~{:.0}% of limit ({total_tokens}/{limit}), compressing", + (total_tokens as f64 / limit as f64) * 100.0 + ); + messages = self.context_compressor.compress(messages, limit).await; + } + + // Save messages back for next turn + *session_guard = Some(messages); + + *self.status.lock().await = AgentStatus::Idle; + Ok(assistant_text) + } + // ── Internal methods ─────────────────────────────────────────────── /// Execute a single turn: LLM call → tool calls → LLM follow-up. diff --git a/src/main.rs b/src/main.rs index ac1e1e9..9dce485 100644 --- a/src/main.rs +++ b/src/main.rs @@ -555,9 +555,6 @@ async fn cmd_setup() -> anyhow::Result<()> { async fn cmd_tui() -> anyhow::Result<()> { tracing::info!("Launching TUI with live agent"); - // Print banner to stdout too (for logs/terminal buffer) - gyandoot_tui::banner::print_banner(); - // Load agent config let config = load_agent_config(); @@ -569,22 +566,39 @@ async fn cmd_tui() -> anyhow::Result<()> { let agent = gyandoot_agent::agent_loop::AgentLoop::new(config); let agent_event_tx = event_tx.clone(); tokio::spawn(async move { + let _ = agent_event_tx.send(gyandoot_tui::tui::TuiEvent::Status("INITIALISING...".to_string())).await; + + // Init session (browser, identity, memory — like interactive run()) + if let Err(e) = agent.init_session().await { + let _ = agent_event_tx.send(gyandoot_tui::tui::TuiEvent::Status( + format!("SESSION INIT ERROR: {}", e) + )).await; + } else { + let _ = agent_event_tx.send(gyandoot_tui::tui::TuiEvent::Status("AGENT READY".to_string())).await; + } + // Send initial telemetry - let _ = agent_event_tx.send(gyandoot_tui::tui::TuiEvent::Status("AGENT READY".to_string())).await; + let _ = agent_event_tx.send(gyandoot_tui::tui::TuiEvent::Telemetry { + provider: agent.config().provider.default.clone(), + model: agent.config().model.clone(), + total_tokens: 0, + max_tokens: agent.config().max_tokens, + msg_count: 0, + }).await; // Process messages from TUI while let Some(user_msg) = user_rx.recv().await { let _ = agent_event_tx.send(gyandoot_tui::tui::TuiEvent::Status("PROCESSING...".to_string())).await; - match agent.run_conversation(&user_msg).await { - Ok(output) => { + match agent.session_turn(&user_msg).await { + Ok(text) => { let _ = agent_event_tx.send(gyandoot_tui::tui::TuiEvent::Telemetry { - provider: "—".to_string(), // populated from config - model: "—".to_string(), + provider: agent.config().provider.default.clone(), + model: agent.config().model.clone(), total_tokens: 0, - max_tokens: 4096, + max_tokens: agent.config().max_tokens, msg_count: 0, }).await; - let _ = agent_event_tx.send(gyandoot_tui::tui::TuiEvent::Response(output.text)).await; + let _ = agent_event_tx.send(gyandoot_tui::tui::TuiEvent::Response(text)).await; } Err(e) => { let _ = agent_event_tx.send(gyandoot_tui::tui::TuiEvent::Status(