ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
@@ -0,0 +1,22 @@
"""
Learning Agent for Browser-Use RPA
A wrapper system that adds learning and experience-replay capabilities to browser-use.
This agent can learn from successful task executions and replay learned workflows efficiently.
"""
from .knowledge_base import KnowledgeBase
from .workflow import Workflow, WorkflowStep, StatePredicate, PredicateType, WorkflowStatus
__all__ = [
'LearningAgent', 'KnowledgeBase', 'Workflow', 'WorkflowStep',
'StatePredicate', 'PredicateType', 'WorkflowStatus'
]
def __getattr__(name):
"""Keep the data model usable in offline tests without browser-use."""
if name == 'LearningAgent':
from .agent import LearningAgent
return LearningAgent
raise AttributeError(name)
@@ -0,0 +1,609 @@
"""
Learning Agent that extends browser-use with experience-based learning.
This agent wraps the browser-use Agent to capture successful workflows
and replay them efficiently without LLM calls.
"""
import asyncio
import logging
from typing import Any, Dict, List, Optional
import time
from pathlib import Path
import sys
import os
# Add parent directory to path to import browser-use
sys.path.append(str(Path(__file__).parent.parent))
from browser_use import Agent, Browser
from browser_use.agent.views import AgentOutput, ActionResult
from browser_use.browser.views import BrowserStateSummary
from .workflow import (
Workflow, WorkflowStep, ActionType, StatePredicate, PredicateType,
)
from .knowledge_base import KnowledgeBase
from .replay import WorkflowReplayer
logger = logging.getLogger(__name__)
class LearningAgent:
"""
An agent that learns from experience and can replay learned workflows.
This agent:
1. Attempts to match tasks to learned workflows
2. Falls back to browser-use Agent for new tasks
3. Captures successful executions as new workflows
4. Improves over time by building a knowledge base
"""
def __init__(self,
task: str,
llm: Any,
browser: Optional[Browser] = None,
knowledge_base_path: str = "./knowledge_base",
headless: bool = False,
validation_reset: Optional[Any] = None,
**agent_kwargs):
"""
Initialize the learning agent.
Args:
task: The task to be performed
llm: Language model to use (browser-use compatible)
browser: Browser instance (optional)
knowledge_base_path: Path to store learned workflows
headless: Whether to run browser in headless mode for replay
validation_reset: Sync or async callback that resets the target
sandbox before validating a candidate workflow. Without it,
candidates are audited but never published for reuse.
**agent_kwargs: Additional arguments for browser-use Agent
"""
self.task = task
self.llm = llm
self.browser = browser
self.headless = headless
self.validation_reset = validation_reset
# Initialize knowledge base
self.knowledge_base = KnowledgeBase(knowledge_base_path)
# Initialize workflow replayer
self.replayer = WorkflowReplayer(headless=headless)
# Workflow capture state
self.current_workflow: Optional[Workflow] = None
self.is_learning = False
self.captured_steps: List[Dict[str, Any]] = []
# Browser-use agent (lazy initialization)
self._agent: Optional[Agent] = None
self._agent_kwargs = agent_kwargs
# Metrics
self.metrics = {
"llm_calls": 0,
"replay_used": False,
"execution_time": 0,
"success": False
}
@property
def agent(self) -> Agent:
"""Lazy initialization of browser-use agent."""
if self._agent is None:
# Create agent with step callback for capturing
self._agent = Agent(
task=self.task,
llm=self.llm,
browser=self.browser,
**self._agent_kwargs
)
# Store original step method
self._original_step = self._agent.step
# Monkey-patch the step method to capture actions
self._agent.step = self._wrapped_step
return self._agent
async def _wrapped_step(self, step_info=None):
"""Wrapped step method that captures workflow information."""
# Call original step
await self._original_step(step_info)
# Capture step information if learning
if self.is_learning:
await self._capture_step()
async def _capture_step(self):
"""Capture the current step for workflow learning."""
try:
# Get the last action and result from agent state
if self.agent.state.last_model_output and self.agent.state.last_result:
model_output = self.agent.state.last_model_output
results = self.agent.state.last_result
# Get browser state for element information
browser_state = await self.agent.browser_session.get_browser_state_summary()
# Process each action in the step
for i, (action, result) in enumerate(zip(model_output.action, results)):
if result and not result.error:
# Extract action details
action_data = self._extract_action_data(action, result, browser_state)
if action_data:
self.captured_steps.append(action_data)
logger.debug(f"Captured step: {action_data['type']}")
except Exception as e:
logger.error(f"Failed to capture step: {e}")
def _extract_action_data(self, action: Any, result: ActionResult, browser_state: BrowserStateSummary) -> Optional[Dict[str, Any]]:
"""
Extract action data for workflow capture.
Args:
action: The action object from browser-use
result: The result of the action
browser_state: Current browser state
Returns:
Dictionary containing action data, or None if extraction fails
"""
try:
# exclude_unset is essential: a plain model_dump() emits a key
# for EVERY registered action (None for the unset ones), which
# made the first branch match every action and drop it on
# None.get(...). browser-use itself reads action names the same
# way (see browser_use/agent/service.py).
action_dict = action.model_dump(exclude_unset=True) if hasattr(action, 'model_dump') else {}
# Determine action type
action_type = None
parameters = {}
element_info = None
# Parse different action types
if 'go_to_url' in action_dict:
action_type = ActionType.NAVIGATE
parameters = {'url': action_dict['go_to_url'].get('url')}
elif 'click_element_by_index' in action_dict:
action_type = ActionType.CLICK
click_data = action_dict['click_element_by_index']
parameters = {
'while_holding_ctrl': click_data.get('while_holding_ctrl', False)
}
# Get element info from selector map
index = click_data.get('index')
if index and browser_state.dom_state.selector_map:
element_info = self._get_element_info(index, browser_state.dom_state.selector_map)
elif 'input_text' in action_dict:
action_type = ActionType.INPUT_TEXT
input_data = action_dict['input_text']
parameters = {
'text': input_data.get('text', ''),
'clear_existing': input_data.get('clear_existing', True)
}
# Get element info
index = input_data.get('index')
if index and browser_state.dom_state.selector_map:
element_info = self._get_element_info(index, browser_state.dom_state.selector_map)
elif 'select_dropdown_option' in action_dict:
action_type = ActionType.SELECT_OPTION
select_data = action_dict['select_dropdown_option']
parameters = {
'text': select_data.get('text', '')
}
index = select_data.get('index')
if index and browser_state.dom_state.selector_map:
element_info = self._get_element_info(index, browser_state.dom_state.selector_map)
elif 'scroll' in action_dict:
action_type = ActionType.SCROLL
scroll_data = action_dict['scroll']
parameters = {
'down': scroll_data.get('down', True),
'num_pages': scroll_data.get('num_pages', 1)
}
elif 'upload_file_to_element' in action_dict:
action_type = ActionType.UPLOAD_FILE
upload_data = action_dict['upload_file_to_element']
parameters = {
'path': upload_data.get('path', '')
}
index = upload_data.get('index')
if index and browser_state.dom_state.selector_map:
element_info = self._get_element_info(index, browser_state.dom_state.selector_map)
elif 'done' in action_dict:
# Skip done action for workflow
return None
if action_type:
return {
'type': action_type,
'parameters': parameters,
'element_info': element_info,
'url': browser_state.url
}
except Exception as e:
logger.error(f"Failed to extract action data: {e}")
return None
def _get_element_info(self, index: int, selector_map: Dict) -> Optional[Dict[str, Any]]:
"""
Get element information from selector map.
Args:
index: Element index
selector_map: Browser-use selector map
Returns:
Dictionary containing element selectors and attributes
"""
try:
if index in selector_map:
element = selector_map[index]
# Extract stable selectors
info = {
'xpath': getattr(element, 'xpath', None),
'attributes': {}
}
# Get important attributes
if hasattr(element, 'attributes') and element.attributes:
for attr in ['id', 'name', 'class', 'type', 'role', 'aria-label', 'data-testid']:
if attr in element.attributes:
info['attributes'][attr] = element.attributes[attr]
return info
except Exception as e:
logger.error(f"Failed to get element info: {e}")
return None
async def run(self, max_steps: int = 100) -> Dict[str, Any]:
"""
Run the learning agent to complete the task.
Args:
max_steps: Maximum steps for browser-use agent
Returns:
Dictionary containing execution results and metrics
"""
start_time = time.time()
try:
# Check if we have a learned workflow for this task
match = self.knowledge_base.find_workflow_for_task(self.task)
if match and match.confidence > 0.6:
# Use learned workflow
logger.info(f"Found matching workflow: '{match.workflow.intent}' "
f"(confidence: {match.confidence:.2f})")
logger.info(f"Match reason: {match.match_reason}")
result = await self._run_with_replay(match.workflow)
# A state-predicate failure means the page or API changed.
# Remove this version from retrieval before falling back.
if result['success']:
self.knowledge_base.update_workflow_metrics(
match.workflow.workflow_id,
success=True,
execution_time=result['execution_time'],
model_calls_saved=result['model_calls_saved']
)
else:
reason = result.get('failed_predicate') or '; '.join(result.get('errors', []))
self.knowledge_base.invalidate_workflow(match.workflow.workflow_id, reason)
self.metrics['replay_used'] = True
self.metrics['success'] = result['success']
# If replay failed, fall back to learning mode
if not result['success']:
logger.warning("Replay failed, falling back to learning mode")
# The LLM loop is about to run, so this is no longer a
# replay run. Leaving the flag set makes the summary log and
# the demos report "0 LLM calls / Nx faster" for a run that
# actually made real LLM calls.
self.metrics['replay_used'] = False
result = await self._run_with_learning(max_steps)
else:
# No matching workflow, run in learning mode
logger.info("No matching workflow found, running in learning mode")
result = await self._run_with_learning(max_steps)
finally:
self.metrics['execution_time'] = time.time() - start_time
# Log performance comparison
if self.metrics['replay_used']:
logger.info(f"Task completed with replay in {self.metrics['execution_time']:.2f}s")
logger.info(f"Model calls saved: {result.get('model_calls_saved', 0)}")
else:
logger.info(f"Task completed with learning in {self.metrics['execution_time']:.2f}s")
logger.info(f"LLM calls made: {self.metrics['llm_calls']}")
return self.metrics
async def _run_with_replay(self, workflow: Workflow) -> Dict[str, Any]:
"""
Run task using a learned workflow.
Args:
workflow: The workflow to replay
Returns:
Execution results
"""
logger.info("Replaying learned workflow...")
# Extract parameters from task if needed
parameters = self._extract_task_parameters(self.task, workflow)
# Setup replayer
await self.replayer.setup()
try:
# Replay workflow
result = await self.replayer.replay_workflow(
workflow,
parameters=parameters
)
logger.info(f"Replay completed: {result['steps_completed']}/{result['total_steps']} steps")
return result
finally:
await self.replayer.cleanup()
async def _run_with_learning(self, max_steps: int) -> Dict[str, Any]:
"""
Run task with browser-use agent and capture workflow.
Args:
max_steps: Maximum steps for agent
Returns:
Execution results
"""
logger.info("Running with browser-use agent (learning mode)...")
# Enable learning mode
self.is_learning = True
self.captured_steps = []
# Track LLM calls
original_get_model_output = self.agent.get_model_output
async def tracked_get_model_output(*args, **kwargs):
self.metrics['llm_calls'] += 1
return await original_get_model_output(*args, **kwargs)
self.agent.get_model_output = tracked_get_model_output
try:
# Run the agent
await self.agent.run(max_steps=max_steps)
# Check if task was successful
success = False
if self.agent.state.last_result:
for result in self.agent.state.last_result:
if result and hasattr(result, 'success') and result.success:
success = True
break
self.metrics['success'] = success
# If successful, save the workflow
if success and self.captured_steps:
await self._save_learned_workflow()
return {
'success': success,
'steps_completed': len(self.captured_steps),
'total_steps': len(self.captured_steps),
'execution_time': self.metrics['execution_time'],
'model_calls_saved': 0
}
finally:
self.is_learning = False
async def _save_learned_workflow(self):
"""Save the captured workflow to knowledge base."""
try:
# Create workflow from captured steps
workflow = Workflow(
workflow_id="", # Will be generated
intent=self.task,
description=f"Learned workflow for: {self.task}",
initial_url=self.captured_steps[0].get('url') if self.captured_steps else None
)
# Template the captured literals with the learning task's
# parameters: captured steps store the exact values typed during
# learning, and parameterize() only substitutes {placeholder}
# tokens — without this step a replay would silently re-send the
# learning run's recipient/subject/content.
example_params = self._extract_task_parameters(self.task, workflow)
workflow.example_parameters = dict(example_params)
# Convert captured steps to workflow steps
for step_data in self.captured_steps:
parameters = dict(step_data['parameters'])
for key, value in parameters.items():
if isinstance(value, str):
# Replace each captured literal with its {token}. Match
# longest values first so a shorter value that is a
# substring of a longer field (e.g. subject "Report"
# inside body "Report is ready") can't pre-empt it, and
# stage substitutions through unique sentinels so an
# already-inserted {token} is never re-scanned by a later
# parameter whose value happens to appear in the token
# text — the result no longer depends on iteration order.
sentinels = {}
for i, (param_key, param_value) in enumerate(sorted(
example_params.items(),
key=lambda kv: len(str(kv[1])),
reverse=True,
)):
pv = str(param_value)
if pv and pv in value:
sentinel = f"\x00{i}\x00"
sentinels[sentinel] = f"{{{param_key}}}"
value = value.replace(pv, sentinel)
for sentinel, token in sentinels.items():
value = value.replace(sentinel, token)
parameters[key] = value
step = WorkflowStep(
action_type=step_data['type'],
parameters=parameters
)
# Add element info if available
if step_data.get('element_info'):
element_info = step_data['element_info']
step.xpath = element_info.get('xpath')
step.element_attributes = element_info.get('attributes', {})
workflow.add_step(step)
# Derive conservative predicates from captured page state. A
# production extractor can add richer text and state assertions.
for step in workflow.steps:
selector = f"xpath={step.xpath}" if step.xpath else step.css_selector
if selector and step.action_type in {
ActionType.CLICK, ActionType.INPUT_TEXT,
ActionType.SELECT_OPTION, ActionType.UPLOAD_FILE,
}:
step.preconditions.append(StatePredicate(
PredicateType.ELEMENT_VISIBLE,
expected=True,
selector=selector,
description="target element must be visible before action",
))
if step.action_type == ActionType.NAVIGATE and step.parameters.get('url'):
step.postconditions.append(StatePredicate(
PredicateType.URL_CONTAINS,
expected=step.parameters['url'],
description="navigation must reach the requested URL",
))
last_url = self.captured_steps[-1].get('url') if self.captured_steps else None
if last_url:
workflow.final_predicates.append(StatePredicate(
PredicateType.URL_CONTAINS,
expected=last_url,
description="workflow must finish on the observed final page",
))
# First-run success creates only a candidate. Publication requires
# an explicit environment reset and a full independent replay.
self.knowledge_base.save_candidate(workflow)
if self.validation_reset is None:
logger.warning(
"Workflow remains candidate: no validation_reset callback was supplied"
)
return
import inspect
reset_result = self.validation_reset()
if inspect.isawaitable(reset_result):
await reset_result
await self.replayer.setup()
try:
# Validate with the learned example parameters so the replay
# substitutes the {placeholder} tokens back to concrete values.
# Without this the validation run types the literal token text
# (e.g. "{recipient}") into the page, so a correctly-learned
# workflow fails validation and is never published — every later
# replay then falls back to the LLM. (empty dict => no-op.)
validation = await self.replayer.replay_workflow(
workflow, parameters=workflow.example_parameters
)
finally:
await self.replayer.cleanup()
if validation['success']:
workflow.mark_validated()
self.knowledge_base.publish_validated(workflow)
logger.info("Validated and published workflow with %s steps", len(workflow.steps))
else:
logger.warning(
"Candidate replay failed and was not published: %s",
validation.get('failed_predicate') or validation.get('errors'),
)
except Exception as e:
logger.error(f"Failed to save learned workflow: {e}")
def _extract_task_parameters(self, task: str, workflow: Workflow) -> Dict[str, Any]:
"""
Extract parameters from task description for workflow.
Args:
task: Task description
workflow: Workflow that needs parameters
Returns:
Dictionary of extracted parameters
"""
# This is a simplified parameter extraction
# In production, you might use NLP or regex patterns
parameters = {}
# Example: Extract email addresses
import re
email_pattern = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b'
emails = re.findall(email_pattern, task)
if emails:
parameters['recipient'] = emails[0]
# Extract quoted text as subject or content
quoted = re.findall(r'"([^"]*)"', task)
if quoted:
if 'subject' in task.lower() or '主题' in task.lower():
parameters['subject'] = quoted[0]
if len(quoted) > 1:
parameters['content'] = quoted[1]
else:
parameters['content'] = quoted[0]
return parameters
def run_sync(self, max_steps: int = 100) -> Dict[str, Any]:
"""
Synchronous wrapper for run method.
Args:
max_steps: Maximum steps for browser-use agent
Returns:
Execution results
"""
return asyncio.run(self.run(max_steps))
@@ -0,0 +1,336 @@
"""
Knowledge Base for storing and retrieving learned workflows.
This module provides persistent storage and intelligent retrieval of workflows,
including intent matching and workflow selection.
"""
import json
import os
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from datetime import datetime
import logging
from dataclasses import dataclass
import uuid
from .workflow import Workflow, WorkflowStep, WorkflowStatus
logger = logging.getLogger(__name__)
@dataclass
class IntentMatch:
"""Represents a match between a task intent and a stored workflow"""
workflow: Workflow
confidence: float # 0.0 to 1.0
match_reason: str
class KnowledgeBase:
"""
Manages storage and retrieval of learned workflows.
The knowledge base provides:
- Persistent storage of workflows
- Intent matching to find relevant workflows
- Performance tracking and optimization
"""
def __init__(self, storage_path: str = "./knowledge_base"):
"""
Initialize the knowledge base.
Args:
storage_path: Directory path for storing workflow data
"""
self.storage_path = Path(storage_path)
self.storage_path.mkdir(exist_ok=True)
# In-memory cache of workflows
self.workflows: Dict[str, Workflow] = {}
# Intent index for fast matching
self.intent_index: Dict[str, List[str]] = {} # intent -> [workflow_ids]
# Load existing workflows
self.load_all_workflows()
def save_workflow(self, workflow: Workflow) -> None:
"""
Save a workflow to persistent storage.
Args:
workflow: The workflow to save
"""
if workflow.validation_status != WorkflowStatus.VALIDATED:
raise ValueError(
"Only a workflow validated by complete replay in a reset environment may enter the ability store"
)
# Generate ID if not present
if not workflow.workflow_id:
workflow.workflow_id = str(uuid.uuid4())
# Save to file
workflow_file = self.storage_path / f"workflow_{workflow.workflow_id}.json"
with open(workflow_file, 'w', encoding='utf-8') as f:
f.write(workflow.to_json())
# Update in-memory cache
self.workflows[workflow.workflow_id] = workflow
# Update intent index
if workflow.intent not in self.intent_index:
self.intent_index[workflow.intent] = []
if workflow.workflow_id not in self.intent_index[workflow.intent]:
self.intent_index[workflow.intent].append(workflow.workflow_id)
logger.info(f"Saved workflow '{workflow.workflow_id}' for intent: {workflow.intent}")
def save_candidate(self, workflow: Workflow) -> None:
"""Persist a candidate for audit without making it retrievable."""
if not workflow.workflow_id:
workflow.workflow_id = str(uuid.uuid4())
workflow.validation_status = WorkflowStatus.CANDIDATE
candidate_file = self.storage_path / f"candidate_{workflow.workflow_id}.json"
candidate_file.write_text(workflow.to_json(), encoding="utf-8")
def publish_validated(self, workflow: Workflow) -> None:
"""Move a replay-validated candidate into the retrievable store."""
self.save_workflow(workflow)
candidate_file = self.storage_path / f"candidate_{workflow.workflow_id}.json"
if candidate_file.exists():
candidate_file.unlink()
def invalidate_workflow(self, workflow_id: str, reason: str) -> None:
"""Remove a broken workflow from retrieval and preserve it for audit."""
workflow = self.workflows.pop(workflow_id, None)
if not workflow:
return
workflow.mark_invalid(reason)
stable_file = self.storage_path / f"workflow_{workflow_id}.json"
if stable_file.exists():
stable_file.unlink()
invalid_file = self.storage_path / f"invalid_{workflow_id}.json"
invalid_file.write_text(workflow.to_json(), encoding="utf-8")
ids = self.intent_index.get(workflow.intent, [])
self.intent_index[workflow.intent] = [item for item in ids if item != workflow_id]
def load_all_workflows(self) -> None:
"""Load all workflows from storage into memory."""
workflow_files = list(self.storage_path.glob("workflow_*.json"))
for workflow_file in workflow_files:
try:
with open(workflow_file, 'r', encoding='utf-8') as f:
workflow_data = json.load(f)
workflow = Workflow.from_dict(workflow_data)
if workflow.validation_status != WorkflowStatus.VALIDATED:
logger.warning("Ignoring unvalidated workflow file: %s", workflow_file)
continue
# Add to cache
self.workflows[workflow.workflow_id] = workflow
# Update intent index
if workflow.intent not in self.intent_index:
self.intent_index[workflow.intent] = []
self.intent_index[workflow.intent].append(workflow.workflow_id)
except Exception as e:
logger.error(f"Failed to load workflow from {workflow_file}: {e}")
logger.info(f"Loaded {len(self.workflows)} workflows from storage")
def find_workflow_for_task(self, task_description: str) -> Optional[IntentMatch]:
"""
Find the best matching workflow for a given task.
Args:
task_description: Natural language description of the task
Returns:
The best matching workflow with confidence score, or None if no match
"""
matches = self.find_matching_workflows(task_description)
if matches:
# Return the highest confidence match
return max(matches, key=lambda m: m.confidence)
return None
def find_matching_workflows(self, task_description: str) -> List[IntentMatch]:
"""
Find all workflows that might match the given task.
Args:
task_description: Natural language description of the task
Returns:
List of matching workflows sorted by confidence
"""
matches = []
# Normalize task description for matching
task_lower = task_description.lower()
for workflow in self.workflows.values():
if workflow.validation_status != WorkflowStatus.VALIDATED:
continue
confidence, reason = self._calculate_match_confidence(task_lower, workflow)
if confidence > 0.3: # Minimum threshold
matches.append(IntentMatch(
workflow=workflow,
confidence=confidence,
match_reason=reason
))
# Sort by confidence (highest first)
matches.sort(key=lambda m: m.confidence, reverse=True)
return matches
def _calculate_match_confidence(self, task: str, workflow: Workflow) -> Tuple[float, str]:
"""
Calculate how well a workflow matches a task description.
Args:
task: Normalized task description
workflow: Workflow to match against
Returns:
Tuple of (confidence_score, match_reason)
"""
confidence = 0.0
reasons = []
# Check intent match
intent_lower = workflow.intent.lower()
# Exact intent match
if intent_lower in task:
confidence += 0.5
reasons.append("exact intent match")
# Keyword matching for common patterns
intent_keywords = set(intent_lower.split())
task_keywords = set(task.split())
# Calculate keyword overlap
common_keywords = intent_keywords & task_keywords
if common_keywords:
keyword_score = len(common_keywords) / len(intent_keywords)
confidence += keyword_score * 0.3
reasons.append(f"keyword match: {', '.join(common_keywords)}")
# Check for action verbs (send, write, compose, create, etc.)
action_verbs = {
'send': ['send', 'email', 'mail', 'message'],
'write': ['write', 'compose', 'draft', 'create'],
'search': ['search', 'find', 'look', 'query'],
'check': ['check', 'verify', 'view', 'see'],
'login': ['login', 'signin', 'authenticate', 'log in', 'sign in'],
'order': ['order', 'buy', 'purchase', 'checkout'],
'book': ['book', 'reserve', 'schedule']
}
for action_group, verbs in action_verbs.items():
if any(verb in intent_lower for verb in verbs) and any(verb in task for verb in verbs):
confidence += 0.2
reasons.append(f"action verb match: {action_group}")
break
# Boost confidence for recently successful workflows
if workflow.success_count > workflow.failure_count:
success_rate = workflow.success_count / (workflow.success_count + workflow.failure_count)
confidence *= (1 + success_rate * 0.2)
if success_rate > 0.8:
reasons.append(f"high success rate: {success_rate:.0%}")
# Compile reason string
reason = "; ".join(reasons) if reasons else "partial match"
return confidence, reason
def update_workflow_metrics(self,
workflow_id: str,
success: bool,
execution_time: float,
model_calls_saved: int = 0) -> None:
"""
Update performance metrics for a workflow after execution.
Args:
workflow_id: ID of the workflow that was executed
success: Whether the execution was successful
execution_time: Time taken to execute the workflow
model_calls_saved: Number of LLM calls saved by using this workflow
"""
if workflow_id in self.workflows:
workflow = self.workflows[workflow_id]
# Update counters
if success:
workflow.success_count += 1
else:
workflow.failure_count += 1
# Update timing
workflow.last_used_at = datetime.now()
# Update average execution time
total_executions = workflow.success_count + workflow.failure_count
workflow.average_execution_time = (
(workflow.average_execution_time * (total_executions - 1) + execution_time)
/ total_executions
)
# Track model calls saved
workflow.model_calls_saved += model_calls_saved
# Save updated workflow
self.save_workflow(workflow)
logger.info(f"Updated metrics for workflow {workflow_id}: "
f"success={success}, time={execution_time:.2f}s, "
f"total_saved_calls={workflow.model_calls_saved}")
def get_statistics(self) -> Dict[str, any]:
"""
Get statistics about the knowledge base.
Returns:
Dictionary containing knowledge base statistics
"""
total_workflows = len(self.workflows)
total_executions = sum(w.success_count + w.failure_count for w in self.workflows.values())
total_successes = sum(w.success_count for w in self.workflows.values())
total_model_calls_saved = sum(w.model_calls_saved for w in self.workflows.values())
success_rate = (total_successes / total_executions * 100) if total_executions > 0 else 0
return {
"total_workflows": total_workflows,
"total_executions": total_executions,
"total_successes": total_successes,
"success_rate": f"{success_rate:.1f}%",
"total_model_calls_saved": total_model_calls_saved,
"unique_intents": len(self.intent_index)
}
def clear_all(self) -> None:
"""Clear all workflows from the knowledge base (use with caution)."""
# Clear files
for workflow_file in self.storage_path.glob("workflow_*.json"):
workflow_file.unlink()
# Clear memory
self.workflows.clear()
self.intent_index.clear()
logger.info("Cleared all workflows from knowledge base")
@@ -0,0 +1,401 @@
"""
Workflow replay functionality using Playwright.
This module provides reliable replay of learned workflows by directly
controlling the browser through Playwright, bypassing the need for LLM calls.
"""
import asyncio
import logging
from typing import Any, Dict, Optional
from playwright.async_api import Page, Browser, async_playwright, Locator
import time
from .workflow import Workflow, WorkflowStep, ActionType, StatePredicate, PredicateType
logger = logging.getLogger(__name__)
class PredicateFailure(RuntimeError):
"""Raised when the real page no longer satisfies a workflow assertion."""
class WorkflowReplayer:
"""
Replays learned workflows using Playwright for direct browser control.
This replayer:
- Executes workflows without LLM calls
- Handles dynamic page loading with smart waits
- Provides robust error recovery
- Tracks execution metrics
"""
def __init__(self, headless: bool = False):
"""
Initialize the workflow replayer.
Args:
headless: Whether to run browser in headless mode
"""
self.headless = headless
self.browser: Optional[Browser] = None
self.page: Optional[Page] = None
self.context = None
self.playwright = None
async def setup(self):
"""Initialize Playwright and browser."""
self.playwright = await async_playwright().start()
self.browser = await self.playwright.chromium.launch(
headless=self.headless,
args=['--disable-blink-features=AutomationControlled']
)
self.context = await self.browser.new_context(
viewport={'width': 1280, 'height': 720},
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
)
self.page = await self.context.new_page()
# Set default timeout
self.page.set_default_timeout(30000)
logger.info("Workflow replayer initialized")
async def cleanup(self):
"""Clean up browser resources."""
if self.page:
await self.page.close()
if self.context:
await self.context.close()
if self.browser:
await self.browser.close()
if self.playwright:
await self.playwright.stop()
async def replay_workflow(self,
workflow: Workflow,
parameters: Optional[Dict[str, Any]] = None,
initial_url: Optional[str] = None,
validate_state: bool = True) -> Dict[str, Any]:
"""
Replay a workflow with the given parameters.
Args:
workflow: The workflow to replay
parameters: Parameters to apply to the workflow
initial_url: Starting URL (uses workflow's initial_url if not provided)
Returns:
Dictionary containing execution results and metrics
"""
start_time = time.time()
results = {
"success": False,
"steps_completed": 0,
"total_steps": len(workflow.steps),
"errors": [],
"execution_time": 0,
"model_calls_saved": len(workflow.steps), # Each step would require an LLM call
"failed_predicate": None,
"fallback_required": False,
"actions_executed": [],
"validation_enabled": validate_state,
}
try:
# Apply parameters if provided
if parameters:
workflow = workflow.parameterize(parameters)
# Navigate to initial URL
start_url = initial_url or workflow.initial_url
if start_url:
logger.info(f"Navigating to initial URL: {start_url}")
await self.page.goto(start_url, wait_until='domcontentloaded')
await self.page.wait_for_load_state('networkidle', timeout=10000)
# Execute each step
for i, step in enumerate(workflow.steps):
logger.info(f"Executing step {i+1}/{len(workflow.steps)}: {step.action_type.value}")
try:
if validate_state:
await self._check_predicates(step.preconditions, f"step {i+1} precondition")
await self._execute_step(step)
results["actions_executed"].append(step.action_type.value)
if validate_state:
await self._check_predicates(step.postconditions, f"step {i+1} postcondition")
results["steps_completed"] += 1
# Small delay between actions for stability
await asyncio.sleep(0.5)
except Exception as e:
error_msg = f"Step {i+1} failed: {str(e)}"
logger.error(error_msg)
results["errors"].append(error_msg)
if isinstance(e, PredicateFailure):
results["failed_predicate"] = str(e)
# A state mismatch makes all later actions unsafe. Stop
# instead of claiming partial action execution as success.
break
if results["steps_completed"] == results["total_steps"]:
if validate_state:
await self._check_predicates(workflow.final_predicates, "workflow final predicate")
results["success"] = True
except Exception as e:
logger.error(f"Workflow replay failed: {e}")
results["errors"].append(str(e))
if isinstance(e, PredicateFailure):
results["failed_predicate"] = str(e)
finally:
results["execution_time"] = time.time() - start_time
results["fallback_required"] = not results["success"]
logger.info(f"Workflow replay completed in {results['execution_time']:.2f}s")
return results
async def _check_predicates(self, predicates, location: str) -> None:
for predicate in predicates:
# Browser actions often resolve before their fetch/DOM callback has
# committed visible state. Poll the real page briefly instead of
# turning that race into either a false failure or a fixed sleep.
deadline = time.monotonic() + 2.0
ok, actual = await self._evaluate_predicate(predicate)
while not ok and time.monotonic() < deadline:
await asyncio.sleep(0.05)
ok, actual = await self._evaluate_predicate(predicate)
if not ok:
description = predicate.description or predicate.predicate_type.value
raise PredicateFailure(
f"{location} failed: {description}; expected={predicate.expected!r}, actual={actual!r}"
)
async def _evaluate_predicate(self, predicate: StatePredicate):
if self.page is None:
return False, "page is not initialized"
if predicate.predicate_type == PredicateType.URL_CONTAINS:
actual = self.page.url
return str(predicate.expected) in actual, actual
if predicate.predicate_type == PredicateType.ELEMENT_VISIBLE:
if not predicate.selector:
return False, "missing selector"
locator = self.page.locator(predicate.selector)
actual = await locator.count() > 0 and await locator.first.is_visible()
return actual == bool(predicate.expected), actual
if predicate.predicate_type == PredicateType.ELEMENT_TEXT_CONTAINS:
if not predicate.selector:
return False, "missing selector"
locator = self.page.locator(predicate.selector).first
if await locator.count() == 0:
return False, "element missing"
actual = await locator.inner_text()
return str(predicate.expected) in actual, actual
if predicate.predicate_type == PredicateType.ELEMENT_VALUE_EQUALS:
if not predicate.selector:
return False, "missing selector"
locator = self.page.locator(predicate.selector).first
if await locator.count() == 0:
return False, "element missing"
actual = await locator.input_value()
return actual == str(predicate.expected), actual
if predicate.predicate_type == PredicateType.PAGE_STATE_EQUALS:
if not predicate.state_key:
return False, "missing state_key"
actual = await self.page.evaluate(
"key => window.__agentState ? window.__agentState[key] : undefined",
predicate.state_key,
)
return actual == predicate.expected, actual
return False, f"unsupported predicate {predicate.predicate_type}"
async def _execute_step(self, step: WorkflowStep) -> None:
"""
Execute a single workflow step.
Args:
step: The step to execute
"""
# Wait before action if specified
if step.wait_before > 0:
await asyncio.sleep(step.wait_before)
# Execute based on action type
if step.action_type == ActionType.NAVIGATE:
await self._execute_navigate(step)
elif step.action_type == ActionType.CLICK:
await self._execute_click(step)
elif step.action_type == ActionType.INPUT_TEXT:
await self._execute_input(step)
elif step.action_type == ActionType.SELECT_OPTION:
await self._execute_select(step)
elif step.action_type == ActionType.SCROLL:
await self._execute_scroll(step)
elif step.action_type == ActionType.WAIT:
await self._execute_wait(step)
elif step.action_type == ActionType.SWITCH_TAB:
await self._execute_switch_tab(step)
elif step.action_type == ActionType.UPLOAD_FILE:
await self._execute_upload(step)
else:
logger.warning(f"Unsupported action type: {step.action_type}")
async def _get_element(self, step: WorkflowStep) -> Locator:
"""
Get element using stable selectors with fallback.
Args:
step: The step containing selector information
Returns:
Playwright Locator for the element
"""
locator = None
# Try XPath first (most stable)
if step.xpath:
try:
locator = self.page.locator(f"xpath={step.xpath}")
# Check if element exists
if await locator.count() > 0:
# Wait for element to be ready
await locator.wait_for(state='visible', timeout=step.timeout * 1000)
return locator
except Exception as e:
logger.debug(f"XPath locator failed: {e}")
# Try CSS selector as fallback
if step.css_selector:
try:
locator = self.page.locator(step.css_selector)
if await locator.count() > 0:
await locator.wait_for(state='visible', timeout=step.timeout * 1000)
return locator
except Exception as e:
logger.debug(f"CSS selector failed: {e}")
# Try to build selector from attributes
if step.element_attributes:
selector_parts = []
# Use ID if available
if 'id' in step.element_attributes:
return self.page.locator(f"#{step.element_attributes['id']}")
# Build attribute selector
for attr, value in step.element_attributes.items():
if attr in ['name', 'type', 'role', 'aria-label', 'data-testid']:
selector_parts.append(f"[{attr}='{value}']")
if selector_parts:
selector = ''.join(selector_parts)
try:
locator = self.page.locator(selector)
if await locator.count() > 0:
await locator.wait_for(state='visible', timeout=step.timeout * 1000)
return locator
except Exception as e:
logger.debug(f"Attribute selector failed: {e}")
# Try text content as last resort
if 'text' in step.parameters:
text = step.parameters['text']
locator = self.page.get_by_text(text)
if await locator.count() > 0:
return locator
raise Exception(f"Could not find element for step: {step.description or step.action_type}")
async def _execute_navigate(self, step: WorkflowStep) -> None:
"""Execute navigation action."""
url = step.parameters.get('url')
if url:
logger.debug(f"Navigating to: {url}")
await self.page.goto(url, wait_until='domcontentloaded')
await self.page.wait_for_load_state('networkidle', timeout=10000)
async def _execute_click(self, step: WorkflowStep) -> None:
"""Execute click action with smart waiting."""
element = await self._get_element(step)
# Ensure element is clickable
await element.wait_for(state='visible', timeout=step.timeout * 1000)
await element.scroll_into_view_if_needed()
# Check for ctrl/cmd modifier
if step.parameters.get('while_holding_ctrl'):
await element.click(modifiers=['Control'])
else:
await element.click()
# Wait for potential navigation or dynamic updates
try:
await self.page.wait_for_load_state('networkidle', timeout=5000)
except:
pass # Page might not navigate
async def _execute_input(self, step: WorkflowStep) -> None:
"""Execute text input action."""
element = await self._get_element(step)
text = step.parameters.get('text', '')
clear_existing = step.parameters.get('clear_existing', True)
# Click to focus
await element.click()
# Clear existing text if needed
if clear_existing:
await element.fill('')
# Type the text
await element.type(text, delay=50) # Small delay for more human-like typing
async def _execute_select(self, step: WorkflowStep) -> None:
"""Execute select option action."""
element = await self._get_element(step)
option_text = step.parameters.get('text', '')
# Try to select by visible text
await element.select_option(label=option_text)
async def _execute_scroll(self, step: WorkflowStep) -> None:
"""Execute scroll action."""
direction = 'down' if step.parameters.get('down', True) else 'up'
pages = step.parameters.get('num_pages', 1)
# Calculate scroll amount
viewport_height = await self.page.evaluate('window.innerHeight')
scroll_amount = viewport_height * pages
if direction == 'down':
await self.page.evaluate(f'window.scrollBy(0, {scroll_amount})')
else:
await self.page.evaluate(f'window.scrollBy(0, -{scroll_amount})')
# Wait for any lazy-loaded content
await asyncio.sleep(0.5)
async def _execute_wait(self, step: WorkflowStep) -> None:
"""Execute wait action."""
wait_time = step.parameters.get('seconds', 1)
await asyncio.sleep(wait_time)
async def _execute_switch_tab(self, step: WorkflowStep) -> None:
"""Execute tab switching (simplified for single tab replay)."""
# In replay mode, we typically work with a single tab
# This is a placeholder for multi-tab support
logger.debug("Tab switching in replay mode - continuing in current tab")
async def _execute_upload(self, step: WorkflowStep) -> None:
"""Execute file upload action."""
element = await self._get_element(step)
file_path = step.parameters.get('path', '')
# Set the file(s) to upload
await element.set_input_files(file_path)
@@ -0,0 +1,264 @@
"""
Workflow data structures for capturing and storing browser action sequences.
This module defines the structures used to represent learned workflows,
including individual steps and complete action sequences.
"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional
from enum import Enum
import json
class ActionType(Enum):
"""Types of actions that can be recorded in a workflow"""
NAVIGATE = "navigate"
CLICK = "click"
INPUT_TEXT = "input_text"
SELECT_OPTION = "select_option"
SCROLL = "scroll"
WAIT = "wait"
SWITCH_TAB = "switch_tab"
CLOSE_TAB = "close_tab"
UPLOAD_FILE = "upload_file"
class PredicateType(Enum):
"""Machine-checkable browser state predicates."""
URL_CONTAINS = "url_contains"
ELEMENT_VISIBLE = "element_visible"
ELEMENT_TEXT_CONTAINS = "element_text_contains"
ELEMENT_VALUE_EQUALS = "element_value_equals"
PAGE_STATE_EQUALS = "page_state_equals"
class WorkflowStatus(Enum):
CANDIDATE = "candidate"
VALIDATED = "validated"
INVALID = "invalid"
@dataclass
class StatePredicate:
"""A precondition, postcondition or final-state assertion."""
predicate_type: PredicateType
expected: Any = True
selector: Optional[str] = None
state_key: Optional[str] = None
description: str = ""
def to_dict(self) -> Dict[str, Any]:
return {
"predicate_type": self.predicate_type.value,
"expected": self.expected,
"selector": self.selector,
"state_key": self.state_key,
"description": self.description,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'StatePredicate':
values = dict(data)
values["predicate_type"] = PredicateType(values["predicate_type"])
return cls(**values)
@dataclass
class WorkflowStep:
"""Represents a single step in a workflow"""
action_type: ActionType
# Stable selectors for element identification
xpath: Optional[str] = None
css_selector: Optional[str] = None
# Action parameters
parameters: Dict[str, Any] = field(default_factory=dict)
# Additional context
element_attributes: Dict[str, str] = field(default_factory=dict)
description: str = ""
# Timing information
wait_before: float = 0.0 # Seconds to wait before executing this step
timeout: float = 15.0 # Maximum time to wait for element to be ready
# Validation
expected_outcome: Optional[str] = None
preconditions: List[StatePredicate] = field(default_factory=list)
postconditions: List[StatePredicate] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Convert step to dictionary for serialization"""
return {
"action_type": self.action_type.value,
"xpath": self.xpath,
"css_selector": self.css_selector,
"parameters": self.parameters,
"element_attributes": self.element_attributes,
"description": self.description,
"wait_before": self.wait_before,
"timeout": self.timeout,
"expected_outcome": self.expected_outcome,
"preconditions": [item.to_dict() for item in self.preconditions],
"postconditions": [item.to_dict() for item in self.postconditions],
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'WorkflowStep':
"""Create step from dictionary"""
data = data.copy()
data['action_type'] = ActionType(data['action_type'])
data['preconditions'] = [StatePredicate.from_dict(item) for item in data.get('preconditions', [])]
data['postconditions'] = [StatePredicate.from_dict(item) for item in data.get('postconditions', [])]
return cls(**data)
@classmethod
def from_browser_action(cls, action_type: str, element: Optional[Any] = None, **params) -> 'WorkflowStep':
"""Create a workflow step from browser-use action and element info"""
step = cls(
action_type=ActionType(action_type.lower()),
parameters=params
)
if element:
# Extract stable selectors from DOMInteractedElement
if hasattr(element, 'x_path'):
step.xpath = element.x_path
# Store relevant attributes for fallback identification
if hasattr(element, 'attributes') and element.attributes:
step.element_attributes = {
k: v for k, v in element.attributes.items()
if k in ['id', 'name', 'class', 'type', 'role', 'aria-label', 'data-testid']
}
return step
@dataclass
class Workflow:
"""Represents a complete workflow that can be learned and replayed"""
# Identification
workflow_id: str
intent: str # The task intent this workflow accomplishes
# Steps
steps: List[WorkflowStep] = field(default_factory=list)
# Metadata
created_at: datetime = field(default_factory=datetime.now)
last_used_at: Optional[datetime] = None
success_count: int = 0
failure_count: int = 0
# Learning context
initial_url: Optional[str] = None
example_parameters: Dict[str, Any] = field(default_factory=dict)
description: str = ""
# Performance metrics
average_execution_time: float = 0.0
model_calls_saved: int = 0
# Validation lifecycle. New workflows are candidates until a complete
# replay succeeds in a reset environment.
validation_status: WorkflowStatus = WorkflowStatus.CANDIDATE
final_predicates: List[StatePredicate] = field(default_factory=list)
validated_at: Optional[datetime] = None
invalid_reason: Optional[str] = None
def add_step(self, step: WorkflowStep) -> None:
"""Add a step to the workflow"""
self.steps.append(step)
def to_dict(self) -> Dict[str, Any]:
"""Convert workflow to dictionary for serialization"""
return {
"workflow_id": self.workflow_id,
"intent": self.intent,
"steps": [step.to_dict() for step in self.steps],
"created_at": self.created_at.isoformat(),
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None,
"success_count": self.success_count,
"failure_count": self.failure_count,
"initial_url": self.initial_url,
"example_parameters": self.example_parameters,
"description": self.description,
"average_execution_time": self.average_execution_time,
"model_calls_saved": self.model_calls_saved,
"validation_status": self.validation_status.value,
"final_predicates": [item.to_dict() for item in self.final_predicates],
"validated_at": self.validated_at.isoformat() if self.validated_at else None,
"invalid_reason": self.invalid_reason,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Workflow':
"""Create workflow from dictionary"""
data = data.copy()
data['steps'] = [WorkflowStep.from_dict(s) for s in data.get('steps', [])]
data['created_at'] = datetime.fromisoformat(data['created_at'])
if data.get('last_used_at'):
data['last_used_at'] = datetime.fromisoformat(data['last_used_at'])
# Old files had no lifecycle field. Treat them as candidates so they
# cannot silently bypass the new validation protocol.
data['validation_status'] = WorkflowStatus(data.get('validation_status', 'candidate'))
data['final_predicates'] = [StatePredicate.from_dict(item) for item in data.get('final_predicates', [])]
if data.get('validated_at'):
data['validated_at'] = datetime.fromisoformat(data['validated_at'])
return cls(**data)
def to_json(self) -> str:
"""Serialize workflow to JSON string"""
return json.dumps(self.to_dict(), indent=2, default=str)
@classmethod
def from_json(cls, json_str: str) -> 'Workflow':
"""Deserialize workflow from JSON string"""
data = json.loads(json_str)
return cls.from_dict(data)
def parameterize(self, parameters: Dict[str, Any]) -> 'Workflow':
"""
Create a parameterized copy of this workflow with specific values.
Args:
parameters: Dictionary mapping parameter names to values
Returns:
A new Workflow instance with parameters applied
"""
import copy
parameterized = copy.deepcopy(self)
# Apply parameters to each step
for step in parameterized.steps:
for param_key, param_value in parameters.items():
# Replace placeholders in step parameters
for key, value in step.parameters.items():
if isinstance(value, str) and f"{{{param_key}}}" in value:
step.parameters[key] = value.replace(f"{{{param_key}}}", str(param_value))
for predicate in (*step.preconditions, *step.postconditions):
if isinstance(predicate.expected, str) and f"{{{param_key}}}" in predicate.expected:
predicate.expected = predicate.expected.replace(f"{{{param_key}}}", str(param_value))
for predicate in parameterized.final_predicates:
for param_key, param_value in parameters.items():
if isinstance(predicate.expected, str) and f"{{{param_key}}}" in predicate.expected:
predicate.expected = predicate.expected.replace(f"{{{param_key}}}", str(param_value))
return parameterized
def mark_validated(self) -> None:
self.validation_status = WorkflowStatus.VALIDATED
self.validated_at = datetime.now()
self.invalid_reason = None
def mark_invalid(self, reason: str) -> None:
self.validation_status = WorkflowStatus.INVALID
self.invalid_reason = reason