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:
@@ -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.
|
||||
|
||||
34
src/main.rs
34
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 {
|
||||
// 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(
|
||||
|
||||
Reference in New Issue
Block a user