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,
|
retry_cfg: RetryConfig,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
browser_client: Option<BrowserClient>,
|
browser_client: Option<BrowserClient>,
|
||||||
|
/// Accumulated messages for stateful session mode (TUI / multi-turn).
|
||||||
|
session_messages: Mutex<Option<Vec<Message>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgentLoop {
|
impl AgentLoop {
|
||||||
@@ -168,6 +170,7 @@ impl AgentLoop {
|
|||||||
status: Arc::new(Mutex::new(AgentStatus::Idle)),
|
status: Arc::new(Mutex::new(AgentStatus::Idle)),
|
||||||
retry_cfg: RetryConfig::default(),
|
retry_cfg: RetryConfig::default(),
|
||||||
browser_client: None,
|
browser_client: None,
|
||||||
|
session_messages: Mutex::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,6 +386,171 @@ impl AgentLoop {
|
|||||||
self.status.lock().await.clone()
|
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 ───────────────────────────────────────────────
|
// ── Internal methods ───────────────────────────────────────────────
|
||||||
|
|
||||||
/// Execute a single turn: LLM call → tool calls → LLM follow-up.
|
/// 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<()> {
|
async fn cmd_tui() -> anyhow::Result<()> {
|
||||||
tracing::info!("Launching TUI with live agent");
|
tracing::info!("Launching TUI with live agent");
|
||||||
|
|
||||||
// Print banner to stdout too (for logs/terminal buffer)
|
|
||||||
gyandoot_tui::banner::print_banner();
|
|
||||||
|
|
||||||
// Load agent config
|
// Load agent config
|
||||||
let config = 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 = gyandoot_agent::agent_loop::AgentLoop::new(config);
|
||||||
let agent_event_tx = event_tx.clone();
|
let agent_event_tx = event_tx.clone();
|
||||||
tokio::spawn(async move {
|
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;
|
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
|
// Process messages from TUI
|
||||||
while let Some(user_msg) = user_rx.recv().await {
|
while let Some(user_msg) = user_rx.recv().await {
|
||||||
let _ = agent_event_tx.send(gyandoot_tui::tui::TuiEvent::Status("PROCESSING...".to_string())).await;
|
let _ = agent_event_tx.send(gyandoot_tui::tui::TuiEvent::Status("PROCESSING...".to_string())).await;
|
||||||
match agent.run_conversation(&user_msg).await {
|
match agent.session_turn(&user_msg).await {
|
||||||
Ok(output) => {
|
Ok(text) => {
|
||||||
let _ = agent_event_tx.send(gyandoot_tui::tui::TuiEvent::Telemetry {
|
let _ = agent_event_tx.send(gyandoot_tui::tui::TuiEvent::Telemetry {
|
||||||
provider: "—".to_string(), // populated from config
|
provider: agent.config().provider.default.clone(),
|
||||||
model: "—".to_string(),
|
model: agent.config().model.clone(),
|
||||||
total_tokens: 0,
|
total_tokens: 0,
|
||||||
max_tokens: 4096,
|
max_tokens: agent.config().max_tokens,
|
||||||
msg_count: 0,
|
msg_count: 0,
|
||||||
}).await;
|
}).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) => {
|
Err(e) => {
|
||||||
let _ = agent_event_tx.send(gyandoot_tui::tui::TuiEvent::Status(
|
let _ = agent_event_tx.send(gyandoot_tui::tui::TuiEvent::Status(
|
||||||
|
|||||||
Reference in New Issue
Block a user