"""GAIA agent tools. Registry assembled by build_tools().""" from __future__ import annotations from typing import Optional from langchain_core.tools import BaseTool, tool from lilith_agent.config import Config from lilith_agent.tools.audio import transcribe_audio as _transcribe_audio from lilith_agent.tools.files import ( read_file as _read_file, ls as _ls, grep as _grep, glob_files as _glob_files, find_files as _find_files, write_file as _write_file ) from lilith_agent.tools.todos import todos as _todos from lilith_agent.tools.pdf import inspect_pdf as _inspect_pdf from lilith_agent.tools.python_exec import run_python as _run_python from lilith_agent.tools.search import web_search as _web_search from lilith_agent.tools.vision import inspect_visual_content as _inspect_visual_content from lilith_agent.tools.web import fetch_url as _fetch_url from lilith_agent.tools.youtube import ( youtube_transcript as _youtube_transcript, youtube_frame_at as _youtube_frame_at, ) from lilith_agent.tools.academic import ( arxiv_search as _arxiv_search, crossref_search as _crossref_search, count_journal_articles as _count_journal_articles ) from lilith_agent.tools.filters import filter_entities as _filter_entities def build_tools(cfg: Config) -> list[BaseTool]: """Build the list of LangChain tools, injecting config via closures.""" @tool def web_search(query: str, max_results: int = 5) -> str: """Search the web for general information using DuckDuckGo (primary) and Tavily (fallback). Returns a list of title/url/snippet results. This is your primary web search tool.""" return _web_search(query, api_key=cfg.tavily_api_key, max_results=max_results) @tool def fetch_url(url: str, max_chars: int = 8000) -> str: """Fetch a URL and return its extracted main text (trafilatura).""" return _fetch_url(url, max_chars=max_chars) @tool def run_python(code: str, timeout: int = 30) -> str: """Run Python code in a sandboxed subprocess and return stdout + last expression. ISOLATED FROM THE HOST FILESYSTEM. This tool runs inside a container (or a scrubbed subprocess with cwd pinned to a throwaway tmpdir). It CANNOT open files from the user's workspace — paths like './notes.md', '.lilith/...', or any relative path resolve against the sandbox's own empty scratch dir, not the repo. Any file written here vanishes when the call returns. To move data across the boundary: - To READ a workspace file, call `read_file` first and pass the returned text into `run_python` as a string literal in `code`. - To PERSIST output the user can see, call `write_file` — do NOT try to write from inside `run_python`. STATELESS EXECUTION: The Python environment resets on every call! Functions, variables, or state created in one `run_python` call DO NOT persist to the next. If you need to reuse code or state, you must pass it back into the next script. AVAILABLE LIBRARIES: requests, beautifulsoup4 (bs4), pandas, trafilatura, openpyxl, faster-whisper, pypdf. CRITICAL: If you use this tool to scrape websites, you MUST include a 'User-Agent' header. Example: headers = {'User-Agent': 'Mozilla/5.0...'} """ return _run_python(code, timeout=timeout) @tool def read_file(path: str, start_line: Optional[int] = None, end_line: Optional[int] = None, max_chars: int = 20000) -> str: """Read a local file with optional line-based chunking (start_line/end_line). Returns text. `path` accepts `~/...` for the user's home directory and absolute paths; reads the HOST filesystem (not the `run_python` sandbox).""" return _read_file(path, start_line=start_line, end_line=end_line, max_chars=max_chars) @tool def ls(path: str = ".") -> str: """List contents of a directory. Use this to find available files. `path` accepts `~/...` for the user's home directory and absolute paths; lists the HOST filesystem (not the `run_python` sandbox).""" return _ls(path) @tool def grep(pattern: str, path: str) -> str: """Search for a pattern in a file. Returns matching lines with line numbers. `path` accepts `~/...` for the user's home directory and absolute paths; searches the HOST filesystem (not the `run_python` sandbox).""" return _grep(pattern, path) @tool def glob_files(pattern: str) -> str: """Find files matching a glob pattern (e.g., '**/*.csv'). `pattern` accepts `~/...` and absolute patterns (e.g. `/Users/me/code/**/*.py`); operates on the HOST filesystem (not the `run_python` sandbox).""" return _glob_files(pattern) @tool def find_files(name: str, root: str = ".", max_results: int = 200) -> str: """Locate files by name under `root` using unix `find` (fast deep search). Use this instead of walking with `ls` when looking for a known filename in a large tree. `root` accepts `~/...` and absolute paths and operates on the HOST filesystem; NEVER pass `root='/'` (will time out scanning the whole disk) — start from a focused subtree like `~/code` or `~`. Default excludes already skip `node_modules`/`.git`/`.venv`/`__pycache__`/etc.""" return _find_files(name, root=root, max_results=max_results) @tool def write_file(path: str, content: str) -> str: """Persist text to a file the user can open afterwards. This is the ONLY tool that writes to the user's workspace. `run_python` runs in an isolated sandbox with no host-filesystem access, so files it creates are lost when the call returns — always route persisted output through this tool instead. `path` is relative to the agent's write root (`.lilith/scratch` by default); absolute paths and `..`-escapes are rejected. """ return _write_file(path, content) @tool def todos(action: str, items: Optional[list[str]] = None, index: Optional[int] = None) -> str: """Manage the planning todo list. action='write': overwrite the list. Requires `items` (list of strings). action='done': mark a todo complete. Requires `index` (0-based position). """ return _todos(action, items=items, index=index) @tool def transcribe_audio(path: str) -> str: """Transcribe an audio file using faster-whisper. Returns the transcript text.""" return _transcribe_audio(path, model_size=cfg.whisper_model) @tool def inspect_pdf(url_or_path: str, query: str) -> str: """Extract text from a PDF (local or remote). Recommended for reading academic papers or long documents.""" return _inspect_pdf(url_or_path, query=query) @tool def inspect_visual_content(file_path_or_url: str, prompt: str) -> str: """Use Gemini Multimodal Vision to analyze an image (PNG, JPG) or video (MP4). Use this for counting objects on screen or identifying visual details.""" return _inspect_visual_content(file_path_or_url, prompt=prompt, cfg=cfg) @tool def youtube_transcript(url: str) -> str: """Fetch the spoken-word transcript (captions) for a YouTube video URL. Does NOT capture on-screen text overlays — use `youtube_frame_at` + `inspect_visual_content` for those.""" return _youtube_transcript(url) @tool def youtube_frame_at(url: str, timestamp_seconds: float) -> str: """Download a YouTube video and extract ONE frame at the given timestamp as a local PNG. Returns the local PNG path — pass it to `inspect_visual_content` to read on-screen text or visual details. REQUIRED for questions about what is visible on screen at a specific time in a YouTube video (e.g. a phrase shown as a title card, or an object visible at 0:30). """ return _youtube_frame_at(url, timestamp_seconds=timestamp_seconds) @tool def arxiv_search(query: str, max_results: int = 5) -> str: """Search arXiv for papers. Returns a list of titles, authors, and summaries. Use this for modern academic pre-prints.""" return _arxiv_search(query, max_results=max_results) @tool def crossref_search(filter_str: str, rows: int = 100, cursor: str = "*") -> str: """Search CrossRef API for bibliographic metadata. Use filter strings like 'issn:0028-0836,type:journal-article,from-pub-date:2020,until-pub-date:2020'. Returns a JSON list of works. Pair with filter_entities to prune broad results. """ return _crossref_search(filter_str, rows=rows, cursor=cursor) @tool def count_journal_articles(journal_name: str, year: int, is_research_only: bool = True) -> str: """High-precision tool to count articles in a journal for a given year. Use this for definitive bibliographic metric counting (e.g., 'articles in Nature in 2020'). It handles complex filtering and official source scraping internally. """ return _count_journal_articles(journal_name, year, is_research_only=is_research_only) @tool def filter_entities(entities: list[dict], keep_conditions: list[str] = None, remove_conditions: list[str] = None) -> str: """Filter a list of entities based on conditions. Returns a metadata-rich JSON. Use this when a prior tool (like crossref_search) returns a 'broad' list that needs pruning (e.g., removing book reviews). """ return _filter_entities(entities, keep_conditions=keep_conditions, remove_conditions=remove_conditions) @tool def search_memory(query: str) -> str: """Search Lilith's long-term memory (semantic facts and episodic experiences) for information matching the given query. Use this when the system context mentions omitted facts, or when you need to recall specific past experiences, user preferences, or project details that may not be in the current context window.""" from lilith_agent.memory import search_memory_store return search_memory_store(query) return [ web_search, fetch_url, run_python, read_file, transcribe_audio, youtube_transcript, youtube_frame_at, inspect_pdf, inspect_visual_content, arxiv_search, crossref_search, count_journal_articles, filter_entities, ls, grep, glob_files, find_files, write_file, todos, search_memory, ]