feat(agent): implement Claude Code SDK agent with chat mode and persistent browser
Implementation: - Singleton browser pattern (BrowserManager) - one instance for entire session - 7 MCP tools for Crawl4AI (quick_crawl, sessions, navigation, extraction, JS execution, screenshots) - Interactive chat mode with streaming I/O using Claude SDK message generator - Rich-based terminal UI with markdown rendering and syntax highlighting - Single-shot and chat modes (--chat flag) - Comprehensive test suite: component tests, tool tests, 9 multi-turn scenarios Architecture: - agent_crawl.py: CLI entry point with SessionStorage (JSONL logging) - browser_manager.py: Singleton pattern for persistent AsyncWebCrawler - c4ai_tools.py: MCP tools using @tool decorator, integrated with BrowserManager - chat_mode.py: Streaming input mode per Claude SDK spec - terminal_ui.py: Rich-based beautiful terminal output - test_scenarios.py: Automated multi-turn conversation tests (simple/medium/complex) - TECH_SPEC.md: Complete AI-to-AI knowledge transfer document Key fixes: - Use result.markdown (not deprecated result.markdown_v2) - Handle both str and MarkdownGenerationResult types - Track current URL per session for extract_data/execute_js/screenshot tools - Manual browser lifecycle (start/close) instead of context managers Tools enabled: - Crawl4AI: quick_crawl, start_session, navigate, extract_data, execute_js, screenshot, close_session - Claude SDK built-in: Read, Write, Edit, Glob, Grep, Bash, NotebookEdit Total: 12 files, 2820 lines
This commit is contained in:
429
crawl4ai/agent/TECH_SPEC.md
Normal file
429
crawl4ai/agent/TECH_SPEC.md
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
# Crawl4AI Agent Technical Specification
|
||||||
|
*AI-to-AI Knowledge Transfer Document*
|
||||||
|
|
||||||
|
## Context Documents
|
||||||
|
**MUST READ FIRST:**
|
||||||
|
1. `/Users/unclecode/devs/crawl4ai/tmp/CRAWL4AI_SDK.md` - Crawl4AI complete API reference
|
||||||
|
2. `/Users/unclecode/devs/crawl4ai/tmp/cc_stream.md` - Claude SDK streaming input mode
|
||||||
|
3. `/Users/unclecode/devs/crawl4ai/tmp/CC_PYTHON_SDK.md` - Claude Code Python SDK complete reference
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
**Core Principle:** Singleton browser instance + streaming chat mode + MCP tools
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Agent Entry Point │
|
||||||
|
│ agent_crawl.py (CLI: --chat | single-shot) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────┼───────────────────┐
|
||||||
|
│ │ │
|
||||||
|
[Chat Mode] [Single-shot] [Browser Manager]
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
ChatMode.run() CrawlAgent.run() BrowserManager
|
||||||
|
- Streaming - One prompt (Singleton)
|
||||||
|
- Interactive - Exit after │
|
||||||
|
- Commands - Uses same ▼
|
||||||
|
│ browser AsyncWebCrawler
|
||||||
|
│ │ (persistent)
|
||||||
|
└───────────────────┴────────────────┘
|
||||||
|
│
|
||||||
|
┌───────┴────────┐
|
||||||
|
│ │
|
||||||
|
MCP Tools Claude SDK
|
||||||
|
(Crawl4AI) (Built-in)
|
||||||
|
│ │
|
||||||
|
┌───────────┴────┐ ┌──────┴──────┐
|
||||||
|
│ │ │ │
|
||||||
|
quick_crawl session Read Edit
|
||||||
|
navigate tools Write Glob
|
||||||
|
extract_data Bash Grep
|
||||||
|
execute_js
|
||||||
|
screenshot
|
||||||
|
close_session
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
crawl4ai/agent/
|
||||||
|
├── __init__.py # Module exports
|
||||||
|
├── agent_crawl.py # Main CLI entry (190 lines)
|
||||||
|
│ ├── SessionStorage # JSONL logging to ~/.crawl4ai/agents/projects/
|
||||||
|
│ ├── CrawlAgent # Single-shot wrapper
|
||||||
|
│ └── main() # CLI parser (--chat flag)
|
||||||
|
│
|
||||||
|
├── browser_manager.py # Singleton pattern (70 lines)
|
||||||
|
│ └── BrowserManager # Class methods only, no instances
|
||||||
|
│ ├── get_browser() # Returns singleton AsyncWebCrawler
|
||||||
|
│ ├── reconfigure_browser()
|
||||||
|
│ ├── close_browser()
|
||||||
|
│ └── is_browser_active()
|
||||||
|
│
|
||||||
|
├── c4ai_tools.py # 7 MCP tools (310 lines)
|
||||||
|
│ ├── @tool decorators # Claude SDK decorator
|
||||||
|
│ ├── CRAWLER_SESSIONS # Dict[str, AsyncWebCrawler] for named sessions
|
||||||
|
│ ├── CRAWLER_SESSION_URLS # Dict[str, str] track current URL per session
|
||||||
|
│ └── CRAWL_TOOLS # List of tool functions
|
||||||
|
│
|
||||||
|
├── c4ai_prompts.py # System prompt (130 lines)
|
||||||
|
│ └── SYSTEM_PROMPT # Agent behavior definition
|
||||||
|
│
|
||||||
|
├── terminal_ui.py # Rich-based UI (120 lines)
|
||||||
|
│ └── TerminalUI # Console rendering
|
||||||
|
│ ├── show_header()
|
||||||
|
│ ├── print_markdown()
|
||||||
|
│ ├── print_code()
|
||||||
|
│ └── with_spinner()
|
||||||
|
│
|
||||||
|
├── chat_mode.py # Streaming chat (160 lines)
|
||||||
|
│ └── ChatMode
|
||||||
|
│ ├── message_generator() # AsyncGenerator per cc_stream.md
|
||||||
|
│ ├── _handle_command() # /exit /clear /help /browser
|
||||||
|
│ └── run() # Main chat loop
|
||||||
|
│
|
||||||
|
├── test_tools.py # Direct tool tests (130 lines)
|
||||||
|
├── test_chat.py # Component tests (90 lines)
|
||||||
|
└── test_scenarios.py # Multi-turn scenarios (500 lines)
|
||||||
|
├── SIMPLE_SCENARIOS
|
||||||
|
├── MEDIUM_SCENARIOS
|
||||||
|
├── COMPLEX_SCENARIOS
|
||||||
|
└── ScenarioRunner
|
||||||
|
```
|
||||||
|
|
||||||
|
## Critical Implementation Details
|
||||||
|
|
||||||
|
### 1. Browser Singleton Pattern
|
||||||
|
|
||||||
|
**Key:** ONE browser instance for ENTIRE agent session
|
||||||
|
|
||||||
|
```python
|
||||||
|
# browser_manager.py
|
||||||
|
class BrowserManager:
|
||||||
|
_crawler: Optional[AsyncWebCrawler] = None # Singleton
|
||||||
|
_config: Optional[BrowserConfig] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_browser(cls, config=None) -> AsyncWebCrawler:
|
||||||
|
if cls._crawler is None:
|
||||||
|
cls._crawler = AsyncWebCrawler(config or BrowserConfig())
|
||||||
|
await cls._crawler.start() # Manual lifecycle
|
||||||
|
return cls._crawler
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- First call: creates browser with `config` (or default)
|
||||||
|
- Subsequent calls: returns same instance, **ignores config param**
|
||||||
|
- To change config: `reconfigure_browser(new_config)` (closes old, creates new)
|
||||||
|
- Tools use: `crawler = await BrowserManager.get_browser()`
|
||||||
|
- No `async with` context manager - manual `start()` / `close()`
|
||||||
|
|
||||||
|
### 2. Tool Architecture
|
||||||
|
|
||||||
|
**Two types of browser usage:**
|
||||||
|
|
||||||
|
**A) Quick operations** (quick_crawl):
|
||||||
|
```python
|
||||||
|
@tool("quick_crawl", ...)
|
||||||
|
async def quick_crawl(args):
|
||||||
|
crawler = await BrowserManager.get_browser() # Singleton
|
||||||
|
result = await crawler.arun(url=args["url"], config=run_config)
|
||||||
|
# No close - browser stays alive
|
||||||
|
```
|
||||||
|
|
||||||
|
**B) Named sessions** (start_session, navigate, extract_data, etc.):
|
||||||
|
```python
|
||||||
|
CRAWLER_SESSIONS: Dict[str, AsyncWebCrawler] = {} # Named refs
|
||||||
|
CRAWLER_SESSION_URLS: Dict[str, str] = {} # Track current URL
|
||||||
|
|
||||||
|
@tool("start_session", ...)
|
||||||
|
async def start_session(args):
|
||||||
|
crawler = await BrowserManager.get_browser()
|
||||||
|
CRAWLER_SESSIONS[args["session_id"]] = crawler # Store ref
|
||||||
|
|
||||||
|
@tool("navigate", ...)
|
||||||
|
async def navigate(args):
|
||||||
|
crawler = CRAWLER_SESSIONS[args["session_id"]]
|
||||||
|
result = await crawler.arun(url=args["url"], ...)
|
||||||
|
CRAWLER_SESSION_URLS[args["session_id"]] = result.url # Track URL
|
||||||
|
|
||||||
|
@tool("extract_data", ...)
|
||||||
|
async def extract_data(args):
|
||||||
|
crawler = CRAWLER_SESSIONS[args["session_id"]]
|
||||||
|
current_url = CRAWLER_SESSION_URLS[args["session_id"]] # Must have URL
|
||||||
|
result = await crawler.arun(url=current_url, ...) # Re-crawl current page
|
||||||
|
|
||||||
|
@tool("close_session", ...)
|
||||||
|
async def close_session(args):
|
||||||
|
CRAWLER_SESSIONS.pop(args["session_id"]) # Remove ref
|
||||||
|
CRAWLER_SESSION_URLS.pop(args["session_id"], None)
|
||||||
|
# Browser stays alive (singleton)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** Named sessions are just **references** to singleton browser. Multiple sessions = same browser instance.
|
||||||
|
|
||||||
|
### 3. Markdown Handling (CRITICAL BUG FIX)
|
||||||
|
|
||||||
|
**OLD (WRONG):**
|
||||||
|
```python
|
||||||
|
result.markdown_v2.raw_markdown # DEPRECATED
|
||||||
|
```
|
||||||
|
|
||||||
|
**NEW (CORRECT):**
|
||||||
|
```python
|
||||||
|
# result.markdown can be:
|
||||||
|
# - str (simple mode)
|
||||||
|
# - MarkdownGenerationResult object (with filters)
|
||||||
|
|
||||||
|
if isinstance(result.markdown, str):
|
||||||
|
markdown_content = result.markdown
|
||||||
|
elif hasattr(result.markdown, 'raw_markdown'):
|
||||||
|
markdown_content = result.markdown.raw_markdown
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: `CRAWL4AI_SDK.md` line 614 - `markdown_v2` deprecated, use `markdown`
|
||||||
|
|
||||||
|
### 4. Chat Mode Streaming Input
|
||||||
|
|
||||||
|
**Per cc_stream.md:** Use message generator pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
# chat_mode.py
|
||||||
|
async def message_generator(self) -> AsyncGenerator[Dict[str, Any], None]:
|
||||||
|
while not self._exit_requested:
|
||||||
|
user_input = await asyncio.to_thread(self.ui.get_user_input)
|
||||||
|
|
||||||
|
if user_input.startswith('/'):
|
||||||
|
await self._handle_command(user_input)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Yield in streaming input format
|
||||||
|
yield {
|
||||||
|
"type": "user",
|
||||||
|
"message": {
|
||||||
|
"role": "user",
|
||||||
|
"content": user_input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
async with ClaudeSDKClient(options=self.options) as client:
|
||||||
|
await client.query(self.message_generator()) # Pass generator
|
||||||
|
|
||||||
|
async for message in client.receive_messages():
|
||||||
|
# Process streaming responses
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key:** Generator keeps yielding user inputs, SDK streams responses back.
|
||||||
|
|
||||||
|
### 5. Claude SDK Integration
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
```python
|
||||||
|
from claude_agent_sdk import tool, create_sdk_mcp_server, ClaudeSDKClient, ClaudeAgentOptions
|
||||||
|
|
||||||
|
# 1. Define tools with @tool decorator
|
||||||
|
@tool("quick_crawl", "description", {"url": str, "output_format": str})
|
||||||
|
async def quick_crawl(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps(result)}]}
|
||||||
|
|
||||||
|
# 2. Create MCP server
|
||||||
|
crawler_server = create_sdk_mcp_server(
|
||||||
|
name="crawl4ai",
|
||||||
|
version="1.0.0",
|
||||||
|
tools=[quick_crawl, start_session, ...] # List of @tool functions
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Configure options
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
mcp_servers={"crawler": crawler_server},
|
||||||
|
allowed_tools=[
|
||||||
|
"mcp__crawler__quick_crawl", # Format: mcp__{server}__{tool}
|
||||||
|
"mcp__crawler__start_session",
|
||||||
|
# Built-in tools:
|
||||||
|
"Read", "Write", "Edit", "Glob", "Grep", "Bash", "NotebookEdit"
|
||||||
|
],
|
||||||
|
system_prompt=SYSTEM_PROMPT,
|
||||||
|
permission_mode="acceptEdits"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Use client
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
await client.query(prompt_or_generator)
|
||||||
|
async for message in client.receive_messages():
|
||||||
|
# Process AssistantMessage, ResultMessage, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tool response format:**
|
||||||
|
```python
|
||||||
|
return {
|
||||||
|
"content": [{
|
||||||
|
"type": "text",
|
||||||
|
"text": json.dumps({"success": True, "data": "..."})
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Operating Modes
|
||||||
|
|
||||||
|
### Single-Shot Mode
|
||||||
|
```bash
|
||||||
|
python -m crawl4ai.agent.agent_crawl "Crawl example.com"
|
||||||
|
```
|
||||||
|
- One prompt → execute → exit
|
||||||
|
- Uses singleton browser
|
||||||
|
- No cleanup of browser (process exit handles it)
|
||||||
|
|
||||||
|
### Chat Mode
|
||||||
|
```bash
|
||||||
|
python -m crawl4ai.agent.agent_crawl --chat
|
||||||
|
```
|
||||||
|
- Interactive loop with streaming I/O
|
||||||
|
- Commands: `/exit` `/clear` `/help` `/browser`
|
||||||
|
- Browser persists across all turns
|
||||||
|
- Cleanup on exit: `BrowserManager.close_browser()`
|
||||||
|
|
||||||
|
## Testing Architecture
|
||||||
|
|
||||||
|
**3 test levels:**
|
||||||
|
|
||||||
|
1. **Component tests** (`test_chat.py`): Non-interactive, tests individual classes
|
||||||
|
2. **Tool tests** (`test_tools.py`): Direct AsyncWebCrawler calls, validates Crawl4AI integration
|
||||||
|
3. **Scenario tests** (`test_scenarios.py`): Automated multi-turn conversations
|
||||||
|
- Injects messages programmatically
|
||||||
|
- Validates tool calls, keywords, files created
|
||||||
|
- Categories: SIMPLE (2), MEDIUM (3), COMPLEX (4)
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
```python
|
||||||
|
# External
|
||||||
|
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode
|
||||||
|
from crawl4ai.extraction_strategy import LLMExtractionStrategy
|
||||||
|
from claude_agent_sdk import (
|
||||||
|
tool, create_sdk_mcp_server, ClaudeSDKClient, ClaudeAgentOptions,
|
||||||
|
AssistantMessage, TextBlock, ResultMessage, ToolUseBlock
|
||||||
|
)
|
||||||
|
from rich.console import Console # Already installed
|
||||||
|
from rich.markdown import Markdown
|
||||||
|
from rich.syntax import Syntax
|
||||||
|
|
||||||
|
# Stdlib
|
||||||
|
import asyncio, json, uuid, argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Any, AsyncGenerator
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **DON'T** use `async with AsyncWebCrawler()` - breaks singleton pattern
|
||||||
|
2. **DON'T** use `result.markdown_v2` - deprecated field
|
||||||
|
3. **DON'T** call `crawler.arun()` without URL in session tools - needs current_url
|
||||||
|
4. **DON'T** close browser in tools - managed by BrowserManager
|
||||||
|
5. **DON'T** use `break` in message iteration - causes asyncio issues
|
||||||
|
6. **DO** track session URLs in `CRAWLER_SESSION_URLS` for session tools
|
||||||
|
7. **DO** handle both `str` and `MarkdownGenerationResult` for `result.markdown`
|
||||||
|
8. **DO** use manual lifecycle `await crawler.start()` / `await crawler.close()`
|
||||||
|
|
||||||
|
## Session Storage
|
||||||
|
|
||||||
|
**Location:** `~/.crawl4ai/agents/projects/{sanitized_cwd}/{uuid}.jsonl`
|
||||||
|
|
||||||
|
**Format:** JSONL with events:
|
||||||
|
```json
|
||||||
|
{"timestamp": "...", "event": "session_start", "data": {...}}
|
||||||
|
{"timestamp": "...", "event": "user_message", "data": {"text": "..."}}
|
||||||
|
{"timestamp": "...", "event": "assistant_message", "data": {"turn": 1, "text": "..."}}
|
||||||
|
{"timestamp": "...", "event": "session_end", "data": {"duration_ms": 1000, ...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI Options
|
||||||
|
|
||||||
|
```
|
||||||
|
--chat Interactive chat mode
|
||||||
|
--model MODEL Claude model override
|
||||||
|
--permission-mode MODE acceptEdits|bypassPermissions|default|plan
|
||||||
|
--add-dir DIR [DIR...] Additional accessible directories
|
||||||
|
--system-prompt TEXT Custom system prompt
|
||||||
|
--session-id UUID Resume/specify session
|
||||||
|
--debug Full tracebacks
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Characteristics
|
||||||
|
|
||||||
|
- **Browser startup:** ~2-4s (once per session)
|
||||||
|
- **Quick crawl:** ~1-2s (reuses browser)
|
||||||
|
- **Session operations:** ~1-2s (same browser)
|
||||||
|
- **Chat latency:** Real-time streaming, no buffering
|
||||||
|
- **Memory:** One browser instance regardless of operations
|
||||||
|
|
||||||
|
## Extension Points
|
||||||
|
|
||||||
|
1. **New tools:** Add `@tool` function → add to `CRAWL_TOOLS` → add to `allowed_tools`
|
||||||
|
2. **New commands:** Add handler in `ChatMode._handle_command()`
|
||||||
|
3. **Custom UI:** Replace `TerminalUI` with different renderer
|
||||||
|
4. **Persistent sessions:** Serialize browser cookies/state to disk in `BrowserManager`
|
||||||
|
5. **Multi-browser:** Modify `BrowserManager` to support multiple configs (not recommended)
|
||||||
|
|
||||||
|
## Next Steps: Testing & Evaluation Pipeline
|
||||||
|
|
||||||
|
### Phase 1: Automated Testing (CURRENT)
|
||||||
|
**Objective:** Verify codebase correctness, not agent quality
|
||||||
|
|
||||||
|
**Test Execution:**
|
||||||
|
```bash
|
||||||
|
# 1. Component tests (fast, non-interactive)
|
||||||
|
python crawl4ai/agent/test_chat.py
|
||||||
|
# Expected: All components instantiate correctly
|
||||||
|
|
||||||
|
# 2. Tool integration tests (medium, requires browser)
|
||||||
|
python crawl4ai/agent/test_tools.py
|
||||||
|
# Expected: All 7 tools work with Crawl4AI
|
||||||
|
|
||||||
|
# 3. Multi-turn scenario tests (slow, comprehensive)
|
||||||
|
python crawl4ai/agent/test_scenarios.py
|
||||||
|
# Expected: 9 scenarios pass (2 simple, 3 medium, 4 complex)
|
||||||
|
# Output: test_agent_output/test_results.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:**
|
||||||
|
- All component tests pass
|
||||||
|
- All tool tests pass
|
||||||
|
- ≥80% scenario tests pass (7/9)
|
||||||
|
- No crashes, exceptions, or hangs
|
||||||
|
- Browser cleanup verified
|
||||||
|
|
||||||
|
**Automated Pipeline:**
|
||||||
|
```bash
|
||||||
|
# Run all tests in sequence, exit on first failure
|
||||||
|
cd /Users/unclecode/devs/crawl4ai
|
||||||
|
python crawl4ai/agent/test_chat.py && \
|
||||||
|
python crawl4ai/agent/test_tools.py && \
|
||||||
|
python crawl4ai/agent/test_scenarios.py
|
||||||
|
echo "Exit code: $?" # 0 = all passed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: Evaluation (NEXT)
|
||||||
|
**Objective:** Measure agent performance quality
|
||||||
|
|
||||||
|
**Metrics to define:**
|
||||||
|
- Task completion rate
|
||||||
|
- Tool selection accuracy
|
||||||
|
- Context retention across turns
|
||||||
|
- Planning effectiveness
|
||||||
|
- Error recovery capability
|
||||||
|
|
||||||
|
**Eval framework needed:**
|
||||||
|
- Expand scenario tests with quality scoring
|
||||||
|
- Add ground truth comparisons
|
||||||
|
- Measure token efficiency
|
||||||
|
- Track reasoning quality
|
||||||
|
|
||||||
|
**Not in scope yet** - wait for Phase 1 completion
|
||||||
|
|
||||||
|
---
|
||||||
|
**Last Updated:** 2025-01-17
|
||||||
|
**Version:** 1.0.0
|
||||||
|
**Status:** Testing Phase - Ready for automated test runs
|
||||||
13
crawl4ai/agent/__init__.py
Normal file
13
crawl4ai/agent/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# __init__.py
|
||||||
|
"""Crawl4AI Agent - Browser automation agent powered by Claude Code SDK."""
|
||||||
|
|
||||||
|
from .c4ai_tools import CRAWL_TOOLS
|
||||||
|
from .c4ai_prompts import SYSTEM_PROMPT
|
||||||
|
from .agent_crawl import CrawlAgent, SessionStorage
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CRAWL_TOOLS",
|
||||||
|
"SYSTEM_PROMPT",
|
||||||
|
"CrawlAgent",
|
||||||
|
"SessionStorage",
|
||||||
|
]
|
||||||
593
crawl4ai/agent/agent-cc-sdk.md
Normal file
593
crawl4ai/agent/agent-cc-sdk.md
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
```python
|
||||||
|
# c4ai_tools.py
|
||||||
|
"""Crawl4AI tools for Claude Code SDK agent."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
from typing import Any, Dict
|
||||||
|
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode
|
||||||
|
from crawl4ai.extraction_strategy import LLMExtractionStrategy
|
||||||
|
from claude_agent_sdk import tool
|
||||||
|
|
||||||
|
# Global session storage
|
||||||
|
CRAWLER_SESSIONS: Dict[str, AsyncWebCrawler] = {}
|
||||||
|
|
||||||
|
@tool("quick_crawl", "One-shot crawl for simple extraction. Returns markdown, HTML, or structured data.", {
|
||||||
|
"url": str,
|
||||||
|
"output_format": str, # "markdown" | "html" | "structured" | "screenshot"
|
||||||
|
"extraction_schema": str, # Optional: JSON schema for structured extraction
|
||||||
|
"js_code": str, # Optional: JavaScript to execute before extraction
|
||||||
|
"wait_for": str, # Optional: CSS selector to wait for
|
||||||
|
})
|
||||||
|
async def quick_crawl(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Fast single-page crawl without session management."""
|
||||||
|
|
||||||
|
crawler_config = BrowserConfig(headless=True, verbose=False)
|
||||||
|
run_config = CrawlerRunConfig(
|
||||||
|
cache_mode=CacheMode.BYPASS,
|
||||||
|
js_code=args.get("js_code"),
|
||||||
|
wait_for=args.get("wait_for"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add extraction strategy if structured data requested
|
||||||
|
if args.get("extraction_schema"):
|
||||||
|
run_config.extraction_strategy = LLMExtractionStrategy(
|
||||||
|
provider="openai/gpt-4o-mini",
|
||||||
|
schema=json.loads(args["extraction_schema"]),
|
||||||
|
instruction="Extract data according to the provided schema."
|
||||||
|
)
|
||||||
|
|
||||||
|
async with AsyncWebCrawler(config=crawler_config) as crawler:
|
||||||
|
result = await crawler.arun(url=args["url"], config=run_config)
|
||||||
|
|
||||||
|
if not result.success:
|
||||||
|
return {
|
||||||
|
"content": [{
|
||||||
|
"type": "text",
|
||||||
|
"text": json.dumps({"error": result.error_message, "success": False})
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
output_map = {
|
||||||
|
"markdown": result.markdown_v2.raw_markdown if result.markdown_v2 else "",
|
||||||
|
"html": result.html,
|
||||||
|
"structured": result.extracted_content,
|
||||||
|
"screenshot": result.screenshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"success": True,
|
||||||
|
"url": result.url,
|
||||||
|
"data": output_map.get(args["output_format"], result.markdown_v2.raw_markdown)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps(response, indent=2)}]}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("start_session", "Start a persistent browser session for multi-step crawling and automation.", {
|
||||||
|
"session_id": str,
|
||||||
|
"headless": bool, # Default True
|
||||||
|
})
|
||||||
|
async def start_session(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Initialize a persistent crawler session."""
|
||||||
|
|
||||||
|
session_id = args["session_id"]
|
||||||
|
if session_id in CRAWLER_SESSIONS:
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"error": f"Session {session_id} already exists",
|
||||||
|
"success": False
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
crawler_config = BrowserConfig(
|
||||||
|
headless=args.get("headless", True),
|
||||||
|
verbose=False
|
||||||
|
)
|
||||||
|
|
||||||
|
crawler = AsyncWebCrawler(config=crawler_config)
|
||||||
|
await crawler.__aenter__()
|
||||||
|
CRAWLER_SESSIONS[session_id] = crawler
|
||||||
|
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"success": True,
|
||||||
|
"session_id": session_id,
|
||||||
|
"message": f"Browser session {session_id} started"
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("navigate", "Navigate to a URL in an active session.", {
|
||||||
|
"session_id": str,
|
||||||
|
"url": str,
|
||||||
|
"wait_for": str, # Optional: CSS selector to wait for
|
||||||
|
"js_code": str, # Optional: JavaScript to execute after load
|
||||||
|
})
|
||||||
|
async def navigate(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Navigate to URL in session."""
|
||||||
|
|
||||||
|
session_id = args["session_id"]
|
||||||
|
if session_id not in CRAWLER_SESSIONS:
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"error": f"Session {session_id} not found",
|
||||||
|
"success": False
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
crawler = CRAWLER_SESSIONS[session_id]
|
||||||
|
run_config = CrawlerRunConfig(
|
||||||
|
cache_mode=CacheMode.BYPASS,
|
||||||
|
wait_for=args.get("wait_for"),
|
||||||
|
js_code=args.get("js_code"),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await crawler.arun(url=args["url"], config=run_config)
|
||||||
|
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"success": result.success,
|
||||||
|
"url": result.url,
|
||||||
|
"message": f"Navigated to {args['url']}"
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("extract_data", "Extract data from current page in session using schema or return markdown.", {
|
||||||
|
"session_id": str,
|
||||||
|
"output_format": str, # "markdown" | "structured"
|
||||||
|
"extraction_schema": str, # Required for structured, JSON schema
|
||||||
|
"wait_for": str, # Optional: Wait for element before extraction
|
||||||
|
"js_code": str, # Optional: Execute JS before extraction
|
||||||
|
})
|
||||||
|
async def extract_data(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Extract data from current page."""
|
||||||
|
|
||||||
|
session_id = args["session_id"]
|
||||||
|
if session_id not in CRAWLER_SESSIONS:
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"error": f"Session {session_id} not found",
|
||||||
|
"success": False
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
crawler = CRAWLER_SESSIONS[session_id]
|
||||||
|
run_config = CrawlerRunConfig(
|
||||||
|
cache_mode=CacheMode.BYPASS,
|
||||||
|
wait_for=args.get("wait_for"),
|
||||||
|
js_code=args.get("js_code"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if args["output_format"] == "structured" and args.get("extraction_schema"):
|
||||||
|
run_config.extraction_strategy = LLMExtractionStrategy(
|
||||||
|
provider="openai/gpt-4o-mini",
|
||||||
|
schema=json.loads(args["extraction_schema"]),
|
||||||
|
instruction="Extract data according to schema."
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await crawler.arun(config=run_config)
|
||||||
|
|
||||||
|
if not result.success:
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"error": result.error_message,
|
||||||
|
"success": False
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
data = (result.extracted_content if args["output_format"] == "structured"
|
||||||
|
else result.markdown_v2.raw_markdown if result.markdown_v2 else "")
|
||||||
|
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"success": True,
|
||||||
|
"data": data
|
||||||
|
}, indent=2)}]}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("execute_js", "Execute JavaScript in the current page context.", {
|
||||||
|
"session_id": str,
|
||||||
|
"js_code": str,
|
||||||
|
"wait_for": str, # Optional: Wait for element after execution
|
||||||
|
})
|
||||||
|
async def execute_js(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Execute JavaScript in session."""
|
||||||
|
|
||||||
|
session_id = args["session_id"]
|
||||||
|
if session_id not in CRAWLER_SESSIONS:
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"error": f"Session {session_id} not found",
|
||||||
|
"success": False
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
crawler = CRAWLER_SESSIONS[session_id]
|
||||||
|
run_config = CrawlerRunConfig(
|
||||||
|
cache_mode=CacheMode.BYPASS,
|
||||||
|
js_code=args["js_code"],
|
||||||
|
wait_for=args.get("wait_for"),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await crawler.arun(config=run_config)
|
||||||
|
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"success": result.success,
|
||||||
|
"message": "JavaScript executed"
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("screenshot", "Take a screenshot of the current page.", {
|
||||||
|
"session_id": str,
|
||||||
|
})
|
||||||
|
async def screenshot(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Capture screenshot."""
|
||||||
|
|
||||||
|
session_id = args["session_id"]
|
||||||
|
if session_id not in CRAWLER_SESSIONS:
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"error": f"Session {session_id} not found",
|
||||||
|
"success": False
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
crawler = CRAWLER_SESSIONS[session_id]
|
||||||
|
result = await crawler.arun(config=CrawlerRunConfig(cache_mode=CacheMode.BYPASS))
|
||||||
|
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"success": True,
|
||||||
|
"screenshot": result.screenshot if result.success else None
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("close_session", "Close and cleanup a browser session.", {
|
||||||
|
"session_id": str,
|
||||||
|
})
|
||||||
|
async def close_session(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Close crawler session."""
|
||||||
|
|
||||||
|
session_id = args["session_id"]
|
||||||
|
if session_id not in CRAWLER_SESSIONS:
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"error": f"Session {session_id} not found",
|
||||||
|
"success": False
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
crawler = CRAWLER_SESSIONS.pop(session_id)
|
||||||
|
await crawler.__aexit__(None, None, None)
|
||||||
|
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"success": True,
|
||||||
|
"message": f"Session {session_id} closed"
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
|
||||||
|
# Export all tools
|
||||||
|
CRAWL_TOOLS = [
|
||||||
|
quick_crawl,
|
||||||
|
start_session,
|
||||||
|
navigate,
|
||||||
|
extract_data,
|
||||||
|
execute_js,
|
||||||
|
screenshot,
|
||||||
|
close_session,
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# c4ai_prompts.py
|
||||||
|
"""System prompts for Crawl4AI agent."""
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """You are an expert web crawling and browser automation agent powered by Crawl4AI.
|
||||||
|
|
||||||
|
# Core Capabilities
|
||||||
|
|
||||||
|
You can perform sophisticated multi-step web scraping and automation tasks through two modes:
|
||||||
|
|
||||||
|
## Quick Mode (simple tasks)
|
||||||
|
- Use `quick_crawl` for single-page data extraction
|
||||||
|
- Best for: simple scrapes, getting page content, one-time extractions
|
||||||
|
|
||||||
|
## Session Mode (complex tasks)
|
||||||
|
- Use `start_session` to create persistent browser sessions
|
||||||
|
- Navigate, interact, extract data across multiple pages
|
||||||
|
- Essential for: workflows requiring JS execution, pagination, filtering, multi-step automation
|
||||||
|
|
||||||
|
# Tool Usage Patterns
|
||||||
|
|
||||||
|
## Simple Extraction
|
||||||
|
1. Use `quick_crawl` with appropriate output_format
|
||||||
|
2. Provide extraction_schema for structured data
|
||||||
|
|
||||||
|
## Multi-Step Workflow
|
||||||
|
1. `start_session` - Create browser session with unique ID
|
||||||
|
2. `navigate` - Go to target URL
|
||||||
|
3. `execute_js` - Interact with page (click buttons, scroll, fill forms)
|
||||||
|
4. `extract_data` - Get data using schema or markdown
|
||||||
|
5. Repeat steps 2-4 as needed
|
||||||
|
6. `close_session` - Clean up when done
|
||||||
|
|
||||||
|
# Critical Instructions
|
||||||
|
|
||||||
|
1. **Iteration & Validation**: When tasks require filtering or conditional logic:
|
||||||
|
- Extract data first, analyze results
|
||||||
|
- Filter/validate in your reasoning
|
||||||
|
- Make subsequent tool calls based on validation
|
||||||
|
- Continue until task criteria are met
|
||||||
|
|
||||||
|
2. **Structured Extraction**: Always use JSON schemas for structured data:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"field_name": {"type": "string"},
|
||||||
|
"price": {"type": "number"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Session Management**:
|
||||||
|
- Generate unique session IDs (e.g., "product_scrape_001")
|
||||||
|
- Always close sessions when done
|
||||||
|
- Use sessions for tasks requiring multiple page visits
|
||||||
|
|
||||||
|
4. **JavaScript Execution**:
|
||||||
|
- Use for: clicking buttons, scrolling, waiting for dynamic content
|
||||||
|
- Example: `js_code: "document.querySelector('.load-more').click()"`
|
||||||
|
- Combine with `wait_for` to ensure content loads
|
||||||
|
|
||||||
|
5. **Error Handling**:
|
||||||
|
- Check `success` field in all responses
|
||||||
|
- Retry with different strategies if extraction fails
|
||||||
|
- Report specific errors to user
|
||||||
|
|
||||||
|
6. **Data Persistence**:
|
||||||
|
- Save results using `Write` tool to JSON files
|
||||||
|
- Use descriptive filenames with timestamps
|
||||||
|
- Structure data clearly for user consumption
|
||||||
|
|
||||||
|
# Example Workflows
|
||||||
|
|
||||||
|
## Workflow 1: Filter & Crawl
|
||||||
|
Task: "Find products >$10, crawl each, extract details"
|
||||||
|
|
||||||
|
1. `quick_crawl` product listing page with schema for [name, price, url]
|
||||||
|
2. Analyze results, filter price > 10 in reasoning
|
||||||
|
3. `start_session` for detailed crawling
|
||||||
|
4. For each filtered product:
|
||||||
|
- `navigate` to product URL
|
||||||
|
- `extract_data` with detail schema
|
||||||
|
5. Aggregate results
|
||||||
|
6. `close_session`
|
||||||
|
7. `Write` results to JSON
|
||||||
|
|
||||||
|
## Workflow 2: Paginated Scraping
|
||||||
|
Task: "Scrape all items across multiple pages"
|
||||||
|
|
||||||
|
1. `start_session`
|
||||||
|
2. `navigate` to page 1
|
||||||
|
3. `extract_data` items from current page
|
||||||
|
4. Check for "next" button
|
||||||
|
5. `execute_js` to click next
|
||||||
|
6. Repeat 3-5 until no more pages
|
||||||
|
7. `close_session`
|
||||||
|
8. Save aggregated data
|
||||||
|
|
||||||
|
## Workflow 3: Dynamic Content
|
||||||
|
Task: "Scrape reviews after clicking 'Load More'"
|
||||||
|
|
||||||
|
1. `start_session`
|
||||||
|
2. `navigate` to product page
|
||||||
|
3. `execute_js` to click load more button
|
||||||
|
4. `wait_for` reviews container
|
||||||
|
5. `extract_data` all reviews
|
||||||
|
6. `close_session`
|
||||||
|
|
||||||
|
# Quality Guidelines
|
||||||
|
|
||||||
|
- **Be thorough**: Don't stop until task requirements are fully met
|
||||||
|
- **Validate data**: Check extracted data matches expected format
|
||||||
|
- **Handle edge cases**: Empty results, pagination limits, rate limiting
|
||||||
|
- **Clear reporting**: Summarize what was found, any issues encountered
|
||||||
|
- **Efficient**: Use quick_crawl when possible, sessions only when needed
|
||||||
|
|
||||||
|
# Output Format
|
||||||
|
|
||||||
|
When saving data, use clean JSON structure:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"scraped_at": "ISO timestamp",
|
||||||
|
"source_url": "...",
|
||||||
|
"total_items": 0
|
||||||
|
},
|
||||||
|
"data": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Always provide a final summary of:
|
||||||
|
- Items found/processed
|
||||||
|
- Time taken
|
||||||
|
- Files created
|
||||||
|
- Any warnings/errors
|
||||||
|
|
||||||
|
Remember: You have unlimited turns to complete the task. Take your time, validate each step, and ensure quality results."""
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# agent_crawl.py
|
||||||
|
"""Crawl4AI Agent CLI - Browser automation agent powered by Claude Code SDK."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions, create_sdk_mcp_server
|
||||||
|
from claude_agent_sdk import AssistantMessage, TextBlock, ResultMessage
|
||||||
|
|
||||||
|
from c4ai_tools import CRAWL_TOOLS
|
||||||
|
from c4ai_prompts import SYSTEM_PROMPT
|
||||||
|
|
||||||
|
|
||||||
|
class SessionStorage:
|
||||||
|
"""Manage session storage in ~/.crawl4ai/agents/projects/"""
|
||||||
|
|
||||||
|
def __init__(self, cwd: Optional[str] = None):
|
||||||
|
self.cwd = Path(cwd) if cwd else Path.cwd()
|
||||||
|
self.base_dir = Path.home() / ".crawl4ai" / "agents" / "projects"
|
||||||
|
self.project_dir = self.base_dir / self._sanitize_path(str(self.cwd.resolve()))
|
||||||
|
self.project_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.session_id = str(uuid.uuid4())
|
||||||
|
self.log_file = self.project_dir / f"{self.session_id}.jsonl"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sanitize_path(path: str) -> str:
|
||||||
|
"""Convert /Users/unclecode/devs/test to -Users-unclecode-devs-test"""
|
||||||
|
return path.replace("/", "-").replace("\\", "-")
|
||||||
|
|
||||||
|
def log(self, event_type: str, data: dict):
|
||||||
|
"""Append event to JSONL log."""
|
||||||
|
entry = {
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"event": event_type,
|
||||||
|
"session_id": self.session_id,
|
||||||
|
"data": data
|
||||||
|
}
|
||||||
|
with open(self.log_file, "a") as f:
|
||||||
|
f.write(json.dumps(entry) + "\n")
|
||||||
|
|
||||||
|
def get_session_path(self) -> str:
|
||||||
|
"""Return path to current session log."""
|
||||||
|
return str(self.log_file)
|
||||||
|
|
||||||
|
|
||||||
|
class CrawlAgent:
|
||||||
|
"""Crawl4AI agent wrapper."""
|
||||||
|
|
||||||
|
def __init__(self, args: argparse.Namespace):
|
||||||
|
self.args = args
|
||||||
|
self.storage = SessionStorage(args.add_dir[0] if args.add_dir else None)
|
||||||
|
self.client: Optional[ClaudeSDKClient] = None
|
||||||
|
|
||||||
|
# Create MCP server with crawl tools
|
||||||
|
self.crawler_server = create_sdk_mcp_server(
|
||||||
|
name="crawl4ai",
|
||||||
|
version="1.0.0",
|
||||||
|
tools=CRAWL_TOOLS
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build options
|
||||||
|
self.options = ClaudeAgentOptions(
|
||||||
|
mcp_servers={"crawler": self.crawler_server},
|
||||||
|
allowed_tools=[
|
||||||
|
"mcp__crawler__quick_crawl",
|
||||||
|
"mcp__crawler__start_session",
|
||||||
|
"mcp__crawler__navigate",
|
||||||
|
"mcp__crawler__extract_data",
|
||||||
|
"mcp__crawler__execute_js",
|
||||||
|
"mcp__crawler__screenshot",
|
||||||
|
"mcp__crawler__close_session",
|
||||||
|
"Write", "Read", "Bash"
|
||||||
|
],
|
||||||
|
system_prompt=SYSTEM_PROMPT if not args.system_prompt else args.system_prompt,
|
||||||
|
permission_mode=args.permission_mode or "acceptEdits",
|
||||||
|
cwd=args.add_dir[0] if args.add_dir else str(Path.cwd()),
|
||||||
|
model=args.model,
|
||||||
|
session_id=args.session_id or self.storage.session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run(self, prompt: str):
|
||||||
|
"""Execute crawl task."""
|
||||||
|
|
||||||
|
self.storage.log("session_start", {
|
||||||
|
"prompt": prompt,
|
||||||
|
"cwd": self.options.cwd,
|
||||||
|
"model": self.options.model
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"\n🕷️ Crawl4AI Agent")
|
||||||
|
print(f"📁 Session: {self.storage.session_id}")
|
||||||
|
print(f"💾 Log: {self.storage.get_session_path()}")
|
||||||
|
print(f"🎯 Task: {prompt}\n")
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=self.options) as client:
|
||||||
|
self.client = client
|
||||||
|
await client.query(prompt)
|
||||||
|
|
||||||
|
turn = 0
|
||||||
|
async for message in client.receive_messages():
|
||||||
|
turn += 1
|
||||||
|
|
||||||
|
if isinstance(message, AssistantMessage):
|
||||||
|
for block in message.content:
|
||||||
|
if isinstance(block, TextBlock):
|
||||||
|
print(f"\n💭 [{turn}] {block.text}")
|
||||||
|
self.storage.log("assistant_message", {"turn": turn, "text": block.text})
|
||||||
|
|
||||||
|
elif isinstance(message, ResultMessage):
|
||||||
|
print(f"\n✅ Completed in {message.duration_ms/1000:.2f}s")
|
||||||
|
print(f"💰 Cost: ${message.total_cost_usd:.4f}" if message.total_cost_usd else "")
|
||||||
|
print(f"🔄 Turns: {message.num_turns}")
|
||||||
|
|
||||||
|
self.storage.log("session_end", {
|
||||||
|
"duration_ms": message.duration_ms,
|
||||||
|
"cost_usd": message.total_cost_usd,
|
||||||
|
"turns": message.num_turns,
|
||||||
|
"success": not message.is_error
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"\n📊 Session log: {self.storage.get_session_path()}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Crawl4AI Agent - Browser automation powered by Claude Code SDK",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument("prompt", nargs="?", help="Your crawling task prompt")
|
||||||
|
parser.add_argument("--system-prompt", help="Custom system prompt")
|
||||||
|
parser.add_argument("--permission-mode", choices=["acceptEdits", "bypassPermissions", "default", "plan"],
|
||||||
|
help="Permission mode for tool execution")
|
||||||
|
parser.add_argument("--model", help="Model to use (e.g., 'sonnet', 'opus')")
|
||||||
|
parser.add_argument("--add-dir", nargs="+", help="Additional directories for file access")
|
||||||
|
parser.add_argument("--session-id", help="Use specific session ID (UUID)")
|
||||||
|
parser.add_argument("-v", "--version", action="version", version="Crawl4AI Agent 1.0.0")
|
||||||
|
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.prompt:
|
||||||
|
parser.print_help()
|
||||||
|
print("\nExample usage:")
|
||||||
|
print(' crawl-agent "Scrape all products from example.com with price > $10"')
|
||||||
|
print(' crawl-agent --add-dir ~/projects "Find all Python files and analyze imports"')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
agent = CrawlAgent(args)
|
||||||
|
asyncio.run(agent.run(args.prompt))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\n⚠️ Interrupted by user")
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Error: {e}")
|
||||||
|
if args.debug:
|
||||||
|
raise
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Simple scrape
|
||||||
|
python agent_crawl.py "Get all product names from example.com"
|
||||||
|
|
||||||
|
# Complex filtering
|
||||||
|
python agent_crawl.py "Find products >$10 from shop.com, crawl each, extract id/name/price"
|
||||||
|
|
||||||
|
# Multi-step automation
|
||||||
|
python agent_crawl.py "Go to amazon.com, search 'laptop', filter 4+ stars, scrape top 10"
|
||||||
|
|
||||||
|
# With options
|
||||||
|
python agent_crawl.py --add-dir ~/projects --model sonnet "Scrape competitor prices"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Session logs stored at:**
|
||||||
|
`~/.crawl4ai/agents/projects/-Users-unclecode-devs-test/{uuid}.jsonl`
|
||||||
202
crawl4ai/agent/agent_crawl.py
Normal file
202
crawl4ai/agent/agent_crawl.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
# agent_crawl.py
|
||||||
|
"""Crawl4AI Agent CLI - Browser automation agent powered by Claude Code SDK."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions, create_sdk_mcp_server
|
||||||
|
from claude_agent_sdk import AssistantMessage, TextBlock, ResultMessage
|
||||||
|
|
||||||
|
from .c4ai_tools import CRAWL_TOOLS
|
||||||
|
from .c4ai_prompts import SYSTEM_PROMPT
|
||||||
|
from .terminal_ui import TerminalUI
|
||||||
|
from .chat_mode import ChatMode
|
||||||
|
|
||||||
|
|
||||||
|
class SessionStorage:
|
||||||
|
"""Manage session storage in ~/.crawl4ai/agents/projects/"""
|
||||||
|
|
||||||
|
def __init__(self, cwd: Optional[str] = None):
|
||||||
|
self.cwd = Path(cwd) if cwd else Path.cwd()
|
||||||
|
self.base_dir = Path.home() / ".crawl4ai" / "agents" / "projects"
|
||||||
|
self.project_dir = self.base_dir / self._sanitize_path(str(self.cwd.resolve()))
|
||||||
|
self.project_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.session_id = str(uuid.uuid4())
|
||||||
|
self.log_file = self.project_dir / f"{self.session_id}.jsonl"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sanitize_path(path: str) -> str:
|
||||||
|
"""Convert /Users/unclecode/devs/test to -Users-unclecode-devs-test"""
|
||||||
|
return path.replace("/", "-").replace("\\", "-")
|
||||||
|
|
||||||
|
def log(self, event_type: str, data: dict):
|
||||||
|
"""Append event to JSONL log."""
|
||||||
|
entry = {
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"event": event_type,
|
||||||
|
"session_id": self.session_id,
|
||||||
|
"data": data
|
||||||
|
}
|
||||||
|
with open(self.log_file, "a") as f:
|
||||||
|
f.write(json.dumps(entry) + "\n")
|
||||||
|
|
||||||
|
def get_session_path(self) -> str:
|
||||||
|
"""Return path to current session log."""
|
||||||
|
return str(self.log_file)
|
||||||
|
|
||||||
|
|
||||||
|
class CrawlAgent:
|
||||||
|
"""Crawl4AI agent wrapper."""
|
||||||
|
|
||||||
|
def __init__(self, args: argparse.Namespace):
|
||||||
|
self.args = args
|
||||||
|
self.storage = SessionStorage(args.add_dir[0] if args.add_dir else None)
|
||||||
|
self.client: Optional[ClaudeSDKClient] = None
|
||||||
|
|
||||||
|
# Create MCP server with crawl tools
|
||||||
|
self.crawler_server = create_sdk_mcp_server(
|
||||||
|
name="crawl4ai",
|
||||||
|
version="1.0.0",
|
||||||
|
tools=CRAWL_TOOLS
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build options
|
||||||
|
self.options = ClaudeAgentOptions(
|
||||||
|
mcp_servers={"crawler": self.crawler_server},
|
||||||
|
allowed_tools=[
|
||||||
|
# Crawl4AI tools
|
||||||
|
"mcp__crawler__quick_crawl",
|
||||||
|
"mcp__crawler__start_session",
|
||||||
|
"mcp__crawler__navigate",
|
||||||
|
"mcp__crawler__extract_data",
|
||||||
|
"mcp__crawler__execute_js",
|
||||||
|
"mcp__crawler__screenshot",
|
||||||
|
"mcp__crawler__close_session",
|
||||||
|
# Claude Code SDK built-in tools
|
||||||
|
"Read",
|
||||||
|
"Write",
|
||||||
|
"Edit",
|
||||||
|
"Glob",
|
||||||
|
"Grep",
|
||||||
|
"Bash",
|
||||||
|
"NotebookEdit"
|
||||||
|
],
|
||||||
|
system_prompt=SYSTEM_PROMPT if not args.system_prompt else args.system_prompt,
|
||||||
|
permission_mode=args.permission_mode or "acceptEdits",
|
||||||
|
cwd=args.add_dir[0] if args.add_dir else str(Path.cwd()),
|
||||||
|
model=args.model,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run(self, prompt: str):
|
||||||
|
"""Execute crawl task."""
|
||||||
|
|
||||||
|
self.storage.log("session_start", {
|
||||||
|
"prompt": prompt,
|
||||||
|
"cwd": self.options.cwd,
|
||||||
|
"model": self.options.model
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"\n🕷️ Crawl4AI Agent")
|
||||||
|
print(f"📁 Session: {self.storage.session_id}")
|
||||||
|
print(f"💾 Log: {self.storage.get_session_path()}")
|
||||||
|
print(f"🎯 Task: {prompt}\n")
|
||||||
|
|
||||||
|
async with ClaudeSDKClient(options=self.options) as client:
|
||||||
|
self.client = client
|
||||||
|
await client.query(prompt)
|
||||||
|
|
||||||
|
turn = 0
|
||||||
|
async for message in client.receive_messages():
|
||||||
|
turn += 1
|
||||||
|
|
||||||
|
if isinstance(message, AssistantMessage):
|
||||||
|
for block in message.content:
|
||||||
|
if isinstance(block, TextBlock):
|
||||||
|
print(f"\n💭 [{turn}] {block.text}")
|
||||||
|
self.storage.log("assistant_message", {"turn": turn, "text": block.text})
|
||||||
|
|
||||||
|
elif isinstance(message, ResultMessage):
|
||||||
|
print(f"\n✅ Completed in {message.duration_ms/1000:.2f}s")
|
||||||
|
print(f"💰 Cost: ${message.total_cost_usd:.4f}" if message.total_cost_usd else "")
|
||||||
|
print(f"🔄 Turns: {message.num_turns}")
|
||||||
|
|
||||||
|
self.storage.log("session_end", {
|
||||||
|
"duration_ms": message.duration_ms,
|
||||||
|
"cost_usd": message.total_cost_usd,
|
||||||
|
"turns": message.num_turns,
|
||||||
|
"success": not message.is_error
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"\n📊 Session log: {self.storage.get_session_path()}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Crawl4AI Agent - Browser automation powered by Claude Code SDK",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument("prompt", nargs="?", help="Your crawling task prompt (not used in --chat mode)")
|
||||||
|
parser.add_argument("--chat", action="store_true", help="Start interactive chat mode")
|
||||||
|
parser.add_argument("--system-prompt", help="Custom system prompt")
|
||||||
|
parser.add_argument("--permission-mode", choices=["acceptEdits", "bypassPermissions", "default", "plan"],
|
||||||
|
help="Permission mode for tool execution")
|
||||||
|
parser.add_argument("--model", help="Model to use (e.g., 'sonnet', 'opus')")
|
||||||
|
parser.add_argument("--add-dir", nargs="+", help="Additional directories for file access")
|
||||||
|
parser.add_argument("--session-id", help="Use specific session ID (UUID)")
|
||||||
|
parser.add_argument("-v", "--version", action="version", version="Crawl4AI Agent 1.0.0")
|
||||||
|
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Chat mode - interactive
|
||||||
|
if args.chat:
|
||||||
|
try:
|
||||||
|
agent = CrawlAgent(args)
|
||||||
|
ui = TerminalUI()
|
||||||
|
chat = ChatMode(agent.options, ui, agent.storage)
|
||||||
|
asyncio.run(chat.run())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\n⚠️ Chat interrupted by user")
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Error: {e}")
|
||||||
|
if args.debug:
|
||||||
|
raise
|
||||||
|
sys.exit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Single-shot mode - requires prompt
|
||||||
|
if not args.prompt:
|
||||||
|
parser.print_help()
|
||||||
|
print("\nExample usage:")
|
||||||
|
print(' # Single-shot mode:')
|
||||||
|
print(' crawl-agent "Scrape all products from example.com with price > $10"')
|
||||||
|
print(' crawl-agent --add-dir ~/projects "Find all Python files and analyze imports"')
|
||||||
|
print()
|
||||||
|
print(' # Interactive chat mode:')
|
||||||
|
print(' crawl-agent --chat')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
agent = CrawlAgent(args)
|
||||||
|
asyncio.run(agent.run(args.prompt))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\n⚠️ Interrupted by user")
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Error: {e}")
|
||||||
|
if args.debug:
|
||||||
|
raise
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
73
crawl4ai/agent/browser_manager.py
Normal file
73
crawl4ai/agent/browser_manager.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""Browser session management with singleton pattern for persistent browser instances."""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from crawl4ai import AsyncWebCrawler, BrowserConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserManager:
|
||||||
|
"""Singleton browser manager for persistent browser sessions across agent operations."""
|
||||||
|
|
||||||
|
_instance: Optional['BrowserManager'] = None
|
||||||
|
_crawler: Optional[AsyncWebCrawler] = None
|
||||||
|
_config: Optional[BrowserConfig] = None
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_browser(cls, config: Optional[BrowserConfig] = None) -> AsyncWebCrawler:
|
||||||
|
"""
|
||||||
|
Get or create the singleton browser instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Optional browser configuration. Only used if no browser exists yet.
|
||||||
|
To change config, use reconfigure_browser() instead.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AsyncWebCrawler instance
|
||||||
|
"""
|
||||||
|
# Create new browser if needed
|
||||||
|
if cls._crawler is None:
|
||||||
|
# Create default config if none provided
|
||||||
|
if config is None:
|
||||||
|
config = BrowserConfig(headless=True, verbose=False)
|
||||||
|
|
||||||
|
cls._crawler = AsyncWebCrawler(config=config)
|
||||||
|
await cls._crawler.start()
|
||||||
|
cls._config = config
|
||||||
|
|
||||||
|
return cls._crawler
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def reconfigure_browser(cls, new_config: BrowserConfig) -> AsyncWebCrawler:
|
||||||
|
"""
|
||||||
|
Close current browser and create a new one with different configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
new_config: New browser configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New AsyncWebCrawler instance
|
||||||
|
"""
|
||||||
|
await cls.close_browser()
|
||||||
|
return await cls.get_browser(new_config)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def close_browser(cls):
|
||||||
|
"""Close the current browser instance and cleanup."""
|
||||||
|
if cls._crawler is not None:
|
||||||
|
await cls._crawler.close()
|
||||||
|
cls._crawler = None
|
||||||
|
cls._config = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_browser_active(cls) -> bool:
|
||||||
|
"""Check if browser is currently active."""
|
||||||
|
return cls._crawler is not None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_current_config(cls) -> Optional[BrowserConfig]:
|
||||||
|
"""Get the current browser configuration."""
|
||||||
|
return cls._config
|
||||||
137
crawl4ai/agent/c4ai_prompts.py
Normal file
137
crawl4ai/agent/c4ai_prompts.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# c4ai_prompts.py
|
||||||
|
"""System prompts for Crawl4AI agent."""
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """You are an expert web crawling and browser automation agent powered by Crawl4AI.
|
||||||
|
|
||||||
|
# Core Capabilities
|
||||||
|
|
||||||
|
You can perform sophisticated multi-step web scraping and automation tasks through two modes:
|
||||||
|
|
||||||
|
## Quick Mode (simple tasks)
|
||||||
|
- Use `quick_crawl` for single-page data extraction
|
||||||
|
- Best for: simple scrapes, getting page content, one-time extractions
|
||||||
|
|
||||||
|
## Session Mode (complex tasks)
|
||||||
|
- Use `start_session` to create persistent browser sessions
|
||||||
|
- Navigate, interact, extract data across multiple pages
|
||||||
|
- Essential for: workflows requiring JS execution, pagination, filtering, multi-step automation
|
||||||
|
|
||||||
|
# Tool Usage Patterns
|
||||||
|
|
||||||
|
## Simple Extraction
|
||||||
|
1. Use `quick_crawl` with appropriate output_format
|
||||||
|
2. Provide extraction_schema for structured data
|
||||||
|
|
||||||
|
## Multi-Step Workflow
|
||||||
|
1. `start_session` - Create browser session with unique ID
|
||||||
|
2. `navigate` - Go to target URL
|
||||||
|
3. `execute_js` - Interact with page (click buttons, scroll, fill forms)
|
||||||
|
4. `extract_data` - Get data using schema or markdown
|
||||||
|
5. Repeat steps 2-4 as needed
|
||||||
|
6. `close_session` - Clean up when done
|
||||||
|
|
||||||
|
# Critical Instructions
|
||||||
|
|
||||||
|
1. **Iteration & Validation**: When tasks require filtering or conditional logic:
|
||||||
|
- Extract data first, analyze results
|
||||||
|
- Filter/validate in your reasoning
|
||||||
|
- Make subsequent tool calls based on validation
|
||||||
|
- Continue until task criteria are met
|
||||||
|
|
||||||
|
2. **Structured Extraction**: Always use JSON schemas for structured data:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"field_name": {"type": "string"},
|
||||||
|
"price": {"type": "number"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Session Management**:
|
||||||
|
- Generate unique session IDs (e.g., "product_scrape_001")
|
||||||
|
- Always close sessions when done
|
||||||
|
- Use sessions for tasks requiring multiple page visits
|
||||||
|
|
||||||
|
4. **JavaScript Execution**:
|
||||||
|
- Use for: clicking buttons, scrolling, waiting for dynamic content
|
||||||
|
- Example: `js_code: "document.querySelector('.load-more').click()"`
|
||||||
|
- Combine with `wait_for` to ensure content loads
|
||||||
|
|
||||||
|
5. **Error Handling**:
|
||||||
|
- Check `success` field in all responses
|
||||||
|
- Retry with different strategies if extraction fails
|
||||||
|
- Report specific errors to user
|
||||||
|
|
||||||
|
6. **Data Persistence**:
|
||||||
|
- Save results using `Write` tool to JSON files
|
||||||
|
- Use descriptive filenames with timestamps
|
||||||
|
- Structure data clearly for user consumption
|
||||||
|
|
||||||
|
# Example Workflows
|
||||||
|
|
||||||
|
## Workflow 1: Filter & Crawl
|
||||||
|
Task: "Find products >$10, crawl each, extract details"
|
||||||
|
|
||||||
|
1. `quick_crawl` product listing page with schema for [name, price, url]
|
||||||
|
2. Analyze results, filter price > 10 in reasoning
|
||||||
|
3. `start_session` for detailed crawling
|
||||||
|
4. For each filtered product:
|
||||||
|
- `navigate` to product URL
|
||||||
|
- `extract_data` with detail schema
|
||||||
|
5. Aggregate results
|
||||||
|
6. `close_session`
|
||||||
|
7. `Write` results to JSON
|
||||||
|
|
||||||
|
## Workflow 2: Paginated Scraping
|
||||||
|
Task: "Scrape all items across multiple pages"
|
||||||
|
|
||||||
|
1. `start_session`
|
||||||
|
2. `navigate` to page 1
|
||||||
|
3. `extract_data` items from current page
|
||||||
|
4. Check for "next" button
|
||||||
|
5. `execute_js` to click next
|
||||||
|
6. Repeat 3-5 until no more pages
|
||||||
|
7. `close_session`
|
||||||
|
8. Save aggregated data
|
||||||
|
|
||||||
|
## Workflow 3: Dynamic Content
|
||||||
|
Task: "Scrape reviews after clicking 'Load More'"
|
||||||
|
|
||||||
|
1. `start_session`
|
||||||
|
2. `navigate` to product page
|
||||||
|
3. `execute_js` to click load more button
|
||||||
|
4. `wait_for` reviews container
|
||||||
|
5. `extract_data` all reviews
|
||||||
|
6. `close_session`
|
||||||
|
|
||||||
|
# Quality Guidelines
|
||||||
|
|
||||||
|
- **Be thorough**: Don't stop until task requirements are fully met
|
||||||
|
- **Validate data**: Check extracted data matches expected format
|
||||||
|
- **Handle edge cases**: Empty results, pagination limits, rate limiting
|
||||||
|
- **Clear reporting**: Summarize what was found, any issues encountered
|
||||||
|
- **Efficient**: Use quick_crawl when possible, sessions only when needed
|
||||||
|
|
||||||
|
# Output Format
|
||||||
|
|
||||||
|
When saving data, use clean JSON structure:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"scraped_at": "ISO timestamp",
|
||||||
|
"source_url": "...",
|
||||||
|
"total_items": 0
|
||||||
|
},
|
||||||
|
"data": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Always provide a final summary of:
|
||||||
|
- Items found/processed
|
||||||
|
- Time taken
|
||||||
|
- Files created
|
||||||
|
- Any warnings/errors
|
||||||
|
|
||||||
|
Remember: You have unlimited turns to complete the task. Take your time, validate each step, and ensure quality results."""
|
||||||
314
crawl4ai/agent/c4ai_tools.py
Normal file
314
crawl4ai/agent/c4ai_tools.py
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
# c4ai_tools.py
|
||||||
|
"""Crawl4AI tools for Claude Code SDK agent."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
from typing import Any, Dict
|
||||||
|
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode
|
||||||
|
from crawl4ai.extraction_strategy import LLMExtractionStrategy
|
||||||
|
from claude_agent_sdk import tool
|
||||||
|
|
||||||
|
from .browser_manager import BrowserManager
|
||||||
|
|
||||||
|
# Global session storage (for named sessions only)
|
||||||
|
CRAWLER_SESSIONS: Dict[str, AsyncWebCrawler] = {}
|
||||||
|
CRAWLER_SESSION_URLS: Dict[str, str] = {} # Track current URL per session
|
||||||
|
|
||||||
|
@tool("quick_crawl", "One-shot crawl for simple extraction. Returns markdown, HTML, or structured data.", {
|
||||||
|
"url": str,
|
||||||
|
"output_format": str, # "markdown" | "html" | "structured" | "screenshot"
|
||||||
|
"extraction_schema": str, # Optional: JSON schema for structured extraction
|
||||||
|
"js_code": str, # Optional: JavaScript to execute before extraction
|
||||||
|
"wait_for": str, # Optional: CSS selector to wait for
|
||||||
|
})
|
||||||
|
async def quick_crawl(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Fast single-page crawl using persistent browser."""
|
||||||
|
|
||||||
|
# Use singleton browser manager
|
||||||
|
crawler_config = BrowserConfig(headless=True, verbose=False)
|
||||||
|
crawler = await BrowserManager.get_browser(crawler_config)
|
||||||
|
|
||||||
|
run_config = CrawlerRunConfig(
|
||||||
|
cache_mode=CacheMode.BYPASS,
|
||||||
|
js_code=args.get("js_code"),
|
||||||
|
wait_for=args.get("wait_for"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add extraction strategy if structured data requested
|
||||||
|
if args.get("extraction_schema"):
|
||||||
|
run_config.extraction_strategy = LLMExtractionStrategy(
|
||||||
|
provider="openai/gpt-4o-mini",
|
||||||
|
schema=json.loads(args["extraction_schema"]),
|
||||||
|
instruction="Extract data according to the provided schema."
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await crawler.arun(url=args["url"], config=run_config)
|
||||||
|
|
||||||
|
if not result.success:
|
||||||
|
return {
|
||||||
|
"content": [{
|
||||||
|
"type": "text",
|
||||||
|
"text": json.dumps({"error": result.error_message, "success": False})
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle markdown - can be string or MarkdownGenerationResult object
|
||||||
|
markdown_content = ""
|
||||||
|
if isinstance(result.markdown, str):
|
||||||
|
markdown_content = result.markdown
|
||||||
|
elif hasattr(result.markdown, 'raw_markdown'):
|
||||||
|
markdown_content = result.markdown.raw_markdown
|
||||||
|
|
||||||
|
output_map = {
|
||||||
|
"markdown": markdown_content,
|
||||||
|
"html": result.html,
|
||||||
|
"structured": result.extracted_content,
|
||||||
|
"screenshot": result.screenshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"success": True,
|
||||||
|
"url": result.url,
|
||||||
|
"data": output_map.get(args["output_format"], markdown_content)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps(response, indent=2)}]}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("start_session", "Start a named browser session for multi-step crawling and automation.", {
|
||||||
|
"session_id": str,
|
||||||
|
"headless": bool, # Default True
|
||||||
|
})
|
||||||
|
async def start_session(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Initialize a named crawler session using the singleton browser."""
|
||||||
|
|
||||||
|
session_id = args["session_id"]
|
||||||
|
if session_id in CRAWLER_SESSIONS:
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"error": f"Session {session_id} already exists",
|
||||||
|
"success": False
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
# Use the singleton browser
|
||||||
|
crawler_config = BrowserConfig(
|
||||||
|
headless=args.get("headless", True),
|
||||||
|
verbose=False
|
||||||
|
)
|
||||||
|
crawler = await BrowserManager.get_browser(crawler_config)
|
||||||
|
|
||||||
|
# Store reference for named session
|
||||||
|
CRAWLER_SESSIONS[session_id] = crawler
|
||||||
|
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"success": True,
|
||||||
|
"session_id": session_id,
|
||||||
|
"message": f"Browser session {session_id} started"
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("navigate", "Navigate to a URL in an active session.", {
|
||||||
|
"session_id": str,
|
||||||
|
"url": str,
|
||||||
|
"wait_for": str, # Optional: CSS selector to wait for
|
||||||
|
"js_code": str, # Optional: JavaScript to execute after load
|
||||||
|
})
|
||||||
|
async def navigate(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Navigate to URL in session."""
|
||||||
|
|
||||||
|
session_id = args["session_id"]
|
||||||
|
if session_id not in CRAWLER_SESSIONS:
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"error": f"Session {session_id} not found",
|
||||||
|
"success": False
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
crawler = CRAWLER_SESSIONS[session_id]
|
||||||
|
run_config = CrawlerRunConfig(
|
||||||
|
cache_mode=CacheMode.BYPASS,
|
||||||
|
wait_for=args.get("wait_for"),
|
||||||
|
js_code=args.get("js_code"),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await crawler.arun(url=args["url"], config=run_config)
|
||||||
|
|
||||||
|
# Store current URL for this session
|
||||||
|
if result.success:
|
||||||
|
CRAWLER_SESSION_URLS[session_id] = result.url
|
||||||
|
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"success": result.success,
|
||||||
|
"url": result.url,
|
||||||
|
"message": f"Navigated to {args['url']}"
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("extract_data", "Extract data from current page in session using schema or return markdown.", {
|
||||||
|
"session_id": str,
|
||||||
|
"output_format": str, # "markdown" | "structured"
|
||||||
|
"extraction_schema": str, # Required for structured, JSON schema
|
||||||
|
"wait_for": str, # Optional: Wait for element before extraction
|
||||||
|
"js_code": str, # Optional: Execute JS before extraction
|
||||||
|
})
|
||||||
|
async def extract_data(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Extract data from current page."""
|
||||||
|
|
||||||
|
session_id = args["session_id"]
|
||||||
|
if session_id not in CRAWLER_SESSIONS:
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"error": f"Session {session_id} not found",
|
||||||
|
"success": False
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
# Check if we have a current URL for this session
|
||||||
|
if session_id not in CRAWLER_SESSION_URLS:
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"error": "No page loaded in session. Use 'navigate' first.",
|
||||||
|
"success": False
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
crawler = CRAWLER_SESSIONS[session_id]
|
||||||
|
current_url = CRAWLER_SESSION_URLS[session_id]
|
||||||
|
|
||||||
|
run_config = CrawlerRunConfig(
|
||||||
|
cache_mode=CacheMode.BYPASS,
|
||||||
|
wait_for=args.get("wait_for"),
|
||||||
|
js_code=args.get("js_code"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if args["output_format"] == "structured" and args.get("extraction_schema"):
|
||||||
|
run_config.extraction_strategy = LLMExtractionStrategy(
|
||||||
|
provider="openai/gpt-4o-mini",
|
||||||
|
schema=json.loads(args["extraction_schema"]),
|
||||||
|
instruction="Extract data according to schema."
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await crawler.arun(url=current_url, config=run_config)
|
||||||
|
|
||||||
|
if not result.success:
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"error": result.error_message,
|
||||||
|
"success": False
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
# Handle markdown - can be string or MarkdownGenerationResult object
|
||||||
|
markdown_content = ""
|
||||||
|
if isinstance(result.markdown, str):
|
||||||
|
markdown_content = result.markdown
|
||||||
|
elif hasattr(result.markdown, 'raw_markdown'):
|
||||||
|
markdown_content = result.markdown.raw_markdown
|
||||||
|
|
||||||
|
data = (result.extracted_content if args["output_format"] == "structured"
|
||||||
|
else markdown_content)
|
||||||
|
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"success": True,
|
||||||
|
"data": data
|
||||||
|
}, indent=2)}]}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("execute_js", "Execute JavaScript in the current page context.", {
|
||||||
|
"session_id": str,
|
||||||
|
"js_code": str,
|
||||||
|
"wait_for": str, # Optional: Wait for element after execution
|
||||||
|
})
|
||||||
|
async def execute_js(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Execute JavaScript in session."""
|
||||||
|
|
||||||
|
session_id = args["session_id"]
|
||||||
|
if session_id not in CRAWLER_SESSIONS:
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"error": f"Session {session_id} not found",
|
||||||
|
"success": False
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
# Check if we have a current URL for this session
|
||||||
|
if session_id not in CRAWLER_SESSION_URLS:
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"error": "No page loaded in session. Use 'navigate' first.",
|
||||||
|
"success": False
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
crawler = CRAWLER_SESSIONS[session_id]
|
||||||
|
current_url = CRAWLER_SESSION_URLS[session_id]
|
||||||
|
|
||||||
|
run_config = CrawlerRunConfig(
|
||||||
|
cache_mode=CacheMode.BYPASS,
|
||||||
|
js_code=args["js_code"],
|
||||||
|
wait_for=args.get("wait_for"),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await crawler.arun(url=current_url, config=run_config)
|
||||||
|
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"success": result.success,
|
||||||
|
"message": "JavaScript executed"
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("screenshot", "Take a screenshot of the current page.", {
|
||||||
|
"session_id": str,
|
||||||
|
})
|
||||||
|
async def screenshot(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Capture screenshot."""
|
||||||
|
|
||||||
|
session_id = args["session_id"]
|
||||||
|
if session_id not in CRAWLER_SESSIONS:
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"error": f"Session {session_id} not found",
|
||||||
|
"success": False
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
# Check if we have a current URL for this session
|
||||||
|
if session_id not in CRAWLER_SESSION_URLS:
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"error": "No page loaded in session. Use 'navigate' first.",
|
||||||
|
"success": False
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
crawler = CRAWLER_SESSIONS[session_id]
|
||||||
|
current_url = CRAWLER_SESSION_URLS[session_id]
|
||||||
|
|
||||||
|
result = await crawler.arun(
|
||||||
|
url=current_url,
|
||||||
|
config=CrawlerRunConfig(cache_mode=CacheMode.BYPASS, screenshot=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"success": True,
|
||||||
|
"screenshot": result.screenshot if result.success else None
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
|
||||||
|
@tool("close_session", "Close and cleanup a named browser session.", {
|
||||||
|
"session_id": str,
|
||||||
|
})
|
||||||
|
async def close_session(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Close named crawler session (browser stays alive for other operations)."""
|
||||||
|
|
||||||
|
session_id = args["session_id"]
|
||||||
|
if session_id not in CRAWLER_SESSIONS:
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"error": f"Session {session_id} not found",
|
||||||
|
"success": False
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
# Remove from named sessions, but don't close the singleton browser
|
||||||
|
CRAWLER_SESSIONS.pop(session_id)
|
||||||
|
CRAWLER_SESSION_URLS.pop(session_id, None) # Remove URL tracking
|
||||||
|
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({
|
||||||
|
"success": True,
|
||||||
|
"message": f"Session {session_id} closed"
|
||||||
|
})}]}
|
||||||
|
|
||||||
|
|
||||||
|
# Export all tools
|
||||||
|
CRAWL_TOOLS = [
|
||||||
|
quick_crawl,
|
||||||
|
start_session,
|
||||||
|
navigate,
|
||||||
|
extract_data,
|
||||||
|
execute_js,
|
||||||
|
screenshot,
|
||||||
|
close_session,
|
||||||
|
]
|
||||||
166
crawl4ai/agent/chat_mode.py
Normal file
166
crawl4ai/agent/chat_mode.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"""Chat mode implementation with streaming message generator for Claude SDK."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import AsyncGenerator, Dict, Any, Optional
|
||||||
|
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions, AssistantMessage, TextBlock, ResultMessage, ToolUseBlock
|
||||||
|
|
||||||
|
from .terminal_ui import TerminalUI
|
||||||
|
from .browser_manager import BrowserManager
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMode:
|
||||||
|
"""Interactive chat mode with streaming input/output."""
|
||||||
|
|
||||||
|
def __init__(self, options: ClaudeAgentOptions, ui: TerminalUI, storage):
|
||||||
|
self.options = options
|
||||||
|
self.ui = ui
|
||||||
|
self.storage = storage
|
||||||
|
self._exit_requested = False
|
||||||
|
self._current_streaming_text = ""
|
||||||
|
|
||||||
|
async def message_generator(self) -> AsyncGenerator[Dict[str, Any], None]:
|
||||||
|
"""
|
||||||
|
Generate user messages as async generator (streaming input mode per cc_stream.md).
|
||||||
|
|
||||||
|
Yields messages in the format:
|
||||||
|
{
|
||||||
|
"type": "user",
|
||||||
|
"message": {
|
||||||
|
"role": "user",
|
||||||
|
"content": "user input text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
while not self._exit_requested:
|
||||||
|
try:
|
||||||
|
# Get user input
|
||||||
|
user_input = await asyncio.to_thread(self.ui.get_user_input)
|
||||||
|
|
||||||
|
# Handle commands
|
||||||
|
if user_input.startswith('/'):
|
||||||
|
await self._handle_command(user_input)
|
||||||
|
if self._exit_requested:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip empty input
|
||||||
|
if not user_input.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Log user message
|
||||||
|
self.storage.log("user_message", {"text": user_input})
|
||||||
|
|
||||||
|
# Yield user message for agent
|
||||||
|
yield {
|
||||||
|
"type": "user",
|
||||||
|
"message": {
|
||||||
|
"role": "user",
|
||||||
|
"content": user_input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
self._exit_requested = True
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
self.ui.print_error(f"Input error: {e}")
|
||||||
|
|
||||||
|
async def _handle_command(self, command: str):
|
||||||
|
"""Handle special chat commands."""
|
||||||
|
cmd = command.lower().strip()
|
||||||
|
|
||||||
|
if cmd == '/exit' or cmd == '/quit':
|
||||||
|
self._exit_requested = True
|
||||||
|
self.ui.print_info("Exiting chat mode...")
|
||||||
|
|
||||||
|
elif cmd == '/clear':
|
||||||
|
self.ui.clear_screen()
|
||||||
|
|
||||||
|
elif cmd == '/help':
|
||||||
|
self.ui.show_commands()
|
||||||
|
|
||||||
|
elif cmd == '/browser':
|
||||||
|
# Show browser status
|
||||||
|
if BrowserManager.is_browser_active():
|
||||||
|
config = BrowserManager.get_current_config()
|
||||||
|
self.ui.print_info(f"Browser active: {config}")
|
||||||
|
else:
|
||||||
|
self.ui.print_info("No browser instance active")
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.ui.print_error(f"Unknown command: {command}")
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
"""Run the interactive chat loop with streaming responses."""
|
||||||
|
# Show header
|
||||||
|
self.ui.show_header(
|
||||||
|
session_id=str(self.options.session_id or "chat"),
|
||||||
|
log_path=self.storage.get_session_path() if hasattr(self.storage, 'get_session_path') else "N/A"
|
||||||
|
)
|
||||||
|
self.ui.show_commands()
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with ClaudeSDKClient(options=self.options) as client:
|
||||||
|
# Start streaming input mode
|
||||||
|
await client.query(self.message_generator())
|
||||||
|
|
||||||
|
# Process streaming responses
|
||||||
|
turn = 0
|
||||||
|
async for message in client.receive_messages():
|
||||||
|
turn += 1
|
||||||
|
|
||||||
|
if isinstance(message, AssistantMessage):
|
||||||
|
# Clear "thinking" line if we printed it
|
||||||
|
if self._current_streaming_text:
|
||||||
|
self.ui.console.print() # New line after streaming
|
||||||
|
|
||||||
|
self._current_streaming_text = ""
|
||||||
|
|
||||||
|
# Process message content blocks
|
||||||
|
for block in message.content:
|
||||||
|
if isinstance(block, TextBlock):
|
||||||
|
# Stream text as it arrives
|
||||||
|
self.ui.print_agent_text(block.text)
|
||||||
|
self._current_streaming_text += block.text
|
||||||
|
|
||||||
|
# Log assistant message
|
||||||
|
self.storage.log("assistant_message", {
|
||||||
|
"turn": turn,
|
||||||
|
"text": block.text
|
||||||
|
})
|
||||||
|
|
||||||
|
elif isinstance(block, ToolUseBlock):
|
||||||
|
# Show tool usage
|
||||||
|
self.ui.print_tool_use(block.name)
|
||||||
|
|
||||||
|
elif isinstance(message, ResultMessage):
|
||||||
|
# Session completed (user exited or error)
|
||||||
|
if message.is_error:
|
||||||
|
self.ui.print_error(f"Agent error: {message.result}")
|
||||||
|
else:
|
||||||
|
self.ui.print_session_summary(
|
||||||
|
duration_s=message.duration_ms / 1000 if message.duration_ms else 0,
|
||||||
|
turns=message.num_turns,
|
||||||
|
cost_usd=message.total_cost_usd
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log session end
|
||||||
|
self.storage.log("session_end", {
|
||||||
|
"duration_ms": message.duration_ms,
|
||||||
|
"cost_usd": message.total_cost_usd,
|
||||||
|
"turns": message.num_turns,
|
||||||
|
"success": not message.is_error
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
self.ui.print_info("\nChat interrupted by user")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.ui.print_error(f"Chat error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Cleanup browser on exit
|
||||||
|
await BrowserManager.close_browser()
|
||||||
|
self.ui.print_info("Browser closed")
|
||||||
115
crawl4ai/agent/terminal_ui.py
Normal file
115
crawl4ai/agent/terminal_ui.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""Terminal UI components using Rich for beautiful agent output."""
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.markdown import Markdown
|
||||||
|
from rich.syntax import Syntax
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.live import Live
|
||||||
|
from rich.spinner import Spinner
|
||||||
|
from rich.text import Text
|
||||||
|
from rich.prompt import Prompt
|
||||||
|
from rich.rule import Rule
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalUI:
|
||||||
|
"""Rich-based terminal interface for the Crawl4AI agent."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.console = Console()
|
||||||
|
self._current_text = ""
|
||||||
|
|
||||||
|
def show_header(self, session_id: str, log_path: str):
|
||||||
|
"""Display agent session header."""
|
||||||
|
self.console.print()
|
||||||
|
self.console.print(Panel.fit(
|
||||||
|
"[bold cyan]🕷️ Crawl4AI Agent - Chat Mode[/bold cyan]",
|
||||||
|
border_style="cyan"
|
||||||
|
))
|
||||||
|
self.console.print(f"[dim]📁 Session: {session_id}[/dim]")
|
||||||
|
self.console.print(f"[dim]💾 Log: {log_path}[/dim]")
|
||||||
|
self.console.print()
|
||||||
|
|
||||||
|
def show_commands(self):
|
||||||
|
"""Display available commands."""
|
||||||
|
self.console.print("\n[dim]Commands:[/dim]")
|
||||||
|
self.console.print(" [cyan]/exit[/cyan] - Exit chat")
|
||||||
|
self.console.print(" [cyan]/clear[/cyan] - Clear screen")
|
||||||
|
self.console.print(" [cyan]/help[/cyan] - Show this help\n")
|
||||||
|
|
||||||
|
def get_user_input(self) -> str:
|
||||||
|
"""Get user input with styled prompt."""
|
||||||
|
return Prompt.ask("\n[bold green]You[/bold green]")
|
||||||
|
|
||||||
|
def print_separator(self):
|
||||||
|
"""Print a visual separator."""
|
||||||
|
self.console.print(Rule(style="dim"))
|
||||||
|
|
||||||
|
def print_thinking(self):
|
||||||
|
"""Show thinking indicator."""
|
||||||
|
self.console.print("\n[cyan]Agent:[/cyan] [dim]thinking...[/dim]", end="")
|
||||||
|
|
||||||
|
def print_agent_text(self, text: str, stream: bool = False):
|
||||||
|
"""
|
||||||
|
Print agent response text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to print
|
||||||
|
stream: If True, append to current streaming output
|
||||||
|
"""
|
||||||
|
if stream:
|
||||||
|
# For streaming, just print without newline
|
||||||
|
self.console.print(f"\r[cyan]Agent:[/cyan] {text}", end="")
|
||||||
|
else:
|
||||||
|
# For complete messages
|
||||||
|
self.console.print(f"\n[cyan]Agent:[/cyan] {text}")
|
||||||
|
|
||||||
|
def print_markdown(self, markdown_text: str):
|
||||||
|
"""Render markdown content."""
|
||||||
|
self.console.print()
|
||||||
|
self.console.print(Markdown(markdown_text))
|
||||||
|
|
||||||
|
def print_code(self, code: str, language: str = "python"):
|
||||||
|
"""Render code with syntax highlighting."""
|
||||||
|
self.console.print()
|
||||||
|
self.console.print(Syntax(code, language, theme="monokai", line_numbers=True))
|
||||||
|
|
||||||
|
def print_error(self, error_msg: str):
|
||||||
|
"""Display error message."""
|
||||||
|
self.console.print(f"\n[bold red]Error:[/bold red] {error_msg}")
|
||||||
|
|
||||||
|
def print_success(self, msg: str):
|
||||||
|
"""Display success message."""
|
||||||
|
self.console.print(f"\n[bold green]✓[/bold green] {msg}")
|
||||||
|
|
||||||
|
def print_info(self, msg: str):
|
||||||
|
"""Display info message."""
|
||||||
|
self.console.print(f"\n[bold blue]ℹ[/bold blue] {msg}")
|
||||||
|
|
||||||
|
def clear_screen(self):
|
||||||
|
"""Clear the terminal screen."""
|
||||||
|
self.console.clear()
|
||||||
|
|
||||||
|
def print_session_summary(self, duration_s: float, turns: int, cost_usd: float = None):
|
||||||
|
"""Display session completion summary."""
|
||||||
|
self.console.print()
|
||||||
|
self.console.print(Panel(
|
||||||
|
f"[green]✅ Completed[/green]\n"
|
||||||
|
f"⏱ Duration: {duration_s:.2f}s\n"
|
||||||
|
f"🔄 Turns: {turns}\n"
|
||||||
|
+ (f"💰 Cost: ${cost_usd:.4f}" if cost_usd else ""),
|
||||||
|
border_style="green"
|
||||||
|
))
|
||||||
|
|
||||||
|
def print_tool_use(self, tool_name: str):
|
||||||
|
"""Indicate tool usage."""
|
||||||
|
self.console.print(f"\n[dim]🔧 Using tool: {tool_name}[/dim]")
|
||||||
|
|
||||||
|
def with_spinner(self, text: str = "Processing..."):
|
||||||
|
"""
|
||||||
|
Context manager for showing a spinner.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
with ui.with_spinner("Crawling page..."):
|
||||||
|
# do work
|
||||||
|
"""
|
||||||
|
return self.console.status(f"[cyan]{text}[/cyan]", spinner="dots")
|
||||||
114
crawl4ai/agent/test_chat.py
Normal file
114
crawl4ai/agent/test_chat.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Test script to verify chat mode setup (non-interactive)."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add parent to path for imports
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||||
|
|
||||||
|
from crawl4ai.agent.browser_manager import BrowserManager
|
||||||
|
from crawl4ai.agent.terminal_ui import TerminalUI
|
||||||
|
from crawl4ai.agent.chat_mode import ChatMode
|
||||||
|
from crawl4ai.agent.c4ai_tools import CRAWL_TOOLS
|
||||||
|
from crawl4ai.agent.c4ai_prompts import SYSTEM_PROMPT
|
||||||
|
|
||||||
|
from claude_agent_sdk import ClaudeAgentOptions, create_sdk_mcp_server
|
||||||
|
|
||||||
|
|
||||||
|
class MockStorage:
|
||||||
|
"""Mock storage for testing."""
|
||||||
|
|
||||||
|
def log(self, event_type: str, data: dict):
|
||||||
|
print(f"[LOG] {event_type}: {data}")
|
||||||
|
|
||||||
|
def get_session_path(self):
|
||||||
|
return "/tmp/test_session.jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_components():
|
||||||
|
"""Test individual components."""
|
||||||
|
|
||||||
|
print("="*60)
|
||||||
|
print("CHAT MODE COMPONENT TESTS")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# Test 1: BrowserManager
|
||||||
|
print("\n[TEST 1] BrowserManager singleton")
|
||||||
|
try:
|
||||||
|
browser1 = await BrowserManager.get_browser()
|
||||||
|
browser2 = await BrowserManager.get_browser()
|
||||||
|
assert browser1 is browser2, "Browser instances should be same (singleton)"
|
||||||
|
print("✓ BrowserManager singleton works")
|
||||||
|
await BrowserManager.close_browser()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ BrowserManager failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Test 2: TerminalUI
|
||||||
|
print("\n[TEST 2] TerminalUI rendering")
|
||||||
|
try:
|
||||||
|
ui = TerminalUI()
|
||||||
|
ui.show_header("test-123", "/tmp/test.log")
|
||||||
|
ui.print_agent_text("Hello from agent")
|
||||||
|
ui.print_markdown("# Test\nThis is **bold**")
|
||||||
|
ui.print_success("Test success message")
|
||||||
|
print("✓ TerminalUI renders correctly")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ TerminalUI failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Test 3: MCP Server Setup
|
||||||
|
print("\n[TEST 3] MCP Server with tools")
|
||||||
|
try:
|
||||||
|
crawler_server = create_sdk_mcp_server(
|
||||||
|
name="crawl4ai",
|
||||||
|
version="1.0.0",
|
||||||
|
tools=CRAWL_TOOLS
|
||||||
|
)
|
||||||
|
print(f"✓ MCP server created with {len(CRAWL_TOOLS)} tools")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ MCP Server failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Test 4: ChatMode instantiation
|
||||||
|
print("\n[TEST 4] ChatMode instantiation")
|
||||||
|
try:
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
mcp_servers={"crawler": crawler_server},
|
||||||
|
allowed_tools=[
|
||||||
|
"mcp__crawler__quick_crawl",
|
||||||
|
"mcp__crawler__start_session",
|
||||||
|
"mcp__crawler__navigate",
|
||||||
|
"mcp__crawler__extract_data",
|
||||||
|
"mcp__crawler__execute_js",
|
||||||
|
"mcp__crawler__screenshot",
|
||||||
|
"mcp__crawler__close_session",
|
||||||
|
],
|
||||||
|
system_prompt=SYSTEM_PROMPT,
|
||||||
|
permission_mode="acceptEdits"
|
||||||
|
)
|
||||||
|
|
||||||
|
ui = TerminalUI()
|
||||||
|
storage = MockStorage()
|
||||||
|
chat = ChatMode(options, ui, storage)
|
||||||
|
print("✓ ChatMode instance created successfully")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ ChatMode failed: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("ALL COMPONENT TESTS PASSED ✓")
|
||||||
|
print("="*60)
|
||||||
|
print("\nTo test interactive chat mode, run:")
|
||||||
|
print(" python -m crawl4ai.agent.agent_crawl --chat")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = asyncio.run(test_components())
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
524
crawl4ai/agent/test_scenarios.py
Normal file
524
crawl4ai/agent/test_scenarios.py
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""
|
||||||
|
Automated multi-turn chat scenario tests for Crawl4AI Agent.
|
||||||
|
|
||||||
|
Tests agent's ability to handle complex conversations, maintain state,
|
||||||
|
plan and execute tasks without human interaction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions, create_sdk_mcp_server
|
||||||
|
from claude_agent_sdk import AssistantMessage, TextBlock, ResultMessage, ToolUseBlock
|
||||||
|
|
||||||
|
from .c4ai_tools import CRAWL_TOOLS
|
||||||
|
from .c4ai_prompts import SYSTEM_PROMPT
|
||||||
|
from .browser_manager import BrowserManager
|
||||||
|
|
||||||
|
|
||||||
|
class TurnResult(Enum):
|
||||||
|
"""Result of a single conversation turn."""
|
||||||
|
PASS = "PASS"
|
||||||
|
FAIL = "FAIL"
|
||||||
|
TIMEOUT = "TIMEOUT"
|
||||||
|
ERROR = "ERROR"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TurnExpectation:
|
||||||
|
"""Expectations for a single conversation turn."""
|
||||||
|
user_message: str
|
||||||
|
expect_tools: Optional[List[str]] = None # Tools that should be called
|
||||||
|
expect_keywords: Optional[List[str]] = None # Keywords in response
|
||||||
|
expect_files_created: Optional[List[str]] = None # File patterns created
|
||||||
|
expect_success: bool = True # Should complete without error
|
||||||
|
expect_min_turns: int = 1 # Minimum agent turns to complete
|
||||||
|
timeout_seconds: int = 60
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Scenario:
|
||||||
|
"""A complete multi-turn conversation scenario."""
|
||||||
|
name: str
|
||||||
|
category: str # "simple", "medium", "complex"
|
||||||
|
description: str
|
||||||
|
turns: List[TurnExpectation]
|
||||||
|
cleanup_files: Optional[List[str]] = None # Files to cleanup after test
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# TEST SCENARIOS - Categorized from Simple to Complex
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
SIMPLE_SCENARIOS = [
|
||||||
|
Scenario(
|
||||||
|
name="Single quick crawl",
|
||||||
|
category="simple",
|
||||||
|
description="Basic one-shot crawl with markdown extraction",
|
||||||
|
turns=[
|
||||||
|
TurnExpectation(
|
||||||
|
user_message="Use quick_crawl to get the title from example.com",
|
||||||
|
expect_tools=["mcp__crawler__quick_crawl"],
|
||||||
|
expect_keywords=["Example Domain", "title"],
|
||||||
|
timeout_seconds=30
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
|
||||||
|
Scenario(
|
||||||
|
name="Session lifecycle",
|
||||||
|
category="simple",
|
||||||
|
description="Start session, navigate, close - basic session management",
|
||||||
|
turns=[
|
||||||
|
TurnExpectation(
|
||||||
|
user_message="Start a session named 'simple_test'",
|
||||||
|
expect_tools=["mcp__crawler__start_session"],
|
||||||
|
expect_keywords=["session", "started"],
|
||||||
|
timeout_seconds=20
|
||||||
|
),
|
||||||
|
TurnExpectation(
|
||||||
|
user_message="Navigate to example.com",
|
||||||
|
expect_tools=["mcp__crawler__navigate"],
|
||||||
|
expect_keywords=["navigated", "example.com"],
|
||||||
|
timeout_seconds=25
|
||||||
|
),
|
||||||
|
TurnExpectation(
|
||||||
|
user_message="Close the session",
|
||||||
|
expect_tools=["mcp__crawler__close_session"],
|
||||||
|
expect_keywords=["closed"],
|
||||||
|
timeout_seconds=15
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
MEDIUM_SCENARIOS = [
|
||||||
|
Scenario(
|
||||||
|
name="Multi-page crawl with file output",
|
||||||
|
category="medium",
|
||||||
|
description="Crawl multiple pages and save results to file",
|
||||||
|
turns=[
|
||||||
|
TurnExpectation(
|
||||||
|
user_message="Crawl example.com and example.org, extract titles from both",
|
||||||
|
expect_tools=["mcp__crawler__quick_crawl"],
|
||||||
|
expect_min_turns=2, # Should make 2 separate crawls
|
||||||
|
timeout_seconds=45
|
||||||
|
),
|
||||||
|
TurnExpectation(
|
||||||
|
user_message="Save the results to a JSON file called crawl_results.json",
|
||||||
|
expect_tools=["Write"],
|
||||||
|
expect_files_created=["crawl_results.json"],
|
||||||
|
timeout_seconds=20
|
||||||
|
)
|
||||||
|
],
|
||||||
|
cleanup_files=["crawl_results.json"]
|
||||||
|
),
|
||||||
|
|
||||||
|
Scenario(
|
||||||
|
name="Session-based data extraction",
|
||||||
|
category="medium",
|
||||||
|
description="Use session to navigate and extract data in steps",
|
||||||
|
turns=[
|
||||||
|
TurnExpectation(
|
||||||
|
user_message="Start session 'extract_test', navigate to example.com, and extract the markdown",
|
||||||
|
expect_tools=["mcp__crawler__start_session", "mcp__crawler__navigate", "mcp__crawler__extract_data"],
|
||||||
|
expect_keywords=["Example Domain"],
|
||||||
|
timeout_seconds=50
|
||||||
|
),
|
||||||
|
TurnExpectation(
|
||||||
|
user_message="Now save that markdown to example_content.md",
|
||||||
|
expect_tools=["Write"],
|
||||||
|
expect_files_created=["example_content.md"],
|
||||||
|
timeout_seconds=20
|
||||||
|
),
|
||||||
|
TurnExpectation(
|
||||||
|
user_message="Close the session",
|
||||||
|
expect_tools=["mcp__crawler__close_session"],
|
||||||
|
timeout_seconds=15
|
||||||
|
)
|
||||||
|
],
|
||||||
|
cleanup_files=["example_content.md"]
|
||||||
|
),
|
||||||
|
|
||||||
|
Scenario(
|
||||||
|
name="Context retention across turns",
|
||||||
|
category="medium",
|
||||||
|
description="Agent should remember previous context",
|
||||||
|
turns=[
|
||||||
|
TurnExpectation(
|
||||||
|
user_message="Crawl example.com and tell me the title",
|
||||||
|
expect_tools=["mcp__crawler__quick_crawl"],
|
||||||
|
expect_keywords=["Example Domain"],
|
||||||
|
timeout_seconds=30
|
||||||
|
),
|
||||||
|
TurnExpectation(
|
||||||
|
user_message="What was the URL I just asked you to crawl?",
|
||||||
|
expect_keywords=["example.com"],
|
||||||
|
expect_tools=[], # Should answer from memory, no tools needed
|
||||||
|
timeout_seconds=15
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
COMPLEX_SCENARIOS = [
|
||||||
|
Scenario(
|
||||||
|
name="Multi-step task with planning",
|
||||||
|
category="complex",
|
||||||
|
description="Complex task requiring agent to plan, execute, and verify",
|
||||||
|
turns=[
|
||||||
|
TurnExpectation(
|
||||||
|
user_message="Crawl example.com and example.org, compare their content, and create a markdown report with: 1) titles of both, 2) word count comparison, 3) save to comparison_report.md",
|
||||||
|
expect_tools=["mcp__crawler__quick_crawl", "Write"],
|
||||||
|
expect_files_created=["comparison_report.md"],
|
||||||
|
expect_min_turns=3, # Plan, crawl both, write report
|
||||||
|
timeout_seconds=90
|
||||||
|
),
|
||||||
|
TurnExpectation(
|
||||||
|
user_message="Read back the report you just created",
|
||||||
|
expect_tools=["Read"],
|
||||||
|
expect_keywords=["Example Domain"],
|
||||||
|
timeout_seconds=20
|
||||||
|
)
|
||||||
|
],
|
||||||
|
cleanup_files=["comparison_report.md"]
|
||||||
|
),
|
||||||
|
|
||||||
|
Scenario(
|
||||||
|
name="Session with state manipulation",
|
||||||
|
category="complex",
|
||||||
|
description="Complex session workflow with multiple operations",
|
||||||
|
turns=[
|
||||||
|
TurnExpectation(
|
||||||
|
user_message="Start session 'complex_session' and navigate to example.com",
|
||||||
|
expect_tools=["mcp__crawler__start_session", "mcp__crawler__navigate"],
|
||||||
|
timeout_seconds=30
|
||||||
|
),
|
||||||
|
TurnExpectation(
|
||||||
|
user_message="Extract the page content and count how many times the word 'example' appears (case insensitive)",
|
||||||
|
expect_tools=["mcp__crawler__extract_data"],
|
||||||
|
expect_keywords=["example"],
|
||||||
|
timeout_seconds=30
|
||||||
|
),
|
||||||
|
TurnExpectation(
|
||||||
|
user_message="Take a screenshot of the current page",
|
||||||
|
expect_tools=["mcp__crawler__screenshot"],
|
||||||
|
expect_keywords=["screenshot"],
|
||||||
|
timeout_seconds=25
|
||||||
|
),
|
||||||
|
TurnExpectation(
|
||||||
|
user_message="Close the session",
|
||||||
|
expect_tools=["mcp__crawler__close_session"],
|
||||||
|
timeout_seconds=15
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
|
||||||
|
Scenario(
|
||||||
|
name="Error recovery and continuation",
|
||||||
|
category="complex",
|
||||||
|
description="Agent should handle errors gracefully and continue",
|
||||||
|
turns=[
|
||||||
|
TurnExpectation(
|
||||||
|
user_message="Crawl https://this-site-definitely-does-not-exist-12345.com",
|
||||||
|
expect_success=False, # Should fail gracefully
|
||||||
|
expect_keywords=["error", "fail"],
|
||||||
|
timeout_seconds=30
|
||||||
|
),
|
||||||
|
TurnExpectation(
|
||||||
|
user_message="That's okay, crawl example.com instead",
|
||||||
|
expect_tools=["mcp__crawler__quick_crawl"],
|
||||||
|
expect_keywords=["Example Domain"],
|
||||||
|
timeout_seconds=30
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Combine all scenarios
|
||||||
|
ALL_SCENARIOS = SIMPLE_SCENARIOS + MEDIUM_SCENARIOS + COMPLEX_SCENARIOS
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# TEST RUNNER
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class ScenarioRunner:
|
||||||
|
"""Runs automated chat scenarios without human interaction."""
|
||||||
|
|
||||||
|
def __init__(self, working_dir: Path):
|
||||||
|
self.working_dir = working_dir
|
||||||
|
self.results = []
|
||||||
|
|
||||||
|
async def run_scenario(self, scenario: Scenario) -> Dict[str, Any]:
|
||||||
|
"""Run a single scenario and return results."""
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print(f"[{scenario.category.upper()}] {scenario.name}")
|
||||||
|
print(f"{'='*70}")
|
||||||
|
print(f"Description: {scenario.description}\n")
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
turn_results = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Setup agent options
|
||||||
|
crawler_server = create_sdk_mcp_server(
|
||||||
|
name="crawl4ai",
|
||||||
|
version="1.0.0",
|
||||||
|
tools=CRAWL_TOOLS
|
||||||
|
)
|
||||||
|
|
||||||
|
options = ClaudeAgentOptions(
|
||||||
|
mcp_servers={"crawler": crawler_server},
|
||||||
|
allowed_tools=[
|
||||||
|
"mcp__crawler__quick_crawl",
|
||||||
|
"mcp__crawler__start_session",
|
||||||
|
"mcp__crawler__navigate",
|
||||||
|
"mcp__crawler__extract_data",
|
||||||
|
"mcp__crawler__execute_js",
|
||||||
|
"mcp__crawler__screenshot",
|
||||||
|
"mcp__crawler__close_session",
|
||||||
|
"Read", "Write", "Edit", "Glob", "Grep", "Bash"
|
||||||
|
],
|
||||||
|
system_prompt=SYSTEM_PROMPT,
|
||||||
|
permission_mode="acceptEdits",
|
||||||
|
cwd=str(self.working_dir)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run conversation
|
||||||
|
async with ClaudeSDKClient(options=options) as client:
|
||||||
|
for turn_idx, expectation in enumerate(scenario.turns, 1):
|
||||||
|
print(f"\nTurn {turn_idx}: {expectation.user_message}")
|
||||||
|
|
||||||
|
turn_result = await self._run_turn(
|
||||||
|
client, expectation, turn_idx
|
||||||
|
)
|
||||||
|
turn_results.append(turn_result)
|
||||||
|
|
||||||
|
if turn_result["status"] != TurnResult.PASS:
|
||||||
|
print(f" ✗ FAILED: {turn_result['reason']}")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print(f" ✓ PASSED")
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
if scenario.cleanup_files:
|
||||||
|
self._cleanup_files(scenario.cleanup_files)
|
||||||
|
|
||||||
|
# Overall result
|
||||||
|
all_passed = all(r["status"] == TurnResult.PASS for r in turn_results)
|
||||||
|
duration = time.time() - start_time
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"scenario": scenario.name,
|
||||||
|
"category": scenario.category,
|
||||||
|
"status": "PASS" if all_passed else "FAIL",
|
||||||
|
"duration_seconds": duration,
|
||||||
|
"turns": turn_results
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n✗ SCENARIO ERROR: {e}")
|
||||||
|
return {
|
||||||
|
"scenario": scenario.name,
|
||||||
|
"category": scenario.category,
|
||||||
|
"status": "ERROR",
|
||||||
|
"error": str(e),
|
||||||
|
"duration_seconds": time.time() - start_time,
|
||||||
|
"turns": turn_results
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
# Ensure browser cleanup
|
||||||
|
await BrowserManager.close_browser()
|
||||||
|
|
||||||
|
async def _run_turn(
|
||||||
|
self,
|
||||||
|
client: ClaudeSDKClient,
|
||||||
|
expectation: TurnExpectation,
|
||||||
|
turn_number: int
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Execute a single conversation turn and validate."""
|
||||||
|
|
||||||
|
tools_used = []
|
||||||
|
response_text = ""
|
||||||
|
agent_turns = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Send user message
|
||||||
|
await client.query(expectation.user_message)
|
||||||
|
|
||||||
|
# Collect response
|
||||||
|
start_time = time.time()
|
||||||
|
async for message in client.receive_messages():
|
||||||
|
if time.time() - start_time > expectation.timeout_seconds:
|
||||||
|
return {
|
||||||
|
"turn": turn_number,
|
||||||
|
"status": TurnResult.TIMEOUT,
|
||||||
|
"reason": f"Exceeded {expectation.timeout_seconds}s timeout"
|
||||||
|
}
|
||||||
|
|
||||||
|
if isinstance(message, AssistantMessage):
|
||||||
|
agent_turns += 1
|
||||||
|
for block in message.content:
|
||||||
|
if isinstance(block, TextBlock):
|
||||||
|
response_text += block.text + " "
|
||||||
|
elif isinstance(block, ToolUseBlock):
|
||||||
|
tools_used.append(block.name)
|
||||||
|
|
||||||
|
elif isinstance(message, ResultMessage):
|
||||||
|
# Check if error when expecting success
|
||||||
|
if expectation.expect_success and message.is_error:
|
||||||
|
return {
|
||||||
|
"turn": turn_number,
|
||||||
|
"status": TurnResult.FAIL,
|
||||||
|
"reason": f"Agent returned error: {message.result}"
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
# Validate expectations
|
||||||
|
validation = self._validate_turn(
|
||||||
|
expectation, tools_used, response_text, agent_turns
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"turn": turn_number,
|
||||||
|
"status": validation["status"],
|
||||||
|
"reason": validation.get("reason", "All checks passed"),
|
||||||
|
"tools_used": tools_used,
|
||||||
|
"agent_turns": agent_turns
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"turn": turn_number,
|
||||||
|
"status": TurnResult.ERROR,
|
||||||
|
"reason": f"Exception: {str(e)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
def _validate_turn(
|
||||||
|
self,
|
||||||
|
expectation: TurnExpectation,
|
||||||
|
tools_used: List[str],
|
||||||
|
response_text: str,
|
||||||
|
agent_turns: int
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Validate turn results against expectations."""
|
||||||
|
|
||||||
|
# Check expected tools
|
||||||
|
if expectation.expect_tools:
|
||||||
|
for tool in expectation.expect_tools:
|
||||||
|
if tool not in tools_used:
|
||||||
|
return {
|
||||||
|
"status": TurnResult.FAIL,
|
||||||
|
"reason": f"Expected tool '{tool}' was not used"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check keywords
|
||||||
|
if expectation.expect_keywords:
|
||||||
|
response_lower = response_text.lower()
|
||||||
|
for keyword in expectation.expect_keywords:
|
||||||
|
if keyword.lower() not in response_lower:
|
||||||
|
return {
|
||||||
|
"status": TurnResult.FAIL,
|
||||||
|
"reason": f"Expected keyword '{keyword}' not found in response"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check files created
|
||||||
|
if expectation.expect_files_created:
|
||||||
|
for pattern in expectation.expect_files_created:
|
||||||
|
matches = list(self.working_dir.glob(pattern))
|
||||||
|
if not matches:
|
||||||
|
return {
|
||||||
|
"status": TurnResult.FAIL,
|
||||||
|
"reason": f"Expected file matching '{pattern}' was not created"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check minimum turns
|
||||||
|
if agent_turns < expectation.expect_min_turns:
|
||||||
|
return {
|
||||||
|
"status": TurnResult.FAIL,
|
||||||
|
"reason": f"Expected at least {expectation.expect_min_turns} agent turns, got {agent_turns}"
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"status": TurnResult.PASS}
|
||||||
|
|
||||||
|
def _cleanup_files(self, patterns: List[str]):
|
||||||
|
"""Remove files created during test."""
|
||||||
|
for pattern in patterns:
|
||||||
|
for file_path in self.working_dir.glob(pattern):
|
||||||
|
try:
|
||||||
|
file_path.unlink()
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Warning: Could not delete {file_path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def run_all_scenarios(working_dir: Optional[Path] = None):
|
||||||
|
"""Run all test scenarios and report results."""
|
||||||
|
|
||||||
|
if working_dir is None:
|
||||||
|
working_dir = Path.cwd() / "test_agent_output"
|
||||||
|
working_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
runner = ScenarioRunner(working_dir)
|
||||||
|
|
||||||
|
print("\n" + "="*70)
|
||||||
|
print("CRAWL4AI AGENT SCENARIO TESTS")
|
||||||
|
print("="*70)
|
||||||
|
print(f"Working directory: {working_dir}")
|
||||||
|
print(f"Total scenarios: {len(ALL_SCENARIOS)}")
|
||||||
|
print(f" Simple: {len(SIMPLE_SCENARIOS)}")
|
||||||
|
print(f" Medium: {len(MEDIUM_SCENARIOS)}")
|
||||||
|
print(f" Complex: {len(COMPLEX_SCENARIOS)}")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for scenario in ALL_SCENARIOS:
|
||||||
|
result = await runner.run_scenario(scenario)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print("\n" + "="*70)
|
||||||
|
print("TEST SUMMARY")
|
||||||
|
print("="*70)
|
||||||
|
|
||||||
|
by_category = {"simple": [], "medium": [], "complex": []}
|
||||||
|
for result in results:
|
||||||
|
by_category[result["category"]].append(result)
|
||||||
|
|
||||||
|
for category in ["simple", "medium", "complex"]:
|
||||||
|
cat_results = by_category[category]
|
||||||
|
passed = sum(1 for r in cat_results if r["status"] == "PASS")
|
||||||
|
total = len(cat_results)
|
||||||
|
print(f"\n{category.upper()}: {passed}/{total} passed")
|
||||||
|
for r in cat_results:
|
||||||
|
status_icon = "✓" if r["status"] == "PASS" else "✗"
|
||||||
|
print(f" {status_icon} {r['scenario']} ({r['duration_seconds']:.1f}s)")
|
||||||
|
|
||||||
|
total_passed = sum(1 for r in results if r["status"] == "PASS")
|
||||||
|
total = len(results)
|
||||||
|
|
||||||
|
print(f"\nOVERALL: {total_passed}/{total} scenarios passed ({total_passed/total*100:.1f}%)")
|
||||||
|
|
||||||
|
# Save detailed results
|
||||||
|
results_file = working_dir / "test_results.json"
|
||||||
|
with open(results_file, "w") as f:
|
||||||
|
json.dump(results, f, indent=2)
|
||||||
|
print(f"\nDetailed results saved to: {results_file}")
|
||||||
|
|
||||||
|
return total_passed == total
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
success = asyncio.run(run_all_scenarios())
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
140
crawl4ai/agent/test_tools.py
Normal file
140
crawl4ai/agent/test_tools.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Test script for Crawl4AI tools - tests tools directly without the agent."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode
|
||||||
|
|
||||||
|
async def test_quick_crawl():
|
||||||
|
"""Test quick_crawl tool logic directly."""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("TEST 1: Quick Crawl - Markdown Format")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
crawler_config = BrowserConfig(headless=True, verbose=False)
|
||||||
|
run_config = CrawlerRunConfig(cache_mode=CacheMode.BYPASS)
|
||||||
|
|
||||||
|
async with AsyncWebCrawler(config=crawler_config) as crawler:
|
||||||
|
result = await crawler.arun(url="https://example.com", config=run_config)
|
||||||
|
|
||||||
|
print(f"Success: {result.success}")
|
||||||
|
print(f"URL: {result.url}")
|
||||||
|
|
||||||
|
# Handle markdown - can be string or MarkdownGenerationResult object
|
||||||
|
if isinstance(result.markdown, str):
|
||||||
|
markdown_content = result.markdown
|
||||||
|
elif hasattr(result.markdown, 'raw_markdown'):
|
||||||
|
markdown_content = result.markdown.raw_markdown
|
||||||
|
else:
|
||||||
|
markdown_content = str(result.markdown)
|
||||||
|
|
||||||
|
print(f"Markdown type: {type(result.markdown)}")
|
||||||
|
print(f"Markdown length: {len(markdown_content)}")
|
||||||
|
print(f"Markdown preview:\n{markdown_content[:300]}")
|
||||||
|
|
||||||
|
return result.success
|
||||||
|
|
||||||
|
|
||||||
|
async def test_session_workflow():
|
||||||
|
"""Test session-based workflow."""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("TEST 2: Session-Based Workflow")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
crawler_config = BrowserConfig(headless=True, verbose=False)
|
||||||
|
|
||||||
|
# Start session
|
||||||
|
crawler = AsyncWebCrawler(config=crawler_config)
|
||||||
|
await crawler.__aenter__()
|
||||||
|
print("✓ Session started")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Navigate to URL
|
||||||
|
run_config = CrawlerRunConfig(cache_mode=CacheMode.BYPASS)
|
||||||
|
result = await crawler.arun(url="https://example.com", config=run_config)
|
||||||
|
print(f"✓ Navigated to {result.url}, success: {result.success}")
|
||||||
|
|
||||||
|
# Extract data
|
||||||
|
if isinstance(result.markdown, str):
|
||||||
|
markdown_content = result.markdown
|
||||||
|
elif hasattr(result.markdown, 'raw_markdown'):
|
||||||
|
markdown_content = result.markdown.raw_markdown
|
||||||
|
else:
|
||||||
|
markdown_content = str(result.markdown)
|
||||||
|
|
||||||
|
print(f"✓ Extracted {len(markdown_content)} chars of markdown")
|
||||||
|
print(f" Preview: {markdown_content[:200]}")
|
||||||
|
|
||||||
|
# Screenshot test - need to re-fetch with screenshot enabled
|
||||||
|
screenshot_config = CrawlerRunConfig(cache_mode=CacheMode.BYPASS, screenshot=True)
|
||||||
|
result2 = await crawler.arun(url=result.url, config=screenshot_config)
|
||||||
|
print(f"✓ Screenshot captured: {result2.screenshot is not None}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Close session
|
||||||
|
await crawler.__aexit__(None, None, None)
|
||||||
|
print("✓ Session closed")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_html_format():
|
||||||
|
"""Test HTML output format."""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("TEST 3: Quick Crawl - HTML Format")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
crawler_config = BrowserConfig(headless=True, verbose=False)
|
||||||
|
run_config = CrawlerRunConfig(cache_mode=CacheMode.BYPASS)
|
||||||
|
|
||||||
|
async with AsyncWebCrawler(config=crawler_config) as crawler:
|
||||||
|
result = await crawler.arun(url="https://example.com", config=run_config)
|
||||||
|
|
||||||
|
print(f"Success: {result.success}")
|
||||||
|
print(f"HTML length: {len(result.html)}")
|
||||||
|
print(f"HTML preview:\n{result.html[:300]}")
|
||||||
|
|
||||||
|
return result.success
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Run all tests."""
|
||||||
|
print("\n" + "="*70)
|
||||||
|
print(" CRAWL4AI TOOLS TEST SUITE")
|
||||||
|
print("="*70)
|
||||||
|
|
||||||
|
tests = [
|
||||||
|
("Quick Crawl (Markdown)", test_quick_crawl),
|
||||||
|
("Session Workflow", test_session_workflow),
|
||||||
|
("Quick Crawl (HTML)", test_html_format),
|
||||||
|
]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for name, test_func in tests:
|
||||||
|
try:
|
||||||
|
result = await test_func()
|
||||||
|
results.append((name, result, None))
|
||||||
|
except Exception as e:
|
||||||
|
results.append((name, False, str(e)))
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print("\n" + "="*70)
|
||||||
|
print(" TEST SUMMARY")
|
||||||
|
print("="*70)
|
||||||
|
|
||||||
|
for name, success, error in results:
|
||||||
|
status = "✓ PASS" if success else "✗ FAIL"
|
||||||
|
print(f"{status} - {name}")
|
||||||
|
if error:
|
||||||
|
print(f" Error: {error}")
|
||||||
|
|
||||||
|
total = len(results)
|
||||||
|
passed = sum(1 for _, success, _ in results if success)
|
||||||
|
print(f"\nTotal: {total} | Passed: {passed} | Failed: {total - passed}")
|
||||||
|
|
||||||
|
return all(success for _, success, _ in results)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = asyncio.run(main())
|
||||||
|
exit(0 if success else 1)
|
||||||
Reference in New Issue
Block a user