#![warn(missing_docs)]
mod actions;
mod chain;
mod concurrent_chain;
mod confidence;
mod config;
mod content;
mod helpers;
mod html_diff;
mod map_result;
mod memory_ops;
mod observation;
mod planning;
mod prompts;
mod schema_gen;
mod selector_cache;
mod self_healing;
mod synthesis;
mod tool_calling;
pub use actions::{ActionRecord, ActionResult, ActionType};
pub use chain::{
ChainBuilder, ChainCondition, ChainContext, ChainResult, ChainStep, ChainStepResult,
};
pub use concurrent_chain::{
ConcurrentChainConfig, ConcurrentChainResult, DependencyGraph, DependentStep, StepResult,
};
pub use confidence::{
Alternative, ConfidenceRetryStrategy, ConfidenceSummary, ConfidenceTracker, ConfidentStep,
Verification, VerificationType,
};
pub use config::{
arena_rank, effective_thinking_budget, effective_thinking_payload, is_anthropic_endpoint,
is_url_allowed, merged_config, model_profile, reasoning_payload, supports_pdf, supports_video,
supports_vision, thinking_payload, AutomationConfig, CaptureProfile, CleaningIntent,
ClipViewport, CostTier, HtmlCleaningProfile, ModelCapabilities, ModelEndpoint, ModelInfoEntry,
ModelPolicy, ModelPricing, ModelProfile, ModelRanks, ReasoningEffort, RecoveryStrategy,
RemoteMultimodalConfig, RetryPolicy, VisionRouteMode, MODEL_INFO,
};
pub use content::ContentAnalysis;
pub use helpers::{
extract_assistant_content, extract_last_code_block, extract_last_json_array,
extract_last_json_boundaries, extract_last_json_object, extract_thinking_content,
extract_usage, fnv1a64, truncate_utf8_tail,
};
pub use html_diff::{
ChangeType, DiffStats, ElementChange, HtmlDiffMode, HtmlDiffResult, PageStateDiff,
};
pub use map_result::{categories, DiscoveredUrl, MapResult};
pub use memory_ops::{AutomationMemory, MemoryOperation};
pub use observation::{
ActResult, FormField, FormInfo, InteractiveElement, NavigationOption, PageObservation,
};
pub use planning::{
Checkpoint, CheckpointResult, CheckpointType, ExecutionPlan, PageState, PlanExecutionState,
PlannedStep, PlanningModeConfig, ReplanContext,
};
pub use prompts::{
ACT_SYSTEM_PROMPT, CHROME_AI_SYSTEM_PROMPT, CONFIGURATION_SYSTEM_PROMPT, DEFAULT_SYSTEM_PROMPT,
EXTRACTION_ONLY_SYSTEM_PROMPT, EXTRACT_SYSTEM_PROMPT, MAP_SYSTEM_PROMPT, OBSERVE_SYSTEM_PROMPT,
};
pub use schema_gen::{
build_schema_generation_prompt, generate_schema, infer_schema, infer_schema_from_examples,
refine_schema, GeneratedSchema, SchemaCache, SchemaGenerationRequest,
};
pub use selector_cache::{SelectorCache, SelectorCacheEntry};
pub use self_healing::{
extract_html_context, HealedSelectorCache, HealingDiagnosis, HealingRequest, HealingResult,
HealingStats, SelectorIssueType, SelfHealingConfig,
};
pub use synthesis::{
MultiPageContext, PageContext, PageContribution, SynthesisConfig, SynthesisResult,
};
pub use tool_calling::{
parse_tool_calls, tool_calls_to_steps, ActionToolSchemas, FunctionCall, FunctionDefinition,
ToolCall, ToolCallingMode, ToolDefinition,
};
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PromptUrlGate {
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_url_map: Option<Box<std::collections::HashMap<String, Box<AutomationConfig>>>>,
#[serde(default)]
pub paths_map: bool,
}
impl PromptUrlGate {
pub fn new() -> Self {
Self::default()
}
pub fn with_map(map: std::collections::HashMap<String, Box<AutomationConfig>>) -> Self {
Self {
prompt_url_map: Some(Box::new(map)),
paths_map: false,
}
}
pub fn with_paths_map(mut self) -> Self {
self.paths_map = true;
self
}
pub fn add_override(&mut self, url: impl Into<String>, config: AutomationConfig) {
let map = self
.prompt_url_map
.get_or_insert_with(|| Box::new(std::collections::HashMap::new()));
map.insert(url.into(), Box::new(config));
}
pub fn match_url<'a>(&'a self, url: &str) -> Option<Option<&'a AutomationConfig>> {
let map = match self.prompt_url_map.as_deref() {
Some(m) => m,
None => return Some(None), };
let url_lower = url.to_lowercase();
if let Some(cfg) = map.get(&url_lower) {
return Some(Some(cfg));
}
if let Some(cfg) = map.get(url) {
return Some(Some(cfg));
}
if self.paths_map {
for (pattern, cfg) in map.iter() {
let pattern_lower = pattern.to_lowercase();
if url_lower.starts_with(&pattern_lower) {
return Some(Some(cfg));
}
}
}
None
}
pub fn is_allowed(&self, url: &str) -> bool {
self.match_url(url).is_some()
}
pub fn get_override(&self, url: &str) -> Option<&AutomationConfig> {
self.match_url(url).flatten()
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct AutomationUsage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
#[serde(default)]
pub llm_calls: u32,
#[serde(default)]
pub search_calls: u32,
#[serde(default)]
pub fetch_calls: u32,
#[serde(default)]
pub webbrowser_calls: u32,
#[serde(default)]
pub custom_tool_calls: std::collections::HashMap<String, u32>,
#[serde(default)]
pub api_calls: u32,
}
impl PartialEq for AutomationUsage {
fn eq(&self, other: &Self) -> bool {
self.prompt_tokens == other.prompt_tokens
&& self.completion_tokens == other.completion_tokens
&& self.total_tokens == other.total_tokens
&& self.llm_calls == other.llm_calls
&& self.search_calls == other.search_calls
&& self.fetch_calls == other.fetch_calls
&& self.webbrowser_calls == other.webbrowser_calls
&& self.custom_tool_calls == other.custom_tool_calls
&& self.api_calls == other.api_calls
}
}
impl Eq for AutomationUsage {}
impl AutomationUsage {
pub fn new(prompt_tokens: u32, completion_tokens: u32) -> Self {
Self {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
llm_calls: 1,
search_calls: 0,
fetch_calls: 0,
webbrowser_calls: 0,
custom_tool_calls: std::collections::HashMap::new(),
api_calls: 1,
}
}
pub fn with_api_calls(prompt_tokens: u32, completion_tokens: u32, api_calls: u32) -> Self {
Self {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
llm_calls: api_calls,
search_calls: 0,
fetch_calls: 0,
webbrowser_calls: 0,
custom_tool_calls: std::collections::HashMap::new(),
api_calls,
}
}
pub fn accumulate(&mut self, other: &Self) {
self.prompt_tokens += other.prompt_tokens;
self.completion_tokens += other.completion_tokens;
self.total_tokens += other.total_tokens;
self.llm_calls += other.llm_calls;
self.search_calls += other.search_calls;
self.fetch_calls += other.fetch_calls;
self.webbrowser_calls += other.webbrowser_calls;
for (tool, count) in &other.custom_tool_calls {
*self.custom_tool_calls.entry(tool.clone()).or_insert(0) += count;
}
self.api_calls += other.api_calls;
}
pub fn increment_llm_calls(&mut self) {
self.llm_calls += 1;
self.api_calls += 1;
}
pub fn increment_search_calls(&mut self) {
self.search_calls += 1;
self.api_calls += 1;
}
pub fn increment_fetch_calls(&mut self) {
self.fetch_calls += 1;
self.api_calls += 1;
}
pub fn increment_webbrowser_calls(&mut self) {
self.webbrowser_calls += 1;
self.api_calls += 1;
}
pub fn increment_custom_tool_calls(&mut self, tool_name: &str) {
*self
.custom_tool_calls
.entry(tool_name.to_string())
.or_insert(0) += 1;
self.api_calls += 1;
}
pub fn get_custom_tool_calls(&self, tool_name: &str) -> u32 {
self.custom_tool_calls.get(tool_name).copied().unwrap_or(0)
}
pub fn total_custom_tool_calls(&self) -> u32 {
self.custom_tool_calls.values().sum()
}
pub fn increment_api_calls(&mut self) {
self.api_calls += 1;
}
pub fn is_empty(&self) -> bool {
self.total_tokens == 0
}
}
impl std::ops::Add for AutomationUsage {
type Output = Self;
fn add(self, other: Self) -> Self {
let mut result = Self {
prompt_tokens: self.prompt_tokens + other.prompt_tokens,
completion_tokens: self.completion_tokens + other.completion_tokens,
total_tokens: self.total_tokens + other.total_tokens,
llm_calls: self.llm_calls + other.llm_calls,
search_calls: self.search_calls + other.search_calls,
fetch_calls: self.fetch_calls + other.fetch_calls,
webbrowser_calls: self.webbrowser_calls + other.webbrowser_calls,
custom_tool_calls: self.custom_tool_calls.clone(),
api_calls: self.api_calls + other.api_calls,
};
for (tool, count) in &other.custom_tool_calls {
*result.custom_tool_calls.entry(tool.clone()).or_insert(0) += count;
}
result
}
}
impl std::ops::AddAssign for AutomationUsage {
fn add_assign(&mut self, other: Self) {
self.accumulate(&other);
}
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ExtractionSchema {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub schema: String,
#[serde(default)]
pub strict: bool,
}
impl ExtractionSchema {
pub fn new(name: impl Into<String>, schema: impl Into<String>) -> Self {
Self {
name: name.into(),
description: None,
schema: schema.into(),
strict: false,
}
}
pub fn with_description(
name: impl Into<String>,
description: impl Into<String>,
schema: impl Into<String>,
) -> Self {
Self {
name: name.into(),
description: Some(description.into()),
schema: schema.into(),
strict: false,
}
}
pub fn strict(mut self) -> Self {
self.strict = true;
self
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct StructuredOutputConfig {
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema: Option<serde_json::Value>,
#[serde(default = "default_schema_name")]
pub schema_name: String,
#[serde(default)]
pub strict: bool,
}
fn default_schema_name() -> String {
"response".to_string()
}
impl StructuredOutputConfig {
pub fn new(schema: serde_json::Value) -> Self {
Self {
enabled: true,
schema: Some(schema),
schema_name: "response".to_string(),
strict: false,
}
}
pub fn strict(schema: serde_json::Value) -> Self {
Self {
enabled: true,
schema: Some(schema),
schema_name: "response".to_string(),
strict: true,
}
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.schema_name = name.into();
self
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct AutomationResult {
pub label: String,
pub steps_executed: usize,
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default)]
pub usage: AutomationUsage,
#[serde(skip_serializing_if = "Option::is_none")]
pub extracted: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub screenshot: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub spawn_pages: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub relevant: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<String>,
}
impl AutomationResult {
pub fn success(label: impl Into<String>, steps: usize) -> Self {
Self {
label: label.into(),
steps_executed: steps,
success: true,
..Default::default()
}
}
pub fn failure(label: impl Into<String>, error: impl Into<String>) -> Self {
Self {
label: label.into(),
success: false,
error: Some(error.into()),
..Default::default()
}
}
pub fn with_extracted(mut self, data: serde_json::Value) -> Self {
self.extracted = Some(data);
self
}
pub fn with_screenshot(mut self, screenshot: impl Into<String>) -> Self {
self.screenshot = Some(screenshot.into());
self
}
pub fn with_usage(mut self, usage: AutomationUsage) -> Self {
self.usage = usage;
self
}
pub fn with_spawn_pages(mut self, pages: Vec<String>) -> Self {
self.spawn_pages = pages;
self
}
pub fn add_spawn_page(mut self, url: impl Into<String>) -> Self {
self.spawn_pages.push(url.into());
self
}
pub fn has_spawn_pages(&self) -> bool {
!self.spawn_pages.is_empty()
}
pub fn with_relevant(mut self, relevant: Option<bool>) -> Self {
self.relevant = relevant;
self
}
pub fn with_reasoning(mut self, reasoning: Option<String>) -> Self {
self.reasoning = reasoning;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_automation_usage() {
let mut usage1 = AutomationUsage::new(100, 50);
assert_eq!(usage1.total_tokens, 150);
let usage2 = AutomationUsage::new(200, 100);
usage1.accumulate(&usage2);
assert_eq!(usage1.prompt_tokens, 300);
assert_eq!(usage1.completion_tokens, 150);
assert_eq!(usage1.total_tokens, 450);
}
#[test]
fn test_extraction_schema() {
let schema = ExtractionSchema::new("products", r#"{"type": "array"}"#).strict();
assert_eq!(schema.name, "products");
assert!(schema.strict);
assert!(schema.description.is_none());
}
#[test]
fn test_automation_result() {
let result = AutomationResult::success("test", 5)
.with_extracted(serde_json::json!({"data": "test"}))
.with_usage(AutomationUsage::new(100, 50));
assert!(result.success);
assert_eq!(result.steps_executed, 5);
assert!(result.extracted.is_some());
}
#[test]
fn test_automation_result_spawn_pages() {
let result = AutomationResult::success("test", 1).with_spawn_pages(vec![
"https://example.com/page1".to_string(),
"https://example.com/page2".to_string(),
]);
assert!(result.has_spawn_pages());
assert_eq!(result.spawn_pages.len(), 2);
assert_eq!(result.spawn_pages[0], "https://example.com/page1");
assert_eq!(result.spawn_pages[1], "https://example.com/page2");
let result = AutomationResult::success("test", 1)
.add_spawn_page("https://example.com/page1")
.add_spawn_page("https://example.com/page2");
assert!(result.has_spawn_pages());
assert_eq!(result.spawn_pages.len(), 2);
let result = AutomationResult::success("test", 1);
assert!(!result.has_spawn_pages());
assert!(result.spawn_pages.is_empty());
}
#[test]
fn test_automation_result_serialization_with_spawn_pages() {
let result = AutomationResult::success("test", 1).with_spawn_pages(vec![
"https://example.com/page1".to_string(),
"https://example.com/page2".to_string(),
]);
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("spawn_pages"));
assert!(json.contains("https://example.com/page1"));
assert!(json.contains("https://example.com/page2"));
let result = AutomationResult::success("test", 1);
let json = serde_json::to_string(&result).unwrap();
assert!(!json.contains("spawn_pages"));
let json_with_spawn = r#"{"label":"test","steps_executed":1,"success":true,"spawn_pages":["https://a.com","https://b.com"]}"#;
let result: AutomationResult = serde_json::from_str(json_with_spawn).unwrap();
assert_eq!(result.spawn_pages.len(), 2);
assert_eq!(result.spawn_pages[0], "https://a.com");
}
#[test]
fn test_prompt_url_gate_empty() {
let gate = PromptUrlGate::new();
assert!(gate.is_allowed("https://example.com"));
assert!(gate.get_override("https://example.com").is_none());
}
#[test]
fn test_prompt_url_gate_exact_match() {
let mut gate = PromptUrlGate::new();
gate.add_override(
"https://example.com/login",
AutomationConfig::new("Login handling"),
);
assert!(gate.is_allowed("https://example.com/login"));
let override_cfg = gate.get_override("https://example.com/login");
assert!(override_cfg.is_some());
assert_eq!(override_cfg.unwrap().goal, "Login handling");
assert!(!gate.is_allowed("https://example.com/other"));
}
#[test]
fn test_prompt_url_gate_path_prefix() {
let mut gate = PromptUrlGate::new().with_paths_map();
gate.add_override(
"https://example.com/admin",
AutomationConfig::new("Admin handling"),
);
assert!(gate.is_allowed("https://example.com/admin/users"));
assert!(gate.is_allowed("https://example.com/admin"));
assert!(!gate.is_allowed("https://example.com/public"));
}
#[test]
fn test_prompt_url_gate_case_insensitive() {
let mut gate = PromptUrlGate::new().with_paths_map();
gate.add_override("https://example.com/Admin", AutomationConfig::new("Admin"));
assert!(gate.is_allowed("https://example.com/admin"));
assert!(gate.is_allowed("https://example.com/ADMIN"));
assert!(gate.is_allowed("https://example.com/Admin/Users"));
}
}