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
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:
@@ -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
|
||||
Reference in New Issue
Block a user