fix(tui): stateful session with identity, memory, banner

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.
This commit is contained in:
2026-06-12 13:00:20 +05:30
parent 93040d4fb0
commit 3030f6eadf
2 changed files with 192 additions and 10 deletions

View File

@@ -146,6 +146,8 @@ pub struct AgentLoop {
retry_cfg: RetryConfig,
#[allow(dead_code)]
browser_client: Option<BrowserClient>,
/// Accumulated messages for stateful session mode (TUI / multi-turn).
session_messages: Mutex<Option<Vec<Message>>>,
}
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<String> {
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.

View File

@@ -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 {
// Send initial telemetry
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::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(