use crate::error::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialProfile {
pub domain: String,
pub username: String,
pub password: String,
pub username_selector: Option<String>,
pub password_selector: Option<String>,
pub submit_selector: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SiteStrategy {
pub domain: String,
pub description: String,
pub known_selectors: HashMap<String, String>,
pub pre_task_actions: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProgressTracker {
pub visited_pages: Vec<String>,
pub completed_items: Vec<String>,
pub checkpoint: Option<String>,
pub search_pages_checked: u32,
pub collected_items: Vec<String>,
}
impl ProgressTracker {
pub fn mark_visited(&mut self, url: &str) {
let normalized = url.to_string();
if !self.visited_pages.contains(&normalized) {
self.visited_pages.push(normalized);
}
}
pub fn mark_completed(&mut self, item: &str) {
let normalized = item.to_string();
if !self.completed_items.contains(&normalized) {
self.completed_items.push(normalized);
}
}
pub fn collect(&mut self, item: &str) {
self.collected_items.push(item.to_string());
}
pub fn set_checkpoint(&mut self, label: &str) {
self.checkpoint = Some(label.to_string());
}
pub fn is_visited(&self, url: &str) -> bool {
self.visited_pages.contains(&url.to_string())
}
pub fn to_prompt_context(&self) -> String {
let mut lines = vec!["Task Progress:".to_string()];
if let Some(ref cp) = self.checkpoint {
lines.push(format!(" Current checkpoint: {}", cp));
}
if !self.visited_pages.is_empty() {
lines.push(format!(" Pages visited: {}", self.visited_pages.len()));
}
if !self.completed_items.is_empty() {
lines.push(format!(
" Items completed: {}",
self.completed_items.len()
));
for item in &self.completed_items {
lines.push(format!(" - {}", item));
}
}
if !self.collected_items.is_empty() {
lines.push(format!(
" Items collected: {}",
self.collected_items.len()
));
}
if self.search_pages_checked > 0 {
lines.push(format!(
" Search pages checked: {}",
self.search_pages_checked
));
}
lines.join("\n")
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AgentMemory {
pub credentials: HashMap<String, CredentialProfile>,
pub site_strategies: HashMap<String, SiteStrategy>,
pub progress: ProgressTracker,
}
impl AgentMemory {
pub fn new() -> Self {
Self::default()
}
pub fn add_credential(&mut self, profile: CredentialProfile) {
self.credentials.insert(profile.domain.clone(), profile);
}
pub fn get_credential(&self, domain: &str) -> Option<&CredentialProfile> {
self.credentials.get(domain)
}
pub fn add_strategy(&mut self, strategy: SiteStrategy) {
self.site_strategies
.insert(strategy.domain.clone(), strategy);
}
pub fn get_strategy(&self, domain: &str) -> Option<&SiteStrategy> {
self.site_strategies.get(domain)
}
pub fn save_to_file(&self, path: impl AsRef<Path>) -> Result<()> {
let snapshot = MemorySnapshot {
progress: self.progress.clone(),
site_strategies: self.site_strategies.clone(),
saved_at: chrono::Utc::now().to_rfc3339(),
};
let json = serde_json::to_string_pretty(&snapshot)?;
fs::write(path, json)?;
Ok(())
}
pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self> {
let contents = fs::read_to_string(path)?;
let snapshot: MemorySnapshot = serde_json::from_str(&contents)?;
Ok(Self {
credentials: HashMap::new(), site_strategies: snapshot.site_strategies,
progress: snapshot.progress,
})
}
pub fn build_context(&self, current_url: &str, page_intent: Option<&str>) -> String {
let mut context = String::new();
let domain = extract_domain(current_url).unwrap_or_default();
if let Some(strategy) = self.get_strategy(&domain) {
context.push_str(&format!(
"Site-specific knowledge for {}:\n{}",
domain, strategy.description
));
if !strategy.known_selectors.is_empty() {
context.push_str("\nKnown working selectors:\n");
for (name, selector) in &strategy.known_selectors {
context.push_str(&format!(" {}: {}\n", name, selector));
}
}
context.push('\n');
}
let progress = self.progress.to_prompt_context();
if !progress.is_empty() {
context.push_str(&progress);
context.push('\n');
}
if page_intent == Some("login") {
if let Some(cred) = self.get_credential(&domain) {
context.push_str(&format!(
"Credential profile available for {} (user: {}). Use input action with known selectors.\n",
domain, cred.username
));
}
}
context
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MemorySnapshot {
progress: ProgressTracker,
site_strategies: HashMap<String, SiteStrategy>,
saved_at: String,
}
fn extract_domain(url: &str) -> Option<String> {
url.split("//").nth(1)?.split('/').next()?.split(':').next().map(|s| s.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_progress_tracker() {
let mut pt = ProgressTracker::default();
pt.mark_visited("https://example.com/page1");
pt.mark_visited("https://example.com/page2");
pt.mark_completed("Item A");
pt.collect("Result 1");
pt.set_checkpoint("Searching");
assert!(pt.is_visited("https://example.com/page1"));
assert!(!pt.is_visited("https://example.com/page3"));
let ctx = pt.to_prompt_context();
assert!(ctx.contains("Current checkpoint: Searching"));
assert!(ctx.contains("Pages visited: 2"));
assert!(ctx.contains("Items completed: 1"));
assert!(ctx.contains("- Item A"));
}
#[test]
fn test_agent_memory_credentials() {
let mut memory = AgentMemory::new();
memory.add_credential(CredentialProfile {
domain: "example.com".to_string(),
username: "user@example.com".to_string(),
password: "secret".to_string(),
username_selector: Some("#user".to_string()),
password_selector: Some("#pass".to_string()),
submit_selector: Some("#login".to_string()),
});
let cred = memory.get_credential("example.com").unwrap();
assert_eq!(cred.username, "user@example.com");
let ctx = memory.build_context("https://example.com/login", Some("login"));
assert!(ctx.contains("Credential profile available"));
}
#[test]
fn test_site_strategy() {
let mut memory = AgentMemory::new();
let mut selectors = HashMap::new();
selectors.insert("search_button".to_string(), "button[type=submit]".to_string());
memory.add_strategy(SiteStrategy {
domain: "airline.com".to_string(),
description: "Search button is mislabeled as 'Find'".to_string(),
known_selectors: selectors,
pre_task_actions: vec!["click('.cookie-accept')".to_string()],
});
let strategy = memory.get_strategy("airline.com").unwrap();
assert_eq!(strategy.description, "Search button is mislabeled as 'Find'");
let ctx = memory.build_context("https://airline.com/book", None);
assert!(ctx.contains("mislabeled"));
assert!(ctx.contains("search_button"));
}
#[test]
fn test_extract_domain() {
assert_eq!(
extract_domain("https://example.com/path"),
Some("example.com".to_string())
);
assert_eq!(
extract_domain("http://api.example.com:8080/v1"),
Some("api.example.com".to_string())
);
}
#[test]
fn test_memory_persistence() {
let mut memory = AgentMemory::new();
memory.progress.mark_visited("https://example.com/a");
memory.progress.set_checkpoint("reviewing");
let mut selectors = HashMap::new();
selectors.insert("btn".to_string(), "#go".to_string());
memory.add_strategy(SiteStrategy {
domain: "example.com".to_string(),
description: "Test".to_string(),
known_selectors: selectors,
pre_task_actions: vec![],
});
let temp = tempfile::NamedTempFile::new().unwrap();
memory.save_to_file(temp.path()).unwrap();
let loaded = AgentMemory::load_from_file(temp.path()).unwrap();
assert!(loaded.progress.is_visited("https://example.com/a"));
assert_eq!(loaded.progress.checkpoint, Some("reviewing".to_string()));
assert!(loaded.get_strategy("example.com").is_some());
assert!(loaded.credentials.is_empty());
}
}