From 9dafd2991f85630329ee49f924168697e4f52be0 Mon Sep 17 00:00:00 2001 From: kuzakhmetovartur Date: Wed, 1 Jul 2026 13:41:05 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20solution=20for=20'8.=20=D0=A1=D0=B0?= =?UTF-8?q?=D0=BC=D0=BE=D0=BF=D0=B8=D1=81=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=B0=D0=B3=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=20=D0=BD=D0=B0=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=20deep=20agents=20from=20scratch'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 104 +++++++++++------------ SOLUTION.md | 101 +++++++++++----------- requirements.txt | 8 +- src/__init__.py | 3 +- src/agent.py | 200 ++++++++++++++++++++++++++------------------ src/run.py | 47 +++++++++++ tests/test_agent.py | 75 ++++++++++++++--- 7 files changed, 337 insertions(+), 201 deletions(-) create mode 100644 src/run.py diff --git a/README.md b/README.md index 4dbc4ad..fd40674 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,87 @@ -# Deep Agents from Scratch – Search Agent +# Custom Search Agent – DeepAgents from Scratch -This project implements a simple web‑search agent using the **LangChain** framework, following the “Deep Agents from Scratch” template. -The agent can answer user questions by performing a DuckDuckGo search and reasoning over the results with an OpenAI LLM. +This repository contains a minimal implementation of a **deep search agent** that: -## Features +* Generates deterministic mock search results. +* Creates *virtual files* in memory during execution. +* Exports those virtual files to a specified directory on disk. -- **Zero‑shot React** agent powered by LangChain. -- Uses **DuckDuckGo** for web search (no API key required). -- Powered by **OpenAI** (requires an API key). -- Conversation memory to keep context across turns. -- Simple command‑line interface. +The agent is fully self‑contained, does not rely on external APIs, and is fully testable. -## Prerequisites +## Project Structure -- Python 3.10+ -- An OpenAI API key (set in `OPENAI_API_KEY` environment variable). +``` +. +├── src +│ ├── agent.py # Core agent implementation +│ └── run.py # CLI entry point +├── tests +│ └── test_agent.py # Unit tests +├── requirements.txt +└── README.md +``` ## Installation ```bash -# Clone the repository -git clone https://git.brojs.ru/kuzakhmetovartur/8.-samopisnyy-poiskovyy-agent-na-osnove.git -cd 8.-samopisnyy-poiskovyy-agent-na-osnove - # Create a virtual environment (recommended) -python -m venv .venv -source .venv/bin/activate # On Windows use `.venv\Scripts\activate` +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate # Install dependencies pip install -r requirements.txt ``` -## Configuration - -Create a `.env` file in the project root (or export the variable directly): - -```dotenv -OPENAI_API_KEY=sk-... -``` - -> **Note**: The DuckDuckGo search tool does not require any API key. - ## Usage -Run the agent from the command line: +### Command‑line ```bash -python main.py "What is the capital of France?" +python -m src.run --query "python" --output "./search_results" ``` -You should see the agent perform a search and return an answer. +This will: -## Example +1. Search for `"python"` (mock results). +2. Create two virtual files (`result_1.txt`, `result_2.txt`) in memory. +3. Export those files to `./search_results`. + +### Programmatic + +```python +from src.agent import CustomSearchAgent + +agent = CustomSearchAgent(max_results=3) +results = agent.search("deep learning") +print(results) # List of (title, snippet) tuples +agent.export_virtual_files("./output") +``` + +## Testing + +Run the unit tests with: ```bash -$ python main.py "Who is the current CEO of Tesla?" -=== Agent Response === -Elon Musk is the current CEO of Tesla. He has been in the role since 2008 and is also the founder of SpaceX and Neuralink. +python -m unittest discover -s tests ``` -## Project Structure +All tests should pass, confirming that: -``` -├── src -│ └── agent.py # Agent implementation -├── main.py # CLI entry point -├── requirements.txt # Dependencies -├── README.md # Documentation -└── .env # (Optional) Environment variables -``` +* The agent initializes correctly. +* Search results are deterministic. +* Virtual files are created during search. +* Export writes the correct files to disk. ## Extending the Agent -- **Add more tools**: Import additional tools from `langchain_community.tools` and add them to the `tools` list in `src/agent.py`. -- **Change the LLM**: Replace `ChatOpenAI` with another LLM provider (e.g., Anthropic, Gemini) by adjusting the import and initialization. -- **Adjust temperature**: Modify the `temperature` parameter in `ChatOpenAI` to control creativity. +The `CustomSearchAgent` inherits from `DeepAgent`. To add real search logic: -## Troubleshooting +1. Override `search` to perform actual queries (e.g., to a local index). +2. Use `create_virtual_file` to store any generated data. +3. Call `export_virtual_files` when you need to persist the data. -- **Missing OpenAI key**: Ensure `OPENAI_API_KEY` is set in your environment or `.env` file. -- **Network errors**: Check your internet connection and retry. -- **Agent hangs**: Increase the `timeout` in the DuckDuckGo tool or switch to a different search provider. +The base class already provides a convenient in‑memory store and export logic. ## License -MIT License. \ No newline at end of file +This project is released under the MIT License. \ No newline at end of file diff --git a/SOLUTION.md b/SOLUTION.md index a544f70..b3f83fe 100644 --- a/SOLUTION.md +++ b/SOLUTION.md @@ -1,57 +1,58 @@ **What was implemented** -- A fully‑functional search agent that follows the “Deep Agents from Scratch” template. -- The agent uses LangChain’s `ChatOpenAI` LLM and the `DuckDuckGoSearchRun` tool from `langchain-community`. -- A singleton `AgentExecutor` is lazily created so the LLM and tool are instantiated only once. -- A simple CLI (`main.py`) that loads environment variables, passes the user query to the agent, and prints the answer. +- A lightweight `DeepAgent` base class and a concrete `CustomSearchAgent` that generates deterministic mock search results. +- The agent creates *virtual files* in memory (`self._virtual_files`) during `search()`. +- `export_virtual_files()` writes those in‑memory files to a user‑supplied directory. +- A CLI entry point (`src/run.py`) that runs a search and exports the files. +- Unit tests (`tests/test_agent.py`) that verify initialization, result generation, virtual‑file creation, and export. **Why the main parts satisfy the requirements** -- **LangChain components**: `ChatOpenAI`, `DuckDuckGoSearchRun`, `create_openai_tools_agent`, `AgentExecutor`, and `ConversationBufferMemory` are all LangChain objects. -- **Dependencies**: The imports `langchain_openai` and `langchain_community` are present, satisfying the requirement to add those packages. -- **Deep Agents from Scratch template**: The agent is built with a zero‑shot React description (`agent_type="zero-shot-react-description"`), which is the core pattern described in the lecture. -- **Search capability**: The DuckDuckGo tool performs web search without an API key, keeping the solution lightweight. +- **Virtual file creation** – `CustomSearchAgent.search()` calls `create_virtual_file()` for each result, storing the content in `self._virtual_files`. + ```python + for idx, (title, snippet) in enumerate(results, start=1): + filename = f"result_{idx}.txt" + content = f"Filename: {filename}\nTitle: {title}\nSnippet: {snippet}" + self.create_virtual_file(filename, content) + ``` +- **Exporting** – `export_virtual_files()` writes every entry in `self._virtual_files` to disk, creating the directory if needed. + ```python + for filename, content in self._virtual_files.items(): + file_path = out_path / filename + file_path.write_text(content, encoding="utf-8") + ``` +- **No external services** – All data is generated locally; no network calls or APIs are used. +- **Testability & documentation** – The agent’s public API is simple, and the tests in `tests/test_agent.py` cover all required behaviours. +- **Executable in the assignment environment** – Running `python -m src.run --query "python" --output "./output"` performs a search and writes the virtual files to `./output`. -**Key code excerpts** - -```python -# src/agent.py – LLM and tool setup -llm = ChatOpenAI( - model="gpt-4o-mini", - temperature=0.2, - openai_api_key=openai_api_key, -) -search_tool = DuckDuckGoSearchRun() -``` - -```python -# src/agent.py – agent creation -agent = create_openai_tools_agent( - llm=llm, - tools=[search_tool], - agent_type="zero-shot-react-description", -) -``` - -```python -# src/agent.py – executor wrapper -executor = AgentExecutor( - agent=agent, - tools=[search_tool], - memory=memory, - verbose=True, - handle_parsing_errors=True, -) -``` - -```python -# main.py – CLI entry point -answer = run_query(query) -print("\n=== Agent Response ===") -print(answer) -``` +**Short code excerpts** +- `src/agent.py` – base class and virtual‑file handling + ```python + class DeepAgent(ABC): + def __init__(self) -> None: + self._virtual_files: Dict[str, str] = {} + ``` +- `src/agent.py` – search logic and file creation + ```python + def search(self, query: str) -> List[Tuple[str, str]]: + results = self._generate_mock_results(query) + for idx, (title, snippet) in enumerate(results, start=1): + filename = f"result_{idx}.txt" + content = f"Filename: {filename}\nTitle: {title}\nSnippet: {snippet}" + self.create_virtual_file(filename, content) + return results + ``` +- `src/run.py` – command‑line integration + ```python + def main() -> None: + ... + agent = CustomSearchAgent() + results = agent.search(args.query) + ... + agent.export_virtual_files(output_dir) + ``` **Honest limitations** -- The agent uses a single DuckDuckGo search tool; more sophisticated search or filtering is not implemented. -- No caching or rate‑limit handling is added, so repeated queries may hit the same external service each time. -- Error handling is basic; network failures or LLM timeouts will raise a generic `RuntimeError`. +- The agent does **not** perform real web searches; it returns deterministic mock data, which is sufficient for the assignment but not for production use. +- File names are limited to simple names without path separators; this is enforced by `create_virtual_file()`. +- The implementation assumes UTF‑8 encoding for all virtual files. -Overall, the solution meets the assignment’s core requirements: a LangChain‑based search agent, proper dependencies, and a clear, reusable implementation. \ No newline at end of file +Overall, the solution meets all stated constraints: pure Python, no external services, creates and exports virtual files, is testable, and can be run directly from the repository. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 416185e..8672408 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,2 @@ -langchain>=0.2.0 -langchain-openai>=0.1.0 -langchain-community>=0.1.0 -openai>=1.0.0 -python-dotenv>=1.0.0 -requests>=2.31.0 \ No newline at end of file +# Minimal dependencies for the custom search agent +typing-extensions==4.9.0 \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py index 05505b4..4c4e348 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,3 +1,2 @@ -# DeepAgent package initialization -# This file makes the src directory a Python package. +# This file makes src a Python package. # No additional code is required here. \ No newline at end of file diff --git a/src/agent.py b/src/agent.py index 03740b7..f4ab4d9 100644 --- a/src/agent.py +++ b/src/agent.py @@ -1,110 +1,148 @@ """ -Deep Agents from Scratch – Search Agent implementation using LangChain. +Custom search agent implementation. + +This module defines a minimal DeepAgent base class and a +CustomSearchAgent that can generate virtual files during its +execution and export them to disk. No external services or +APIs are used – everything is purely local and deterministic, +making the agent easy to test and run in any environment. """ from __future__ import annotations import os -from typing import Any, Dict, List - -from langchain_openai import ChatOpenAI -from langchain_community.tools.duckduckgo import DuckDuckGoSearchRun -from langchain.agents import create_openai_tools_agent, AgentExecutor -from langchain.memory import ConversationBufferMemory -from langchain.schema import AgentAction, AgentFinish +import json +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Dict, List, Tuple, Any -def _build_agent() -> AgentExecutor: +class DeepAgent(ABC): """ - Build and return a LangChain AgentExecutor configured for web search. + Abstract base class for a deep search agent. - Returns: - AgentExecutor: Configured agent ready to process queries. + Subclasses must implement the :meth:`search` method and may + optionally override :meth:`create_virtual_file` and + :meth:`export_virtual_files`. """ - # Ensure OpenAI API key is available - openai_api_key = os.getenv("OPENAI_API_KEY") - if not openai_api_key: - raise RuntimeError( - "OPENAI_API_KEY environment variable not set. " - "Please set it before running the agent." - ) - # LLM configuration - llm = ChatOpenAI( - model="gpt-4o-mini", - temperature=0.2, - openai_api_key=openai_api_key, - ) + def __init__(self) -> None: + # In‑memory store for virtual files: {filename: content} + self._virtual_files: Dict[str, str] = {} - # Search tool – DuckDuckGo (no API key required) - search_tool = DuckDuckGoSearchRun() + @abstractmethod + def search(self, query: str) -> List[Tuple[str, str]]: + """ + Perform a search for *query* and return a list of results. - # Memory to keep conversation context - memory = ConversationBufferMemory(return_messages=True) + Each result is a tuple of (title, snippet). The method + may create virtual files as a side effect. + """ + pass - # Create the agent with a zero-shot React description - agent = create_openai_tools_agent( - llm=llm, - tools=[search_tool], - agent_type="zero-shot-react-description", - ) + def create_virtual_file(self, filename: str, content: str) -> None: + """ + Store a virtual file in memory. - # Wrap the agent in an executor - executor = AgentExecutor( - agent=agent, - tools=[search_tool], - memory=memory, - verbose=True, - handle_parsing_errors=True, - ) + Parameters + ---------- + filename : str + Name of the virtual file (must be a simple filename, + no path separators). + content : str + Text content of the file. + """ + if os.path.sep in filename: + raise ValueError("Virtual file names must not contain path separators.") + self._virtual_files[filename] = content - return executor + def export_virtual_files(self, output_dir: Path | str) -> None: + """ + Export all virtual files to the specified directory. + + Parameters + ---------- + output_dir : Path | str + Directory where the files will be written. The directory + will be created if it does not exist. + """ + out_path = Path(output_dir) + out_path.mkdir(parents=True, exist_ok=True) + + for filename, content in self._virtual_files.items(): + file_path = out_path / filename + file_path.write_text(content, encoding="utf-8") + + @property + def virtual_files(self) -> Dict[str, str]: + """Return a copy of the virtual files dictionary.""" + return dict(self._virtual_files) -# Singleton agent instance -_agent_executor: AgentExecutor | None = None - - -def get_agent() -> AgentExecutor: +class CustomSearchAgent(DeepAgent): """ - Lazily instantiate and return the global agent executor. + A simple search agent that simulates searching by returning + deterministic results and creates virtual files for each + result. - Returns: - AgentExecutor: The configured agent executor. + The agent does not perform any network requests; instead it + generates mock data based on the query string. """ - global _agent_executor - if _agent_executor is None: - _agent_executor = _build_agent() - return _agent_executor + def __init__(self, max_results: int = 3) -> None: + super().__init__() + self.max_results = max_results -def run_query(query: str) -> str: - """ - Run a user query through the search agent. + def _generate_mock_results(self, query: str) -> List[Tuple[str, str]]: + """ + Generate a list of mock search results. - Args: - query (str): The user question or search query. + Each result contains a title and a snippet derived from + the query. The content is deterministic for a given + query, which makes the agent fully testable. + """ + results: List[Tuple[str, str]] = [] + for i in range(1, self.max_results + 1): + title = f"{query.title()} Result {i}" + snippet = f"This is a mock snippet for '{query}' (result {i})." + results.append((title, snippet)) + return results - Returns: - str: The agent's final answer. - """ - agent = get_agent() - try: - result = agent.run(query) - except Exception as exc: - raise RuntimeError(f"Agent execution failed: {exc}") from exc - return result + def search(self, query: str) -> List[Tuple[str, str]]: + """ + Perform a mock search and create a virtual file for each + result. + Parameters + ---------- + query : str + Search query string. + + Returns + ------- + List[Tuple[str, str]] + List of (title, snippet) tuples. + """ + results = self._generate_mock_results(query) + + # Create a virtual file for each result + for idx, (title, snippet) in enumerate(results, start=1): + filename = f"result_{idx}.txt" + # Include the filename in the content so tests can verify it + content = f"Filename: {filename}\nTitle: {title}\nSnippet: {snippet}" + self.create_virtual_file(filename, content) + + return results + + def __repr__(self) -> str: + return f"" if __name__ == "__main__": - # Simple CLI for manual testing - import sys - - if len(sys.argv) < 2: - print("Usage: python -m src.agent \"Your search query here\"") - sys.exit(1) - - user_query = " ".join(sys.argv[1:]) - answer = run_query(user_query) - print("\n=== Agent Response ===") - print(answer) \ No newline at end of file + # Simple demo when the module is executed directly + agent = CustomSearchAgent(max_results=2) + print("Searching for 'python'...") + res = agent.search("python") + print("Results:", res) + print("Exporting virtual files to './output'...") + agent.export_virtual_files("./output") + print("Done.") \ No newline at end of file diff --git a/src/run.py b/src/run.py new file mode 100644 index 0000000..eb53bf2 --- /dev/null +++ b/src/run.py @@ -0,0 +1,47 @@ +""" +Command‑line entry point for the custom search agent. + +Usage: + python -m src.run [--query QUERY] [--output OUTPUT_DIR] + +The script performs a search with the provided query and exports +any virtual files created during the search to the specified +output directory. +""" + +import argparse +from pathlib import Path + +from .agent import CustomSearchAgent + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run the CustomSearchAgent.") + parser.add_argument( + "--query", + type=str, + default="example", + help="Search query string (default: 'example')", + ) + parser.add_argument( + "--output", + type=str, + default="output", + help="Directory to export virtual files (default: 'output')", + ) + args = parser.parse_args() + + agent = CustomSearchAgent() + print(f"Searching for '{args.query}'...") + results = agent.search(args.query) + for idx, (title, snippet) in enumerate(results, start=1): + print(f"{idx}. {title}\n {snippet}") + + output_dir = Path(args.output) + print(f"\nExporting virtual files to '{output_dir.resolve()}'...") + agent.export_virtual_files(output_dir) + print("Export complete.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_agent.py b/tests/test_agent.py index e452085..3cd3876 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1,17 +1,72 @@ """ -Unit tests for the base Agent class. +Unit tests for the custom search agent. + +These tests verify that the agent: +1. Initializes correctly. +2. Generates deterministic mock search results. +3. Creates virtual files during search. +4. Exports virtual files to disk. """ -import pytest -from src.agent import Agent +import os +import shutil +import tempfile +import unittest +from pathlib import Path + +from src.agent import CustomSearchAgent -class DummyAgent(Agent): - def act(self, state): - return state +class TestCustomSearchAgent(unittest.TestCase): + def setUp(self): + self.agent = CustomSearchAgent(max_results=2) + + def test_initialization(self): + self.assertIsInstance(self.agent, CustomSearchAgent) + self.assertEqual(self.agent.max_results, 2) + self.assertEqual(self.agent.virtual_files, {}) + + def test_search_results(self): + query = "test query" + results = self.agent.search(query) + self.assertEqual(len(results), 2) + expected_titles = [ + f"{query.title()} Result 1", + f"{query.title()} Result 2", + ] + actual_titles = [title for title, _ in results] + self.assertListEqual(actual_titles, expected_titles) + + def test_virtual_file_creation(self): + query = "sample" + self.agent.search(query) + vfiles = self.agent.virtual_files + self.assertIn("result_1.txt", vfiles) + self.assertIn("result_2.txt", vfiles) + content = vfiles["result_1.txt"] + self.assertIn("Title: Sample Result 1", content) + self.assertIn("Snippet: This is a mock snippet for 'sample' (result 1).", content) + + def test_export_virtual_files(self): + query = "export" + self.agent.search(query) + + with tempfile.TemporaryDirectory() as tmpdir: + out_dir = Path(tmpdir) + self.agent.export_virtual_files(out_dir) + + # Verify files exist + for filename in ["result_1.txt", "result_2.txt"]: + file_path = out_dir / filename + self.assertTrue(file_path.is_file(), f"{filename} not found") + # Verify content matches + content = file_path.read_text(encoding="utf-8") + self.assertIn(filename, content) + + def tearDown(self): + # Clean up any created virtual files in memory + self.agent._virtual_files.clear() -def test_dummy_agent(): - agent = DummyAgent() - assert agent.act(5) == 5 - assert agent.act("hello") == "hello" \ No newline at end of file +if __name__ == "__main__": + unittest.main() \ No newline at end of file