Spaces:
Sleeping
Sleeping
Download nps_agent.py from YADUUG/NPS-ChatAgentBot: direct link, hf CLI and curl.
- Browser
- Download file 20.5 kB
-
https://huggingface.co/spaces/YADUUG/NPS-ChatAgentBot/resolve/main/nps_agent.py
- Command line
-
hf download hf://spaces/YADUUG/NPS-ChatAgentBot/nps_agent.py
-
curl -L -o nps_agent.py https://huggingface.co/spaces/YADUUG/NPS-ChatAgentBot/resolve/main/nps_agent.py
20.5 kB
| import asyncio | |
| import os | |
| import json | |
| import logging | |
| import sys | |
| from typing import Any, Optional | |
| from dotenv import load_dotenv | |
| from pathlib import Path | |
| # Ensure we can import nanobot if installed in venv | |
| from playwright.async_api import async_playwright, BrowserContext, Page, Browser | |
| try: | |
| from nanobot.agent.loop import AgentLoop | |
| from nanobot.agent.tools.base import Tool | |
| from nanobot.bus.queue import MessageBus | |
| from nanobot.providers.litellm_provider import LiteLLMProvider | |
| from nanobot.config.schema import ExecToolConfig | |
| except ImportError: | |
| print("Error: 'nanobot' not found. Please install 'nanobot-ai'.") | |
| # We don't exit here so it can be imported safely, but it will likely fail on run | |
| pass | |
| # Load environment variables | |
| load_dotenv() | |
| # Configure logging | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") | |
| logger = logging.getLogger("nps_nanobot") | |
| class PlaywrightTool(Tool): | |
| """ | |
| A tool to control a web browser using Playwright. | |
| Supports navigation, clicking, typing, and extracting text. | |
| """ | |
| name = "browser" | |
| description = ( | |
| "Control a web browser to navigate, interact, and extract information. " | |
| "Use this tool to visit websites, click elements, fill forms, and read content." | |
| ) | |
| parameters = { | |
| "type": "object", | |
| "properties": { | |
| "action": { | |
| "type": "string", | |
| "enum": ["navigate", "click", "type", "press", "scroll", "get_text", "get_links", "get_dom", "screenshot"], | |
| "description": "The action to perform." | |
| }, | |
| "url": {"type": "string", "description": "URL to navigate to (required for 'navigate')"}, | |
| "selector": {"type": "string", "description": "CSS selector or text to interact with (required for 'click', 'type', 'get_text')"}, | |
| "text": {"type": "string", "description": "Text to type (required for 'type')"}, | |
| "key": {"type": "string", "description": "Key to press (required for 'press', e.g., 'Enter')"}, | |
| "direction": {"type": "string", "enum": ["up", "down"], "description": "Scroll direction (required for 'scroll')"}, | |
| "amount": {"type": "integer", "description": "Scroll amount in pixels (optional for 'scroll')"}, | |
| }, | |
| "required": ["action"] | |
| } | |
| def __init__(self): | |
| self._playwright = None | |
| self._browser = None | |
| self._context: Optional[BrowserContext] = None | |
| self._page: Optional[Page] = None | |
| self._initialized = False | |
| async def _ensure_initialized(self): | |
| if not self._initialized: | |
| self._playwright = await async_playwright().start() | |
| # Auto-detect HF Spaces or allow HEADLESS env var override | |
| is_spaces = "SPACE_ID" in os.environ | |
| default_headless = "true" if is_spaces else "false" | |
| is_headless = os.getenv("HEADLESS", default_headless).lower() == "true" | |
| # Launch with anti-detection args | |
| self._browser = await self._playwright.chromium.launch( | |
| headless=is_headless, | |
| args=[ | |
| "--no-sandbox", | |
| "--disable-blink-features=AutomationControlled", | |
| "--disable-infobars" | |
| ], | |
| ignore_default_args=["--enable-automation"] | |
| ) | |
| self._context = await self._browser.new_context( | |
| viewport={"width": 1280, "height": 720}, | |
| user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" | |
| ) | |
| self._page = await self._context.new_page() | |
| self._page.set_default_timeout(15000) # 15s timeout | |
| # Auto-handle native dialogs | |
| self._page.on("dialog", lambda dialog: dialog.accept()) | |
| self._initialized = True | |
| logger.info("Playwright browser initialized") | |
| async def _handle_popups(self): | |
| """Try to dismiss common cookie banners and popups.""" | |
| try: | |
| # Common patterns for dismissal | |
| patterns = [ | |
| "text=Accept", "text=Agree", "text=Allow", | |
| "text=Reject", "text=Close", "text=No thanks", | |
| "text=Not now", "text=OK", "text=I understand", | |
| "button[aria-label='Close']", ".close-button", | |
| "#dismiss-button", "div[aria-label='Close ad']", | |
| ".ri-close-line", ".close-icon" | |
| ] | |
| for selector in patterns: | |
| try: | |
| # Check if visible and click. fast timeout. | |
| elem = await self._page.query_selector(selector) | |
| if elem and await elem.is_visible(): | |
| await elem.click(timeout=500) | |
| logger.info(f"Dismissed popup using {selector}") | |
| await asyncio.sleep(0.5) # Wait for animation | |
| except Exception: | |
| pass | |
| except Exception as e: | |
| logger.warning(f"Popup dismissal error: {e}") | |
| async def execute(self, action: str, **kwargs: Any) -> str: | |
| try: | |
| await self._ensure_initialized() | |
| page = self._page | |
| if action == "navigate": | |
| url = kwargs.get("url") | |
| if not url: return "Error: 'url' is required for navigate" | |
| if not url.startswith("http"): url = "https://" + url | |
| try: | |
| await page.goto(url, timeout=20000) | |
| await page.wait_for_load_state("domcontentloaded", timeout=10000) | |
| # Attempt to clean up view | |
| await self._handle_popups() | |
| except Exception as e: | |
| # Ignore timeout and assume page loaded partially | |
| logger.warning(f"Navigation to {url} timed out: {e}") | |
| await self._handle_popups() # Try finding popups even if timed out | |
| return f"Navigated to {url}, but timed out waiting for full load. Content might be available." | |
| title = await page.title() | |
| return f"Navigated to {url}. Page title: {title}" | |
| elif action == "click": | |
| selector = kwargs.get("selector") | |
| if not selector: return "Error: 'selector' is required for click" | |
| # specialized selector handling: try text if loose selector | |
| try: | |
| # If it looks like "link: <text>", extract | |
| if selector.startswith("link: "): | |
| selector = f"text={selector[6:]}" | |
| await page.click(selector, timeout=5000) | |
| except Exception: | |
| # Fallback to text match | |
| await page.click(f"text={selector}") | |
| return f"Clicked element: {selector}" | |
| elif action == "type": | |
| selector = kwargs.get("selector") | |
| text = kwargs.get("text") | |
| if not selector or text is None: return "Error: 'selector' and 'text' are required for type" | |
| await page.fill(selector, text) | |
| return f"Typed '{text}' into {selector}" | |
| elif action == "press": | |
| key = kwargs.get("key") | |
| if not key: return "Error: 'key' is required for press" | |
| await page.keyboard.press(key) | |
| return f"Pressed key: {key}" | |
| elif action == "scroll": | |
| direction = kwargs.get("direction", "down") | |
| amount = kwargs.get("amount", 500) | |
| if direction == "down": | |
| await page.evaluate(f"window.scrollBy(0, {amount})") | |
| else: | |
| await page.evaluate(f"window.scrollBy(0, -{amount})") | |
| return f"Scrolled {direction} by {amount} pixels" | |
| elif action == "get_text": | |
| selector = kwargs.get("selector", "body") | |
| if selector == "body": | |
| # Smart extraction: get text but also hints of links | |
| # We'll stick to text for this action to keep it simple, | |
| # but maybe use Readability if we had it. For now, innerText is fine. | |
| content = await page.evaluate("document.body.innerText") | |
| return content[:4000] + "... (truncated)" | |
| else: | |
| element = await page.query_selector(selector) | |
| if element: | |
| text = await element.inner_text() | |
| return text | |
| return f"Element not found: {selector}" | |
| elif action == "get_links": | |
| # new action to find clickable links | |
| links = await page.evaluate("""() => { | |
| const params = []; | |
| document.querySelectorAll('a').forEach(a => { | |
| if (a.innerText.trim().length > 3 && a.offsetParent !== null) { | |
| params.push({text: a.innerText.trim().slice(0, 50), href: a.href}); | |
| } | |
| }); | |
| return params.slice(0, 20); // Top 20 relevant links | |
| }""") | |
| return json.dumps(links, indent=2) | |
| elif action == "get_dom": | |
| # Simplified interactive DOM tree for buttons and inputs | |
| dom = await page.evaluate("""() => { | |
| const params = []; | |
| const elements = document.querySelectorAll('button, input, select, textarea, [role="button"]'); | |
| elements.forEach(el => { | |
| if (el.offsetParent !== null) { // visible | |
| let label = el.innerText || el.placeholder || el.getAttribute('aria-label') || el.name || el.id || el.value; | |
| label = label ? label.trim().slice(0, 50) : '[No Label]'; | |
| const tag = el.tagName.toLowerCase(); | |
| params.push(`${tag}: ${label}`); | |
| } | |
| }); | |
| return params.slice(0, 50).join('\\n'); // Top 50 interactive elements | |
| }""") | |
| return dom if dom else "No interactive elements found." | |
| elif action == "screenshot": | |
| path = "screenshot.png" | |
| await page.screenshot(path=path) | |
| return f"Screenshot saved to {path}" | |
| else: | |
| return f"Unknown action: {action}" | |
| except Exception as e: | |
| logger.error(f"Playwright error: {e}") | |
| return f"Error executing {action}: {str(e)}" | |
| async def close(self): | |
| if self._browser: | |
| await self._browser.close() | |
| if self._playwright: | |
| await self._playwright.stop() | |
| async def run_nps_task(task: str) -> str: | |
| """ | |
| Executes a task using the NPS Nanobot Agent. | |
| Args: | |
| task: The natural language task description. | |
| Returns: | |
| The result of the task execution. | |
| """ | |
| print("\n" + "="*60) | |
| print("π NPS OPERATIONAL AGENT (Nanobot + Playwright + Ollama)") | |
| print("="*60) | |
| # Initialize bus | |
| bus = MessageBus() | |
| # Configure LiteLLM for Mistral API (same key as the main app) | |
| model_name = "mistral/mistral-small-latest" | |
| mistral_api_key = os.getenv("MISTRAL_API_KEY") | |
| if not mistral_api_key: | |
| logger.warning("MISTRAL_API_KEY not found in environment variables. Agent may fail.") | |
| # Initialize LiteLLM provider for Mistral | |
| class MistralProvider(LiteLLMProvider): | |
| async def chat(self, messages, tools=None, model=None, max_tokens=4096, temperature=0.7): | |
| model = model or self.default_model | |
| kwargs = { | |
| "model": model, | |
| "messages": messages, | |
| "max_tokens": max_tokens, | |
| "temperature": temperature, | |
| "api_key": os.getenv("MISTRAL_API_KEY", ""), | |
| } | |
| if tools: | |
| kwargs["tools"] = tools | |
| kwargs["tool_choice"] = "auto" | |
| max_retries = 5 | |
| for attempt in range(max_retries): | |
| try: | |
| from litellm import acompletion | |
| from nanobot.providers.base import LLMResponse, ToolCallRequest | |
| import json | |
| response = await acompletion(**kwargs) | |
| llm_response = self._parse_response(response) | |
| # Fallback: if no tool calls but content looks like JSON tool call, parse it | |
| if not llm_response.tool_calls and llm_response.content: | |
| import re | |
| content = llm_response.content.strip() | |
| json_match = re.search(r'(\[.*\]|\{.*\})', content, re.DOTALL) | |
| if json_match: | |
| try: | |
| json_str = json_match.group(1) | |
| try: | |
| data = json.loads(json_str) | |
| except json.JSONDecodeError: | |
| data = [] | |
| for line in json_str.splitlines(): | |
| line = line.strip() | |
| if line: | |
| try: | |
| obj = json.loads(line) | |
| data.append(obj) | |
| except json.JSONDecodeError: | |
| pass | |
| if not data: raise | |
| tool_calls = [] | |
| if isinstance(data, list): | |
| for item in data: | |
| if isinstance(item, dict) and "name" in item and "arguments" in item: | |
| args = item["arguments"] | |
| if isinstance(args, str): | |
| try: args = json.loads(args) | |
| except: pass | |
| import uuid | |
| tool_calls.append(ToolCallRequest( | |
| id=f"call_{uuid.uuid4().hex[:8]}", | |
| name=item["name"], | |
| arguments=args | |
| )) | |
| elif isinstance(item, dict) and "type" in item and item["type"] == "function": | |
| func = item["function"] | |
| args = func["arguments"] | |
| if isinstance(args, str): | |
| try: args = json.loads(args) | |
| except: pass | |
| import uuid | |
| tool_calls.append(ToolCallRequest( | |
| id=item.get("id", f"call_{uuid.uuid4().hex[:8]}"), | |
| name=func["name"], | |
| arguments=args | |
| )) | |
| elif isinstance(data, dict): | |
| if "name" in data and "arguments" in data: | |
| args = data["arguments"] | |
| if isinstance(args, str): | |
| try: args = json.loads(args) | |
| except: pass | |
| import uuid | |
| tool_calls.append(ToolCallRequest( | |
| id=f"call_{uuid.uuid4().hex[:8]}", | |
| name=data["name"], | |
| arguments=args | |
| )) | |
| if tool_calls: | |
| llm_response.tool_calls = tool_calls | |
| llm_response.content = None | |
| llm_response.finish_reason = "tool_calls" | |
| except Exception as e: | |
| logger.warning(f"Failed to parse JSON tool call fallback: {e}") | |
| return llm_response | |
| except Exception as e: | |
| if attempt == max_retries - 1: | |
| from nanobot.providers.base import LLMResponse | |
| return LLMResponse(content=f"Error calling LLM after {max_retries} attempts: {str(e)}", finish_reason="error") | |
| logger.warning(f"LLM call failed (attempt {attempt+1}/{max_retries}): {e}") | |
| return LLMResponse(content="Error: Max retries exceeded", finish_reason="error") | |
| provider = MistralProvider( | |
| default_model=model_name, | |
| api_key=mistral_api_key | |
| ) | |
| # Initialize Agent Loop | |
| # We use current directory as workspace | |
| workspace_path = Path(os.getcwd()) | |
| # Clear memory to ensure fresh start | |
| memory_path = workspace_path / "memory" | |
| if memory_path.exists(): | |
| import shutil | |
| try: | |
| shutil.rmtree(memory_path) | |
| print("INFO: Cleared previous agent memory.") | |
| except Exception as e: | |
| print(f"WARN: Could not clear memory: {e}") | |
| agent = AgentLoop( | |
| bus=bus, | |
| provider=provider, | |
| workspace=workspace_path, | |
| model=model_name, | |
| max_iterations=15, | |
| ) | |
| # Register our custom Playwright tool | |
| playwright_tool = PlaywrightTool() | |
| agent.tools.register(playwright_tool) | |
| print(f"\nINFO: Agent initialized with model: {model_name}") | |
| print("INFO: Browser tool (Playwright) registered") | |
| try: | |
| if not task: | |
| return "β No task provided." | |
| print(f"\nπ Starting task: {task}") | |
| # Append strong instruction to the task | |
| system_instruction = ( | |
| "\n\nIMPORTANT INSTRUCTIONS:" | |
| "\n1. You are an autonomous browser agent. You MUST USE TOOLS to perform actions." | |
| "\n2. DO NOT narrate what you are going to do (e.g., 'I will click...'). JUST CALL THE TOOL." | |
| "\n3. If the task is not complete, your response MUST contain a tool call." | |
| "\n4. Only output text when you have fully completed the user's request and have the answer." | |
| "\n5. Start by navigating: browser(action='navigate', ...)." | |
| "\n6. Use 'get_links' to find clickable items." | |
| "\n7. Use 'get_dom' to find buttons and inputs on interactive pages." | |
| "\n8. If you see popups, 'OK' dialogs, or cookie banners that block you, click them to dismiss." | |
| ) | |
| full_task = task + system_instruction | |
| # Use a unique session ID for each task execution to prevent history leakage | |
| import uuid | |
| session_id = f"task_{uuid.uuid4().hex[:8]}" | |
| # Run the agent with a unique session key | |
| response = await agent.process_direct( | |
| full_task, | |
| session_key=f"cli:{session_id}", | |
| channel="cli", | |
| chat_id=session_id | |
| ) | |
| print("\n" + "="*60) | |
| print("β TASK COMPLETED") | |
| print("="*60) | |
| return response | |
| except KeyboardInterrupt: | |
| return "Task interrupted by user." | |
| finally: | |
| # Cleanup | |
| await playwright_tool.close() | |
| agent.stop() | |
| if __name__ == "__main__": | |
| # Test block | |
| task = input("Enter test task: ") | |
| asyncio.run(run_nps_task(task)) | |