diff --git a/.env.example b/.env.example index 4f0d349..5a83d3c 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,7 @@ RETRY_INITIAL_DELAY=1.0 # Initial delay in seconds RETRY_MAX_DELAY=60.0 # Maximum delay in seconds # Memory Management Configuration +MEMORY_ENABLED=true # Enable/disable memory compression MEMORY_MAX_CONTEXT_TOKENS=100000 # Maximum context window size MEMORY_TARGET_TOKENS=30000 # Target working memory size (soft limit) MEMORY_COMPRESSION_THRESHOLD=25000 # Hard limit - compress when exceeded diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7f49ad1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - uses: astral-sh/setup-uv@v3 + - name: Bootstrap + run: ./scripts/bootstrap.sh --no-env + - name: Lint (pre-commit) + run: ./scripts/dev.sh precommit + - name: Tests + run: ./scripts/dev.sh test -q + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v3 + - name: Bootstrap + run: ./scripts/bootstrap.sh --no-env + - name: Typecheck + run: TYPECHECK_STRICT=1 ./scripts/dev.sh typecheck diff --git a/.gitignore b/.gitignore index cbd1dd8..19872d9 100644 --- a/.gitignore +++ b/.gitignore @@ -145,6 +145,15 @@ ENV/ env.bak/ venv.bak/ +# Runtime data (SQLite memory DB, etc.) +data/ + +# Claude Code local rules/state (project-local) +.claude/ + +# Tool caches +.AgenticLoop/ + # Spyder project settings .spyderproject .spyproject @@ -183,9 +192,9 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..32f16a8 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,25 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/psf/black + rev: 25.1.0 + hooks: + - id: black + language_version: python3 + + - repo: https://github.com/PyCQA/isort + rev: 6.0.0 + hooks: + - id: isort + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.10 + hooks: + - id: ruff + args: ["--fix"] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bf833f1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,129 @@ +# AgenticLoop — Agent Instructions + +This file defines the **operational workflow** for making changes in this repo (how to set up, run, test, format, build, and publish). Keep it short, specific, and executable; link to docs for long explanations. + +Prerequisites: Python 3.12+ and `uv` (https://github.com/astral-sh/uv). + +## Quickstart (Local Dev) + +```bash +./scripts/bootstrap.sh +source .venv/bin/activate +./scripts/dev.sh test +``` + +Optional (recommended): enable git hooks: + +```bash +pre-commit install +``` + +## CI + +GitHub Actions runs `./scripts/dev.sh precommit`, `./scripts/dev.sh test -q`, and strict typecheck on PRs. + +## Review Checklist + +Run these before concluding a change: + +```bash +./scripts/dev.sh precommit +TYPECHECK_STRICT=1 ./scripts/dev.sh typecheck +./scripts/dev.sh test -q +``` + +Manual doc/workflow checks: +- README/AGENTS/docs: avoid legacy/removed commands (`LLM_PROVIDER`, `pip install -e`, `requirements.txt`, `setup.py`) +- Docker examples use `--mode`/`--task` +- Python 3.12+ + uv-only prerequisites documented consistently + +Change impact reminders: +- CLI changes → update `README.md`, `docs/examples.md` +- Config changes → update `.env.example`, `docs/configuration.md` +- Workflow scripts → update `AGENTS.md`, `docs/packaging.md` + +Run a quick smoke task (requires a configured provider in `.env`): + +```bash +python main.py --task "Calculate 1+1" +``` + +## Repo Map + +- `main.py`, `cli.py`, `interactive.py`: CLI entry points and UX +- `agent/`: agent loops (ReAct, Plan-Execute) and orchestration +- `tools/`: tool implementations (file ops, shell, web search, etc.) +- `llm/`: provider adapters + retry logic +- `memory/`: memory manager, compression, persistence +- `docs/`: user/developer documentation +- `scripts/`: packaging/publishing scripts +- `test/`: tests (some require API keys; memory tests are mostly mocked) + +## Commands (Golden Path) + +### Install + +- Use `./scripts/bootstrap.sh` to create `.venv` and install dependencies. +- Use `./scripts/dev.sh install` to reinstall dev deps into an existing `.venv`. + +### Tests + +- All tests: `python -m pytest test/` +- Memory suite: `python -m pytest test/memory/ -v` +- Script: `./scripts/test.sh` +- Unified entrypoint: `./scripts/dev.sh test` +- Integration tests: set `RUN_INTEGRATION_TESTS=1` (live LLM; may incur cost) + +### Format + +This repo uses `black` + `isort` (see `pyproject.toml`). + +```bash +python -m black . +python -m isort . +``` + +Script: `./scripts/format.sh` +Unified entrypoint: `./scripts/dev.sh format` + +### Lint / Typecheck + +- Lint (format check): `./scripts/dev.sh lint` +- Pre-commit (recommended): `./scripts/dev.sh precommit` +- Typecheck (best-effort): `./scripts/dev.sh typecheck` (set `TYPECHECK_STRICT=1` to fail on errors) + +### Build (Packaging) + +```bash +./scripts/build.sh +``` +Unified entrypoint: `./scripts/dev.sh build` + +### Publish (Manual / Interactive) + +`./scripts/publish.sh` defaults to an interactive confirmation and refuses to run without a TTY unless you pass `--yes`. + +- TestPyPI: `./scripts/publish.sh --test` +- PyPI (manual): `./scripts/publish.sh` +- Unified entrypoint: `./scripts/dev.sh publish` + +## Docs Pointers + +- Configuration & `.env`: `docs/configuration.md` +- Packaging & release checklist: `docs/packaging.md` +- Extending tools/agents: `docs/extending.md` +- Memory system: `docs/memory-management.md`, `docs/memory_persistence.md` +- Usage examples: `docs/examples.md` + +## Safety & Secrets + +- Never commit `.env` or API keys. +- Avoid running destructive shell commands; keep file edits scoped and reversible. +- Publishing/releasing steps require explicit human intent (see `docs/packaging.md`). + +## When Changing Key Areas + +- If you change CLI flags / behavior: update `README.md` and `docs/examples.md`. +- If you change configuration/env vars: update `docs/configuration.md` and `.env.example`. +- If you change packaging/versioning: update `pyproject.toml` and `docs/packaging.md`. +- If you change memory/compression/persistence: add/adjust tests under `test/memory/` and update `docs/memory-management.md` / `docs/memory_persistence.md`. diff --git a/Dockerfile b/Dockerfile index 56d1f12..c86dbc4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,13 +10,13 @@ RUN apt-get update && apt-get install -y \ git \ && rm -rf /var/lib/apt/lists/* -# Copy requirements first for better caching -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - # Copy application code COPY . . +# Install uv and package dependencies +RUN pip install --no-cache-dir uv +RUN uv pip install --system . + # Create a non-root user RUN useradd -m -u 1000 agentuser && chown -R agentuser:agentuser /app USER agentuser @@ -31,4 +31,4 @@ CMD ["--help"] # Usage: # Build: docker build -t AgenticLoop . -# Run: docker run -it --rm -e ANTHROPIC_API_KEY=your_key AgenticLoop interactive +# Run: docker run -it --rm -e ANTHROPIC_API_KEY=your_key AgenticLoop --mode react --task "Hello" diff --git a/MANIFEST.in b/MANIFEST.in index 99d6563..26a498a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,6 +2,5 @@ include README.md include LICENSE include .env.example -include requirements.txt recursive-include docs *.md recursive-include examples *.py *.md diff --git a/README.md b/README.md index 29671ca..2152452 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ General AI Agent System ## Installation +Prerequisites for development: +- Python 3.12+ +- `uv` (https://github.com/astral-sh/uv) + ### Option 1: Install from PyPI (Recommended - Coming Soon) ```bash @@ -17,8 +21,8 @@ pip install AgenticLoop git clone https://github.com/yourusername/AgenticLoop.git cd AgenticLoop -# Install in development mode -pip install -e . +# Bootstrap (recommended) +./scripts/bootstrap.sh ``` ### Option 3: Install from GitHub @@ -31,11 +35,26 @@ pip install git+https://github.com/yourusername/AgenticLoop.git ```bash docker pull yourusername/AgenticLoop:latest -docker run -it --rm -e ANTHROPIC_API_KEY=your_key AgenticLoop interactive +docker run -it --rm -e ANTHROPIC_API_KEY=your_key AgenticLoop --mode react ``` ## Quick Start +For repo workflow (install/test/format/build/publish), see `AGENTS.md`. + +### 0. Install Dependencies (Recommended) + +```bash +./scripts/bootstrap.sh +``` + +Optional (recommended): enable git hooks for consistent formatting/linting on commit: + +```bash +source .venv/bin/activate +pre-commit install +``` + ### 1. Configuration Create `.env` file: @@ -49,7 +68,7 @@ Edit `.env` file and configure your LLM provider: ```bash # LiteLLM Model Configuration (supports 100+ providers) # Format: provider/model_name -LITELLM_MODEL=anthropic/claude-3-5-sonnet +LITELLM_MODEL=anthropic/claude-3-5-sonnet-20241022 # API Keys (set the key for your chosen provider) ANTHROPIC_API_KEY=your_anthropic_api_key_here @@ -67,6 +86,7 @@ LITELLM_TIMEOUT=600 # Request timeout in seconds MAX_ITERATIONS=100 # Maximum iteration loops # Memory Management +MEMORY_ENABLED=true MEMORY_MAX_CONTEXT_TOKENS=100000 MEMORY_TARGET_TOKENS=30000 MEMORY_COMPRESSION_THRESHOLD=25000 @@ -87,7 +107,7 @@ LOG_TO_CONSOLE=false **Quick setup for different providers:** -- **Anthropic Claude**: `LITELLM_MODEL=anthropic/claude-3-5-sonnet` +- **Anthropic Claude**: `LITELLM_MODEL=anthropic/claude-3-5-sonnet-20241022` - **OpenAI GPT**: `LITELLM_MODEL=openai/gpt-4o` - **Google Gemini**: `LITELLM_MODEL=gemini/gemini-1.5-pro` - **Azure OpenAI**: `LITELLM_MODEL=azure/gpt-4` @@ -105,10 +125,10 @@ See [LiteLLM Providers](https://docs.litellm.ai/docs/providers) for 100+ support aloop # Single task (ReAct mode) -aloop --mode react "Calculate 123 * 456" +aloop --mode react --task "Calculate 123 * 456" # Single task (Plan-Execute mode) -aloop --mode plan "Build a web scraper" +aloop --mode plan --task "Build a web scraper" # Show help aloop --help @@ -166,7 +186,6 @@ See [Memory Management Documentation](docs/memory-management.md) for detailed in ``` AgenticLoop/ ├── README.md # This document -├── requirements.txt # Python dependencies ├── .env.example # Environment variables template ├── config.py # Configuration management ├── main.py # CLI entry point @@ -225,7 +244,7 @@ See the full configuration template in `.env.example`. Key options: | Setting | Description | Default | |---------|-------------|---------| -| `LITELLM_MODEL` | LiteLLM model (provider/model format) | `anthropic/claude-3-5-sonnet` | +| `LITELLM_MODEL` | LiteLLM model (provider/model format) | `anthropic/claude-3-5-sonnet-20241022` | | `LITELLM_API_BASE` | Custom base URL for proxies | Empty | | `LITELLM_DROP_PARAMS` | Drop unsupported params | `true` | | `LITELLM_TIMEOUT` | Request timeout in seconds | `600` | @@ -244,8 +263,9 @@ See [Configuration Guide](docs/configuration.md) for detailed options. Run basic tests: ```bash -source venv/bin/activate -python test_basic.py +./scripts/bootstrap.sh +source .venv/bin/activate +./scripts/dev.sh test -q ``` ## Learning Resources @@ -288,14 +308,14 @@ See the [Packaging Guide](docs/packaging.md) for instructions on: Quick commands: ```bash -# Install locally for development -./scripts/install_local.sh +# Bootstrap local dev environment (creates .venv, installs deps, initializes .env) +./scripts/bootstrap.sh # Build distribution packages -./scripts/build.sh +./scripts/dev.sh build # Publish to PyPI -./scripts/publish.sh +./scripts/dev.sh publish ``` ## Contributing diff --git a/agent/base.py b/agent/base.py index 5d4d95c..bd72eb7 100644 --- a/agent/base.py +++ b/agent/base.py @@ -1,15 +1,17 @@ """Base agent class for all agent types.""" + from abc import ABC, abstractmethod -from typing import List, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, List, Optional -from .tool_executor import ToolExecutor -from .todo import TodoList +from llm import LLMMessage, LLMResponse, ToolResult +from memory import MemoryConfig, MemoryManager from tools.base import BaseTool from tools.todo import TodoTool -from llm import LLMMessage, LLMResponse, ToolResult -from memory import MemoryManager, MemoryConfig from utils import get_logger, terminal_ui +from .todo import TodoList +from .tool_executor import ToolExecutor + if TYPE_CHECKING: from llm import LiteLLMLLM @@ -62,10 +64,7 @@ def run(self, task: str) -> str: pass def _call_llm( - self, - messages: List[LLMMessage], - tools: Optional[List] = None, - **kwargs + self, messages: List[LLMMessage], tools: Optional[List] = None, **kwargs ) -> LLMResponse: """Helper to call LLM with consistent parameters. @@ -77,12 +76,7 @@ def _call_llm( Returns: LLMResponse object """ - return self.llm.call( - messages=messages, - tools=tools, - max_tokens=4096, - **kwargs - ) + return self.llm.call(messages=messages, tools=tools, max_tokens=4096, **kwargs) def _extract_text(self, response: LLMResponse) -> str: """Extract text from LLM response. @@ -102,7 +96,7 @@ def _react_loop( max_iterations: int, use_memory: bool = True, save_to_memory: bool = True, - verbose: bool = True + verbose: bool = True, ) -> str: """Execute a ReAct (Reasoning + Acting) loop. @@ -143,18 +137,24 @@ def _react_loop( if response.usage: actual_tokens = { "input": response.usage.get("input_tokens", 0), - "output": response.usage.get("output_tokens", 0) + "output": response.usage.get("output_tokens", 0), } self.memory.add_message(assistant_msg, actual_tokens=actual_tokens) # Log compression info if it happened if self.memory.was_compressed_last_iteration: - logger.debug(f"Memory compressed: saved {self.memory.last_compression_savings} tokens") + logger.debug( + f"Memory compressed: saved {self.memory.last_compression_savings} tokens" + ) else: # For local messages (mini-loop), still track token usage if response.usage: - self.memory.token_tracker.add_input_tokens(response.usage.get("input_tokens", 0)) - self.memory.token_tracker.add_output_tokens(response.usage.get("output_tokens", 0)) + self.memory.token_tracker.add_input_tokens( + response.usage.get("input_tokens", 0) + ) + self.memory.token_tracker.add_output_tokens( + response.usage.get("output_tokens", 0) + ) messages.append(assistant_msg) # Check if we're done (no tool calls) @@ -183,13 +183,11 @@ def _react_loop( # Truncate overly large results to prevent context overflow MAX_TOOL_RESULT_LENGTH = 8000 # characters - truncated = False if len(result) > MAX_TOOL_RESULT_LENGTH: - truncated = True truncated_length = MAX_TOOL_RESULT_LENGTH result = ( - result[:truncated_length] + - f"\n\n[... Output truncated. Showing first {truncated_length} characters of {len(result)} total. " + result[:truncated_length] + + f"\n\n[... Output truncated. Showing first {truncated_length} characters of {len(result)} total. " f"Use grep_content or glob_files for more targeted searches instead of reading large files.]" ) if verbose: @@ -200,10 +198,7 @@ def _react_loop( # Log result (truncated) logger.debug(f"Tool result: {result[:200]}{'...' if len(result) > 200 else ''}") - tool_results.append(ToolResult( - tool_call_id=tc.id, - content=result - )) + tool_results.append(ToolResult(tool_call_id=tc.id, content=result)) # Format tool results and add to context result_message = self.llm.format_tool_results(tool_results) @@ -215,10 +210,7 @@ def _react_loop( return "Max iterations reached without completion." def delegate_subtask( - self, - subtask_description: str, - max_iterations: int = 5, - include_context: bool = False + self, subtask_description: str, max_iterations: int = 5, include_context: bool = False ) -> str: """Delegate a complex subtask to an isolated execution context. @@ -236,7 +228,9 @@ def delegate_subtask( Returns: Compressed summary of subtask execution result """ - logger.info(f"🔀 Delegating subtask (max {max_iterations} iterations): {subtask_description[:100]}...") + logger.info( + f"🔀 Delegating subtask (max {max_iterations} iterations): {subtask_description[:100]}..." + ) # Build sub-agent system prompt sub_system_prompt = """ @@ -271,6 +265,7 @@ def delegate_subtask( if include_context: try: from .context import format_context_prompt + context = format_context_prompt() sub_system_prompt = context + "\n\n" + sub_system_prompt except Exception as e: @@ -282,7 +277,7 @@ def delegate_subtask( # Create isolated message context sub_messages = [ LLMMessage(role="system", content=sub_system_prompt), - LLMMessage(role="user", content=f"Execute the subtask: {subtask_description}") + LLMMessage(role="user", content=f"Execute the subtask: {subtask_description}"), ] # Get tools (same as main agent, but sub-agent has its own todo list via tool) @@ -296,20 +291,22 @@ def delegate_subtask( max_iterations=max_iterations, use_memory=False, # KEY: Don't use main memory save_to_memory=False, # KEY: Don't save to main memory - verbose=True # Still show progress + verbose=True, # Still show progress ) # Compress result for main agent max_result_length = 2000 # characters if len(result) > max_result_length: compressed_result = ( - result[:max_result_length] + - f"\n\n[Result truncated. Original length: {len(result)} chars]" + result[:max_result_length] + + f"\n\n[Result truncated. Original length: {len(result)} chars]" ) else: compressed_result = result - logger.info(f"✅ Subtask completed. Result length: {len(result)} → {len(compressed_result)} chars") + logger.info( + f"✅ Subtask completed. Result length: {len(result)} → {len(compressed_result)} chars" + ) return f"Subtask execution result:\n{compressed_result}" diff --git a/agent/context.py b/agent/context.py index 465e719..14bd2dc 100644 --- a/agent/context.py +++ b/agent/context.py @@ -1,10 +1,10 @@ """Context injection module for providing environment information to agents.""" + import os -import subprocess import platform +import subprocess from datetime import datetime -from pathlib import Path -from typing import Dict, Optional +from typing import Any, Dict def get_working_directory() -> str: @@ -29,7 +29,7 @@ def get_platform_info() -> Dict[str, str]: } -def get_git_status() -> Dict[str, any]: +def get_git_status() -> Dict[str, Any]: """Get git repository information if available. Returns: @@ -38,41 +38,32 @@ def get_git_status() -> Dict[str, any]: try: # Check if we're in a git repository subprocess.run( - ["git", "rev-parse", "--git-dir"], - capture_output=True, - check=True, - timeout=2 + ["git", "rev-parse", "--git-dir"], capture_output=True, check=True, timeout=2 ) # Get current branch branch = subprocess.check_output( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], - text=True, - timeout=2 + ["git", "rev-parse", "--abbrev-ref", "HEAD"], text=True, timeout=2 ).strip() # Get status (short format) - status = subprocess.check_output( - ["git", "status", "--short"], - text=True, - timeout=2 - ).strip() + status = subprocess.check_output(["git", "status", "--short"], text=True, timeout=2).strip() # Get recent commits recent_commits = subprocess.check_output( - ["git", "log", "-5", "--oneline"], - text=True, - timeout=2 + ["git", "log", "-5", "--oneline"], text=True, timeout=2 ).strip() # Get main/master branch name try: - main_branch = subprocess.check_output( - ["git", "symbolic-ref", "refs/remotes/origin/HEAD"], - text=True, - timeout=2 - ).strip().split('/')[-1] - except: + main_branch = ( + subprocess.check_output( + ["git", "symbolic-ref", "refs/remotes/origin/HEAD"], text=True, timeout=2 + ) + .strip() + .split("/")[-1] + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): # Fallback: try to find main or master main_branch = "main" # Default assumption @@ -82,7 +73,7 @@ def get_git_status() -> Dict[str, any]: "main_branch": main_branch, "status": status if status else "Clean working directory", "recent_commits": recent_commits, - "has_uncommitted_changes": bool(status) + "has_uncommitted_changes": bool(status), } except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): @@ -109,24 +100,24 @@ def format_context_prompt() -> str: # Add git information if available if git_info.get("is_repo"): - lines.append(f"\nGit repository: Yes") + lines.append("\nGit repository: Yes") lines.append(f"Current branch: {git_info['branch']}") lines.append(f"Main branch: {git_info['main_branch']}") lines.append(f"Status: {git_info['status']}") if git_info.get("recent_commits"): - lines.append(f"\nRecent commits:") - for commit_line in git_info['recent_commits'].split('\n'): + lines.append("\nRecent commits:") + for commit_line in git_info["recent_commits"].split("\n"): lines.append(f" {commit_line}") else: - lines.append(f"\nGit repository: No") + lines.append("\nGit repository: No") lines.append("\n") return "\n".join(lines) -def get_context_dict() -> Dict[str, any]: +def get_context_dict() -> Dict[str, Any]: """Get context information as a dictionary. Returns: @@ -137,5 +128,5 @@ def get_context_dict() -> Dict[str, any]: "platform": get_platform_info(), "git": get_git_status(), "date": datetime.now().strftime("%Y-%m-%d"), - "timestamp": datetime.now().isoformat() + "timestamp": datetime.now().isoformat(), } diff --git a/agent/plan_execute_agent.py b/agent/plan_execute_agent.py index ce815fe..6a1b929 100644 --- a/agent/plan_execute_agent.py +++ b/agent/plan_execute_agent.py @@ -1,11 +1,13 @@ """Plan-and-Execute agent implementation.""" -from typing import List + import re +from typing import List from llm import LLMMessage +from utils import terminal_ui + from .base import BaseAgent from .context import format_context_prompt -from utils import terminal_ui class PlanExecuteAgent(BaseAgent): @@ -122,7 +124,9 @@ def run(self, task: str) -> str: for i, step in enumerate(steps, 1): terminal_ui.console.print() - terminal_ui.console.print(f"[bold magenta]▶ Step {i}/{len(steps)}:[/bold magenta] [white]{step}[/white]") + terminal_ui.console.print( + f"[bold magenta]▶ Step {i}/{len(steps)}:[/bold magenta] [white]{step}[/white]" + ) result = self._execute_step(step, i, step_results, task) step_results.append(f"Step {i}: {step}\nResult: {result}") terminal_ui.print_success(f"Step {i} completed") @@ -165,7 +169,7 @@ def _create_plan(self, task: str) -> str: messages = [ LLMMessage(role="system", content=system_content), - LLMMessage(role="user", content=self.PLANNER_PROMPT.format(task=task)) + LLMMessage(role="user", content=self.PLANNER_PROMPT.format(task=task)), ] response = self._call_llm(messages=messages) @@ -197,9 +201,7 @@ def _execute_step( messages = [ LLMMessage( role="user", - content=self.EXECUTOR_PROMPT.format( - step_num=step_num, step=step, history=history - ) + content=self.EXECUTOR_PROMPT.format(step_num=step_num, step=step, history=history), ) ] @@ -212,7 +214,7 @@ def _execute_step( max_iterations=self.max_iterations, # Limited iterations for each step use_memory=False, # Use local messages list, not global memory save_to_memory=False, # Don't auto-save to memory - verbose=False # Quieter output for sub-steps + verbose=False, # Quieter output for sub-steps ) # Save step result summary to main memory @@ -227,9 +229,7 @@ def _synthesize_results(self, task: str, results: List[str]) -> str: messages = [ LLMMessage( role="user", - content=self.SYNTHESIZER_PROMPT.format( - results="\n\n".join(results), task=task - ) + content=self.SYNTHESIZER_PROMPT.format(results="\n\n".join(results), task=task), ) ] response = self._call_llm(messages=messages) diff --git a/agent/react_agent.py b/agent/react_agent.py index 3bea999..41f2569 100644 --- a/agent/react_agent.py +++ b/agent/react_agent.py @@ -1,9 +1,10 @@ """ReAct (Reasoning + Acting) agent implementation.""" + from llm import LLMMessage +from utils import terminal_ui from .base import BaseAgent from .context import format_context_prompt -from utils import terminal_ui class ReActAgent(BaseAgent): @@ -138,7 +139,7 @@ def run(self, task: str) -> str: try: context = format_context_prompt() system_content = context + "\n" + system_content - except Exception as e: + except Exception: # If context gathering fails, continue without it pass @@ -157,7 +158,7 @@ def run(self, task: str) -> str: max_iterations=self.max_iterations, use_memory=True, save_to_memory=True, - verbose=True + verbose=True, ) self._print_memory_stats() diff --git a/agent/todo.py b/agent/todo.py index caee118..0fbab45 100644 --- a/agent/todo.py +++ b/agent/todo.py @@ -1,11 +1,13 @@ """Todo list management for agents to track complex multi-step tasks.""" + from dataclasses import dataclass from enum import Enum -from typing import List, Optional +from typing import Dict, List class TodoStatus(Enum): """Status of a todo item.""" + PENDING = "pending" IN_PROGRESS = "in_progress" COMPLETED = "completed" @@ -14,23 +16,20 @@ class TodoStatus(Enum): @dataclass class TodoItem: """A single todo item.""" + content: str # Imperative form: "Fix authentication bug" activeForm: str # Present continuous form: "Fixing authentication bug" status: TodoStatus def to_dict(self) -> dict: """Convert to dictionary for serialization.""" - return { - "content": self.content, - "activeForm": self.activeForm, - "status": self.status.value - } + return {"content": self.content, "activeForm": self.activeForm, "status": self.status.value} class TodoList: """Manages a list of todo items for an agent.""" - def __init__(self): + def __init__(self) -> None: self._items: List[TodoItem] = [] def add(self, content: str, activeForm: str) -> str: @@ -46,11 +45,7 @@ def add(self, content: str, activeForm: str) -> str: if not content or not activeForm: return "Error: Both content and activeForm are required" - item = TodoItem( - content=content, - activeForm=activeForm, - status=TodoStatus.PENDING - ) + item = TodoItem(content=content, activeForm=activeForm, status=TodoStatus.PENDING) self._items.append(item) return f"Added todo #{len(self._items)}: {content}" @@ -74,9 +69,15 @@ def update_status(self, index: int, status: str) -> str: # Check the ONE in_progress rule if new_status == TodoStatus.IN_PROGRESS: - in_progress_count = sum(1 for item in self._items if item.status == TodoStatus.IN_PROGRESS) + in_progress_count = sum( + 1 for item in self._items if item.status == TodoStatus.IN_PROGRESS + ) if in_progress_count > 0: - in_progress_items = [i+1 for i, item in enumerate(self._items) if item.status == TodoStatus.IN_PROGRESS] + in_progress_items = [ + i + 1 + for i, item in enumerate(self._items) + if item.status == TodoStatus.IN_PROGRESS + ] return f"Error: Task #{in_progress_items[0]} is already in_progress. Complete it first before starting another task." item = self._items[index - 1] @@ -114,7 +115,7 @@ def format_list(self) -> str: status_symbol = { TodoStatus.PENDING: "⏳", TodoStatus.IN_PROGRESS: "🔄", - TodoStatus.COMPLETED: "✅" + TodoStatus.COMPLETED: "✅", }[item.status] status_text = item.activeForm if item.status == TodoStatus.IN_PROGRESS else item.content @@ -125,17 +126,19 @@ def format_list(self) -> str: in_progress = sum(1 for item in self._items if item.status == TodoStatus.IN_PROGRESS) completed = sum(1 for item in self._items if item.status == TodoStatus.COMPLETED) - lines.append(f"\nSummary: {completed} completed, {in_progress} in progress, {pending} pending") + lines.append( + f"\nSummary: {completed} completed, {in_progress} in progress, {pending} pending" + ) return "\n".join(lines) - def get_summary(self) -> dict: + def get_summary(self) -> Dict[str, int]: """Get summary statistics.""" return { "total": len(self._items), "pending": sum(1 for item in self._items if item.status == TodoStatus.PENDING), "in_progress": sum(1 for item in self._items if item.status == TodoStatus.IN_PROGRESS), - "completed": sum(1 for item in self._items if item.status == TodoStatus.COMPLETED) + "completed": sum(1 for item in self._items if item.status == TodoStatus.COMPLETED), } def clear_completed(self) -> str: diff --git a/agent/tool_executor.py b/agent/tool_executor.py index a55607b..90ac38a 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -1,5 +1,6 @@ """Tool execution engine for managing and executing tools.""" -from typing import List, Dict, Any + +from typing import Any, Dict, List from tools.base import BaseTool diff --git a/cli.py b/cli.py index fb7997c..ac59aac 100644 --- a/cli.py +++ b/cli.py @@ -1,15 +1,18 @@ #!/usr/bin/env python3 """Command-line interface for AgenticLoop.""" -import sys import os +import sys # Add current directory to path to allow imports sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + def main(): """Main CLI entry point.""" from main import main as run_main + run_main() + if __name__ == "__main__": main() diff --git a/config.py b/config.py index d381ffc..e0ab242 100644 --- a/config.py +++ b/config.py @@ -1,5 +1,7 @@ """Configuration management for the agentic system.""" + import os + from dotenv import load_dotenv load_dotenv() @@ -9,8 +11,14 @@ class Config: """Configuration for the agentic system.""" # LiteLLM Model Configuration + # Format: provider/model_name (e.g. "anthropic/claude-3-5-sonnet-20241022") LITELLM_MODEL = os.getenv("LITELLM_MODEL", "anthropic/claude-3-5-sonnet-20241022") + # Common provider API keys (optional depending on provider) + ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY") + OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") + GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") + # Optional LiteLLM Configuration LITELLM_API_BASE = os.getenv("LITELLM_API_BASE") LITELLM_DROP_PARAMS = os.getenv("LITELLM_DROP_PARAMS", "true").lower() == "true" @@ -25,6 +33,7 @@ class Config: RETRY_MAX_DELAY = float(os.getenv("RETRY_MAX_DELAY", "60.0")) # Memory Management Configuration + MEMORY_ENABLED = os.getenv("MEMORY_ENABLED", "true").lower() == "true" MEMORY_MAX_CONTEXT_TOKENS = int(os.getenv("MEMORY_MAX_CONTEXT_TOKENS", "100000")) MEMORY_TARGET_TOKENS = int(os.getenv("MEMORY_TARGET_TOKENS", "50000")) MEMORY_COMPRESSION_THRESHOLD = int(os.getenv("MEMORY_COMPRESSION_THRESHOLD", "40000")) @@ -51,7 +60,7 @@ def get_retry_config(cls): initial_delay=cls.RETRY_INITIAL_DELAY, max_delay=cls.RETRY_MAX_DELAY, exponential_base=2.0, - jitter=True + jitter=True, ) @classmethod @@ -69,6 +78,7 @@ def get_memory_config(cls): compression_threshold=cls.MEMORY_COMPRESSION_THRESHOLD, short_term_message_count=cls.MEMORY_SHORT_TERM_SIZE, compression_ratio=cls.MEMORY_COMPRESSION_RATIO, + enable_compression=cls.MEMORY_ENABLED, ) @classmethod @@ -83,3 +93,12 @@ def validate(cls): "LITELLM_MODEL not set. Please set it in your .env file.\n" "Example: LITELLM_MODEL=anthropic/claude-3-5-sonnet-20241022" ) + + # Validate common providers (LiteLLM supports many; only enforce the ones we document). + provider = cls.LITELLM_MODEL.split("/", 1)[0].lower() if "/" in cls.LITELLM_MODEL else "" + if provider == "anthropic" and not cls.ANTHROPIC_API_KEY: + raise ValueError("ANTHROPIC_API_KEY not set. Please set it in your .env file.") + if provider == "openai" and not cls.OPENAI_API_KEY: + raise ValueError("OPENAI_API_KEY not set. Please set it in your .env file.") + if provider == "gemini" and not cls.GEMINI_API_KEY: + raise ValueError("GEMINI_API_KEY not set. Please set it in your .env file.") diff --git a/docs/advanced-features.md b/docs/advanced-features.md index 12a1359..11e1e4c 100644 --- a/docs/advanced-features.md +++ b/docs/advanced-features.md @@ -38,13 +38,12 @@ RetryConfig( For APIs with strict rate limits: ```python -from llm import create_llm, RetryConfig -from config import Config +from llm import LiteLLMLLM +from llm.retry import RetryConfig -llm = create_llm( - provider="gemini", +llm = LiteLLMLLM( + model="gemini/gemini-1.5-flash", api_key="your_key", - model="gemini-1.5-flash", retry_config=RetryConfig( max_retries=10, # More retries for free tier initial_delay=2.0, # Start with 2s wait @@ -160,14 +159,16 @@ See [Memory Management](memory-management.md) for complete documentation. Easy switching between LLM providers: ```python +from llm import LiteLLMLLM + # Anthropic -llm = create_llm("anthropic", api_key, "claude-3-5-sonnet-20241022") +llm = LiteLLMLLM(model="anthropic/claude-3-5-sonnet-20241022", api_key=api_key) # OpenAI -llm = create_llm("openai", api_key, "gpt-4o") +llm = LiteLLMLLM(model="openai/gpt-4o", api_key=api_key) # Gemini -llm = create_llm("gemini", api_key, "gemini-1.5-pro") +llm = LiteLLMLLM(model="gemini/gemini-1.5-pro", api_key=api_key) ``` ### Provider Comparison @@ -184,12 +185,12 @@ Use different providers for different tasks: ```python # Expensive model for planning -planning_llm = create_llm("anthropic", key, "claude-3-opus-20240229") +planning_llm = LiteLLMLLM(model="anthropic/claude-3-opus-20240229", api_key=key) planner = PlanExecuteAgent(llm=planning_llm) plan = planner._get_plan(task) # Cheap model for execution -execution_llm = create_llm("gemini", key, "gemini-1.5-flash") +execution_llm = LiteLLMLLM(model="gemini/gemini-1.5-flash", api_key=key) executor = ReActAgent(llm=execution_llm) results = [executor.run(step) for step in plan] ``` @@ -201,14 +202,8 @@ Support for proxies, Azure, and local deployments: ### Configuration ```bash -# Proxy -ANTHROPIC_BASE_URL=http://proxy.company.com/anthropic - -# Azure OpenAI -OPENAI_BASE_URL=https://your-resource.openai.azure.com - -# Local deployment -GEMINI_BASE_URL=http://localhost:8000/v1 +# Proxy / custom endpoint +LITELLM_API_BASE=http://proxy.company.com ``` ### Use Cases @@ -223,10 +218,10 @@ GEMINI_BASE_URL=http://localhost:8000/v1 ```bash # .env -LLM_PROVIDER=openai -OPENAI_API_KEY=your_azure_key -OPENAI_BASE_URL=https://your-resource.openai.azure.com -MODEL=gpt-4 +LITELLM_MODEL=azure/gpt-4 +AZURE_API_KEY=your_azure_key +AZURE_API_BASE=https://your-resource.openai.azure.com +AZURE_API_VERSION=2024-02-15-preview ``` ## Agent Mode Comparison @@ -353,11 +348,11 @@ Use different models by task complexity: ```python def get_optimal_llm(task_type: str): if task_type == "simple": - return create_llm("gemini", key, "gemini-1.5-flash") + return LiteLLMLLM(model="gemini/gemini-1.5-flash", api_key=key) elif task_type == "medium": - return create_llm("openai", key, "gpt-4o-mini") + return LiteLLMLLM(model="openai/gpt-4o-mini", api_key=key) else: - return create_llm("anthropic", key, "claude-3-5-sonnet-20241022") + return LiteLLMLLM(model="anthropic/claude-3-5-sonnet-20241022", api_key=key) ``` ### Strategy 2: Memory Compression @@ -406,13 +401,13 @@ Estimated costs for a 50-iteration task: ### Graceful Degradation ```python -from llm import create_llm +from llm import LiteLLMLLM try: - llm = create_llm("anthropic", api_key, "claude-3-5-sonnet-20241022") + llm = LiteLLMLLM(model="anthropic/claude-3-5-sonnet-20241022", api_key=api_key) except Exception as e: print(f"Failed to initialize Anthropic, falling back to OpenAI") - llm = create_llm("openai", api_key, "gpt-4o-mini") + llm = LiteLLMLLM(model="openai/gpt-4o-mini", api_key=api_key) ``` ### Timeout Handling @@ -437,16 +432,16 @@ finally: **Strategy 1**: Automatic retry (built-in) ```python -llm = create_llm(provider, key, model) # Auto-retry enabled +llm = LiteLLMLLM(model=f"{provider}/{model}", api_key=key) # Auto-retry enabled ``` **Strategy 2**: Provider fallback ```python try: - llm = create_llm("anthropic", key1, model1) + llm = LiteLLMLLM(model=f"anthropic/{model1}", api_key=key1) result = agent.run(task) except RateLimitError: - llm = create_llm("openai", key2, model2) + llm = LiteLLMLLM(model=f"openai/{model2}", api_key=key2) result = agent.run(task) ``` @@ -527,12 +522,12 @@ class MonitoredAgent(ReActAgent): ```python # 1. Use Gemini Flash for initial search (cheap) -search_llm = create_llm("gemini", key, "gemini-1.5-flash") +search_llm = LiteLLMLLM(model="gemini/gemini-1.5-flash", api_key=key) searcher = ReActAgent(llm=search_llm, max_iterations=5) raw_data = searcher.run("Search for X") # 2. Use Claude Sonnet for analysis (quality) -analysis_llm = create_llm("anthropic", key, "claude-3-5-sonnet-20241022") +analysis_llm = LiteLLMLLM(model="anthropic/claude-3-5-sonnet-20241022", api_key=key) analyzer = PlanExecuteAgent(llm=analysis_llm) final_report = analyzer.run(f"Analyze this data: {raw_data}") ``` @@ -558,7 +553,7 @@ for i, task in enumerate(tasks): ```python # Try with cheap model first try: - cheap_llm = create_llm("gemini", key, "gemini-1.5-flash") + cheap_llm = LiteLLMLLM(model="gemini/gemini-1.5-flash", api_key=key) agent = ReActAgent(llm=cheap_llm, max_iterations=5) result = agent.run(task) @@ -568,7 +563,7 @@ try: except (ValueError, Exception): print("Retrying with better model...") - better_llm = create_llm("anthropic", key, "claude-3-5-sonnet-20241022") + better_llm = LiteLLMLLM(model="anthropic/claude-3-5-sonnet-20241022", api_key=key) agent = ReActAgent(llm=better_llm, max_iterations=10) result = agent.run(task) ``` diff --git a/docs/configuration.md b/docs/configuration.md index f8b6c71..cf09236 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,415 +1,108 @@ # Configuration Guide -This guide covers all configuration options for the Agentic Loop system. +This repo uses a **single configuration surface** via `.env` and `config.py`. ## Environment Variables -All configuration is done via the `.env` file. Create it from the template: +Create `.env` from the template (Python 3.12+ required for development): ```bash cp .env.example .env ``` -## LLM Provider Configuration +## LLM Configuration (Recommended: LiteLLM) -### Required Settings +### Required ```bash -# Choose your LLM provider -LLM_PROVIDER=anthropic # Options: anthropic, openai, gemini +# Format: provider/model_name +LITELLM_MODEL=anthropic/claude-3-5-sonnet-20241022 -# Set the corresponding API key +# Set the key for your chosen provider ANTHROPIC_API_KEY=your_key_here -# OPENAI_API_KEY=your_key_here -# GEMINI_API_KEY=your_key_here +OPENAI_API_KEY= +GEMINI_API_KEY= ``` -### Model Selection +LiteLLM auto-detects which key is needed based on the `LITELLM_MODEL` prefix. + +### Model Examples ```bash -# Optional: Specify a model (uses provider defaults if not set) -MODEL= - -# Anthropic models: -# - claude-3-5-sonnet-20241022 (default, balanced) -# - claude-3-5-haiku-20241022 (faster, cheaper) -# - claude-3-opus-20240229 (most capable, expensive) - -# OpenAI models: -# - gpt-4o (default, recommended) -# - gpt-4o-mini (cheaper, faster) -# - gpt-4-turbo (legacy) -# - gpt-3.5-turbo (cheapest) - -# Google Gemini models: -# - gemini-1.5-pro (default, most capable) -# - gemini-1.5-flash (faster, cheaper) -# - gemini-2.0-flash-exp (experimental) -``` +# Anthropic +LITELLM_MODEL=anthropic/claude-3-5-sonnet-20241022 -### Default Models by Provider +# OpenAI +LITELLM_MODEL=openai/gpt-4o -If `MODEL` is not specified, these defaults are used: +# Gemini +LITELLM_MODEL=gemini/gemini-1.5-pro +``` -| Provider | Default Model | -|----------|--------------| -| Anthropic | `claude-3-5-sonnet-20241022` | -| OpenAI | `gpt-4o` | -| Gemini | `gemini-1.5-pro` | +For the full list of providers/models, see: https://docs.litellm.ai/docs/providers -### Base URL Configuration +### Base URL (Optional) -For custom API endpoints (proxies, Azure, local deployments): +Use this for proxies or custom endpoints: ```bash -# Optional: Custom API endpoints -ANTHROPIC_BASE_URL= # e.g., https://api.anthropic.com -OPENAI_BASE_URL= # e.g., https://api.openai.com/v1 -GEMINI_BASE_URL= # e.g., https://generativelanguage.googleapis.com +LITELLM_API_BASE= +``` -# Examples: -# Azure OpenAI: -# OPENAI_BASE_URL=https://your-resource.openai.azure.com +### LiteLLM Behavior (Optional) -# Local proxy: -# ANTHROPIC_BASE_URL=http://localhost:8080/v1 +```bash +LITELLM_DROP_PARAMS=true +LITELLM_TIMEOUT=600 ``` -## Agent Configuration +### Legacy (Compatibility) + +This repo does not support legacy `LLM_PROVIDER` / `MODEL` configuration. Use `LITELLM_MODEL`. -### Basic Settings +## Agent Configuration ```bash -# Maximum iterations for agent loops (default: 100) MAX_ITERATIONS=100 - -# Increase for very complex tasks -# MAX_ITERATIONS=200 - -# Decrease for simple tasks to save costs -# MAX_ITERATIONS=50 ``` -All tools (including shell commands) are enabled by default. Tools available: -- File operations (read, write, search) -- Advanced file tools (glob, grep, edit) -- Calculator and Python code execution -- Web search -- Shell command execution -- Sub-agent delegation - -## Memory Management Configuration - -### Basic Memory Settings +## Memory Configuration ```bash -# Maximum total context size in tokens (default: 100000) +MEMORY_ENABLED=true MEMORY_MAX_CONTEXT_TOKENS=100000 - -# Target size after compression (default: 30000) MEMORY_TARGET_TOKENS=30000 - -# Trigger compression when context exceeds this (default: 25000) MEMORY_COMPRESSION_THRESHOLD=25000 -``` - -### Advanced Memory Settings - -```bash -# Number of recent messages to keep in short-term memory (default: 100) MEMORY_SHORT_TERM_SIZE=100 - -# Target compression ratio (0.3 = compress to 30% of original size) MEMORY_COMPRESSION_RATIO=0.3 ``` -Memory management is always enabled. The system automatically: -- Preserves system prompts during compression -- Keeps tool call pairs (tool_use + tool_result) together -- Protects stateful tools (like todo list) from compression -- Selects compression strategy based on message content - -### Memory Compression Strategies - -**sliding_window** (default): -- Summarizes all old messages into a compact summary -- Best for: Long conversations where context is important -- Token savings: 60-70% - -**selective**: -- Preserves important messages, compresses others -- Best for: Tasks with critical intermediate results -- Token savings: 40-50% - -**deletion**: -- Simply deletes old messages -- Best for: Tasks where old context isn't needed -- Token savings: 100% (aggressive) - -### Cost Optimization Settings - -For minimizing API costs: - -```bash -# Use cheaper models -MODEL=gpt-4o-mini # or gemini-1.5-flash, claude-3-5-haiku - -# Enable aggressive memory compression -MEMORY_COMPRESSION_THRESHOLD=30000 # Compress earlier -MEMORY_COMPRESSION_RATIO=0.2 # More aggressive compression - -# Reduce max iterations -MAX_ITERATIONS=8 -``` - ## Retry Configuration -### Rate Limit Handling - -Configure retry behavior via environment variables: - -```bash -# Retry configuration for rate limits (defaults shown) -RETRY_MAX_ATTEMPTS=5 # Maximum retry attempts -RETRY_INITIAL_DELAY=1.0 # Initial delay in seconds -RETRY_MAX_DELAY=60.0 # Maximum delay in seconds -``` - -The system automatically: -- Uses exponential backoff (doubles delay each retry) -- Adds jitter (randomness) to avoid thundering herd -- Retries on 429 (rate limit) and 5xx errors -- Respects provider-specific retry headers - -### Custom Retry Configuration - -To customize retry behavior programmatically: - -```python -from llm import create_llm, RetryConfig -from config import Config - -llm = create_llm( - provider=Config.LLM_PROVIDER, - api_key=Config.get_api_key(), - model=Config.get_default_model(), - retry_config=RetryConfig( - max_retries=10, # More retries for free tier APIs - initial_delay=2.0, # Start with 2 seconds - max_delay=120.0, # Cap at 2 minutes - exponential_base=2.0, # Doubling backoff - jitter=True # Add randomness to avoid thundering herd - ) -) -``` - -## Provider-Specific Configuration - -### Anthropic Claude - -```bash -LLM_PROVIDER=anthropic -ANTHROPIC_API_KEY=sk-ant-xxxxx -MODEL=claude-3-5-sonnet-20241022 - -# Optional: -ANTHROPIC_BASE_URL=https://api.anthropic.com -``` - -**Rate Limits** (as of Jan 2025): -- Free tier: 50 requests/day -- Tier 1: 50 requests/minute, 40K tokens/minute -- Tier 2+: Higher limits - -### OpenAI GPT - -```bash -LLM_PROVIDER=openai -OPENAI_API_KEY=sk-xxxxx -MODEL=gpt-4o - -# Optional: -OPENAI_BASE_URL=https://api.openai.com/v1 -``` - -**Rate Limits** (as of Jan 2025): -- Free tier: Limited quota -- Tier 1: 500 requests/day, 40K tokens/minute -- Tier 3+: Higher limits - -### Google Gemini - -```bash -LLM_PROVIDER=gemini -GEMINI_API_KEY=xxxxx -MODEL=gemini-1.5-flash - -# Optional: -GEMINI_BASE_URL=https://generativelanguage.googleapis.com -``` - -**Rate Limits** (as of Jan 2025): -- Free tier: 15 requests/minute, 1M tokens/minute -- Paid tier: 360 requests/minute, higher token limits - -## Configuration Presets - -### Preset 1: High Performance - -For maximum capability, cost is secondary: - -```bash -LLM_PROVIDER=anthropic -MODEL=claude-3-opus-20240229 -MAX_ITERATIONS=20 -MEMORY_MAX_CONTEXT_TOKENS=200000 -``` - -### Preset 2: Balanced (Recommended) - -Good balance of performance and cost: - -```bash -LLM_PROVIDER=anthropic -MODEL=claude-3-5-sonnet-20241022 -MAX_ITERATIONS=10 -MEMORY_MAX_CONTEXT_TOKENS=100000 -MEMORY_COMPRESSION_THRESHOLD=40000 -``` - -### Preset 3: Cost-Optimized - -Minimize costs while maintaining functionality: - ```bash -LLM_PROVIDER=openai -MODEL=gpt-4o-mini -MAX_ITERATIONS=8 -MEMORY_COMPRESSION_THRESHOLD=30000 -MEMORY_COMPRESSION_RATIO=0.2 -``` - -### Preset 4: Free Tier Friendly - -For APIs with strict rate limits: - -```bash -LLM_PROVIDER=gemini -MODEL=gemini-1.5-flash -MAX_ITERATIONS=5 -MEMORY_COMPRESSION_THRESHOLD=20000 +RETRY_MAX_ATTEMPTS=3 +RETRY_INITIAL_DELAY=1.0 +RETRY_MAX_DELAY=60.0 ``` ## Validation -### Check Your Configuration - ```bash -# Verify .env file exists -ls -la .env - -# Check which provider is configured -grep LLM_PROVIDER .env - -# Verify API key is set (without revealing it) -grep API_KEY .env | sed 's/=.*/=***/' -``` - -### Test Configuration - -```bash -# Run a simple test +# Sanity check (requires correct API key for your model/provider) python main.py --task "Calculate 1+1" -# If successful, you'll see the agent execute and return "2" -``` - -### Common Configuration Errors - -**Error**: `No API key found for provider X` -- **Solution**: Set the correct API key in `.env` - -**Error**: `Invalid model name` -- **Solution**: Check supported models for your provider - -**Error**: `Rate limit exceeded` -- **Solution**: Adjust retry configuration or use a different tier - -**Error**: `Memory compression failed` -- **Solution**: Reduce `MEMORY_COMPRESSION_THRESHOLD` or disable memory - -## Environment-Specific Configurations - -### Development - -```bash -# .env.development -LLM_PROVIDER=gemini -MODEL=gemini-1.5-flash # Cheap and fast for testing -MAX_ITERATIONS=5 +# Run tests +python -m pytest test/ ``` -### Production +Integration tests that call a live LLM are skipped by default: ```bash -# .env.production -LLM_PROVIDER=anthropic -MODEL=claude-3-5-sonnet-20241022 # Reliable and capable -MAX_ITERATIONS=10 -``` - -### CI/CD - -```bash -# .env.ci -LLM_PROVIDER=openai -MODEL=gpt-4o-mini # Fast and cheap for tests -MAX_ITERATIONS=5 +RUN_INTEGRATION_TESTS=1 python -m pytest -m integration ``` ## Security Best Practices -1. **Never commit `.env`**: It's in `.gitignore` by default -2. **Use environment-specific configs**: Different settings for dev/prod -3. **Rotate API keys**: Regularly update your API keys -4. **Review shell commands**: The agent can execute shell commands - monitor logs -5. **Use base URLs cautiously**: Verify custom endpoints are trusted -6. **Limit iterations**: Set `MAX_ITERATIONS` appropriately to avoid runaway costs - -## Troubleshooting - -### Configuration Not Loading - -Check that: -1. `.env` file is in the project root -2. File has correct permissions: `chmod 600 .env` -3. No syntax errors in `.env` (no spaces around `=`) - -### Changes Not Taking Effect - -```bash -# Ensure you're not using cached values -# Restart your Python session/script - -# Force reload -rm -rf __pycache__/ -python main.py --task "Your task" -``` - -## Logging Configuration - -```bash -# Logging settings (defaults shown) -LOG_DIR=logs # Directory for log files -LOG_LEVEL=DEBUG # DEBUG, INFO, WARNING, ERROR, CRITICAL -LOG_TO_FILE=true # Save logs to file -LOG_TO_CONSOLE=false # Print to console (WARNING+ always shown) -``` - -Log files are created in the `logs/` directory with timestamps: `agentic_loop_YYYYMMDD_HHMMSS.log` - -## Next Steps - -- See [Examples](examples.md) for usage patterns -- See [Memory Management](memory-management.md) for detailed memory configuration -- See [Advanced Features](advanced-features.md) for optimization techniques +1. Never commit `.env` or API keys. +2. Treat publishing as a manual step (see `docs/packaging.md`). +3. Keep `MAX_ITERATIONS` low when experimenting to avoid runaway cost. diff --git a/docs/examples.md b/docs/examples.md index e245fe0..df35290 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -92,9 +92,8 @@ What would you like me to help you with? ```bash # Set in .env: -LLM_PROVIDER=openai OPENAI_API_KEY=your_key_here -MODEL=gpt-4o +LITELLM_MODEL=openai/gpt-4o # Run: python main.py --task "Your task here" @@ -104,9 +103,8 @@ python main.py --task "Your task here" ```bash # Set in .env: -LLM_PROVIDER=gemini GEMINI_API_KEY=your_key_here -MODEL=gemini-1.5-flash +LITELLM_MODEL=gemini/gemini-1.5-flash # Run: python main.py --task "Your task here" @@ -116,9 +114,8 @@ python main.py --task "Your task here" ```bash # Set in .env: -LLM_PROVIDER=anthropic ANTHROPIC_API_KEY=your_key_here -MODEL=claude-3-5-sonnet-20241022 +LITELLM_MODEL=anthropic/claude-3-5-sonnet-20241022 # Run: python main.py --task "Your task here" @@ -129,19 +126,19 @@ python main.py --task "Your task here" ### Listing Files ```bash -python main.py --enable-shell --task "List all Python files in the current directory" +python main.py --task "List all Python files in the current directory" ``` ### Git Operations ```bash -python main.py --enable-shell --task "Check git status and show recent commits" +python main.py --task "Check git status and show recent commits" ``` ### System Information ```bash -python main.py --enable-shell --task "Show disk usage and available space" +python main.py --task "Show disk usage and available space" ``` ## Comparing ReAct vs Plan-Execute @@ -190,15 +187,10 @@ The system will automatically retry with exponential backoff. If a task requires a tool that's not available: ```bash -# Without --enable-shell, this will fail gracefully: +# Example: python main.py --task "Run ls command" ``` -Output: -``` -Error: Shell tool is not enabled. Use --enable-shell to enable it. -``` - ## Memory Management Examples For long-running tasks with many iterations: @@ -229,19 +221,18 @@ See [Memory Management](memory-management.md) for more details. ```python from agent.react_agent import ReActAgent -from llm import create_llm +from llm import LiteLLMLLM +from llm.retry import RetryConfig from tools import CalculatorTool, FileReadTool from config import Config # Create custom LLM with retry config -llm = create_llm( - provider="anthropic", - api_key=Config.ANTHROPIC_API_KEY, - model="claude-3-5-sonnet-20241022", - retry_config=RetryConfig( - max_retries=10, - initial_delay=2.0 - ) +llm = LiteLLMLLM( + model=Config.LITELLM_MODEL, + api_base=Config.LITELLM_API_BASE, + drop_params=Config.LITELLM_DROP_PARAMS, + timeout=Config.LITELLM_TIMEOUT, + retry_config=RetryConfig(max_retries=10, initial_delay=2.0, max_delay=60.0), ) # Create agent with specific tools @@ -315,7 +306,7 @@ MEMORY_ENABLED=true MEMORY_COMPRESSION_THRESHOLD=40000 # Use more efficient models: -MODEL=gpt-4o-mini # or gemini-1.5-flash, claude-3-5-haiku +LITELLM_MODEL=openai/gpt-4o-mini # or gemini/gemini-1.5-flash, anthropic/claude-3-5-haiku-20241022 ``` ### API Errors diff --git a/docs/extending.md b/docs/extending.md index 29de074..ef17944 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -406,40 +406,14 @@ class MyProviderLLM(BaseLLM): ### 2. Update Configuration -Add provider configuration to `config.py`: +This repo is configured via LiteLLM (`LITELLM_MODEL` in `.env`). For most providers, **no code changes** are required: -```python -# config.py -MY_PROVIDER_API_KEY = os.getenv("MY_PROVIDER_API_KEY") -MY_PROVIDER_BASE_URL = os.getenv("MY_PROVIDER_BASE_URL") - -SUPPORTED_PROVIDERS = ["anthropic", "openai", "gemini", "my_provider"] - -@classmethod -def get_api_key(cls) -> str: - if cls.LLM_PROVIDER == "my_provider": - return cls.MY_PROVIDER_API_KEY - # ... existing providers - -@classmethod -def get_default_model(cls) -> str: - if cls.LLM_PROVIDER == "my_provider": - return os.getenv("MODEL", "default-model-v1") - # ... existing providers +```bash +LITELLM_MODEL=my_provider/my-model +MY_PROVIDER_API_KEY=... ``` -### 3. Register in Factory - -Update `llm/__init__.py`: - -```python -from .my_provider_llm import MyProviderLLM - -def create_llm(provider: str, api_key: str, model: str, **kwargs) -> BaseLLM: - if provider == "my_provider": - return MyProviderLLM(api_key, model, **kwargs) - # ... existing providers -``` +If a provider is not supported by LiteLLM, implement a custom `BaseLLM` adapter under `llm/` and instantiate it directly in your app code (avoid adding more branching to `config.py`). ### 4. Update .env.example @@ -479,14 +453,16 @@ if __name__ == "__main__": ```python # test_my_agent.py from agent.my_custom_agent import MyCustomAgent -from llm import create_llm +from llm import LiteLLMLLM from config import Config def test_my_agent(): - llm = create_llm( - provider=Config.LLM_PROVIDER, - api_key=Config.get_api_key(), - model=Config.get_default_model() + llm = LiteLLMLLM( + model=Config.LITELLM_MODEL, + api_base=Config.LITELLM_API_BASE, + drop_params=Config.LITELLM_DROP_PARAMS, + timeout=Config.LITELLM_TIMEOUT, + retry_config=Config.get_retry_config(), ) agent = MyCustomAgent(llm=llm) diff --git a/docs/packaging.md b/docs/packaging.md index edd80ec..24103b3 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -6,18 +6,19 @@ This guide explains how to package and distribute AgenticLoop. ### Install for Development -```bash -# Install in editable mode -./scripts/install_local.sh +Prerequisites: Python 3.12+ and `uv` (https://github.com/astral-sh/uv). -# Or manually -pip install -e . +```bash +# Bootstrap dev environment (creates .venv, installs deps) +./scripts/bootstrap.sh ``` -Now you can use it globally: +Note: development workflow requires `uv` (https://github.com/astral-sh/uv). + +Now you can use it (after activating `.venv` or via `./.venv/bin/aloop`): ```bash aloop --help -aloop interactive +aloop ``` ## 🚀 Publishing to PyPI @@ -25,7 +26,7 @@ aloop interactive ### 1. Build the Package ```bash -./scripts/build.sh +./scripts/dev.sh build ``` This creates distribution files in `dist/`: @@ -39,7 +40,7 @@ This creates distribution files in `dist/`: pip install dist/agentic_loop-0.1.0-py3-none-any.whl # Test it -AgenticLoop interactive +aloop ``` ### 3. Publish to Test PyPI (Recommended First) @@ -49,7 +50,7 @@ AgenticLoop interactive # Get API token from account settings # Upload to test PyPI -twine upload --repository testpypi dist/* +./scripts/dev.sh publish --test # Install from test PyPI pip install --index-url https://test.pypi.org/simple/ AgenticLoop @@ -62,7 +63,7 @@ pip install --index-url https://test.pypi.org/simple/ AgenticLoop # Get API token # Upload -./scripts/publish.sh +./scripts/dev.sh publish # Now anyone can install pip install AgenticLoop @@ -83,12 +84,12 @@ docker build -t AgenticLoop:latest . docker run -it --rm \ -e ANTHROPIC_API_KEY=your_key \ -v $(pwd)/data:/app/data \ - AgenticLoop interactive + AgenticLoop --mode react # Single task docker run --rm \ -e ANTHROPIC_API_KEY=your_key \ - AgenticLoop --mode react "Analyze this code" + AgenticLoop --mode react --task "Analyze this code" ``` ### Publish Docker Image @@ -134,10 +135,10 @@ Before publishing a new version: - [ ] Update version in `pyproject.toml` - [ ] Update CHANGELOG.md - [ ] Run tests: `pytest` -- [ ] Build package: `./scripts/build.sh` +- [ ] Build package: `./scripts/dev.sh build` - [ ] Test locally: `pip install dist/*.whl` - [ ] Create git tag: `git tag v0.1.0 && git push --tags` -- [ ] Publish to PyPI: `./scripts/publish.sh` +- [ ] Publish to PyPI: `./scripts/dev.sh publish` - [ ] Create GitHub release with changelog ## 🔧 Troubleshooting @@ -169,15 +170,15 @@ pip install AgenticLoop -c constraints.txt | Method | Command | Use Case | |--------|---------|----------| -| **Local Dev** | `pip install -e .` | Development, testing | +| **Local Dev** | `./scripts/bootstrap.sh` | Development, testing | | **PyPI** | `pip install AgenticLoop` | Public distribution | -| **Docker** | `docker run AgenticLoop` | Containerized deployment | +| **Docker** | `docker run AgenticLoop --mode react --task "..."` | Containerized deployment | | **Executable** | `./AgenticLoop` | Non-Python users | | **GitHub** | `pip install git+https://github.com/user/repo` | Direct from source | ## 🎯 Recommended Workflow -1. **Development**: Use `pip install -e .` -2. **Testing**: Build and test with `./scripts/build.sh` +1. **Development**: Use `./scripts/bootstrap.sh` +2. **Testing**: Build and test with `./scripts/dev.sh build` 3. **Distribution**: Publish to PyPI 4. **Users**: Install with `pip install AgenticLoop` diff --git a/examples/memory_persistence_demo.py b/examples/memory_persistence_demo.py index f7cab84..aa5ada7 100644 --- a/examples/memory_persistence_demo.py +++ b/examples/memory_persistence_demo.py @@ -1,12 +1,13 @@ """Demo: Using memory persistence with sessions.""" + import sys from pathlib import Path # Add parent directory to path sys.path.insert(0, str(Path(__file__).parent.parent)) -from memory import MemoryConfig, MemoryManager from llm.base import LLMMessage +from memory import MemoryConfig, MemoryManager class MockLLM: @@ -37,16 +38,9 @@ def demo_create_session(): llm = MockLLM() # Create manager (persistence is automatic) - config = MemoryConfig( - short_term_message_count=5, - target_working_memory_tokens=100 - ) + config = MemoryConfig(short_term_message_count=5, target_working_memory_tokens=100) - manager = MemoryManager( - config=config, - llm=llm, - db_path="data/demo_memory.db" - ) + manager = MemoryManager(config=config, llm=llm, db_path="data/demo_memory.db") session_id = manager.session_id print(f"\n✅ Created session: {session_id}") @@ -68,11 +62,11 @@ def demo_create_session(): # Save memory state (normally done automatically after agent.run()) manager.save_memory() - print(f"\n💾 Saved memory to database") + print("\n💾 Saved memory to database") # Get stats stats = manager.store.get_session_stats(session_id) - print(f"\n📊 Session Stats:") + print("\n📊 Session Stats:") print(f" Messages: {stats['message_count']}") print(f" System Messages: {stats['system_message_count']}") print(f" Compressions: {stats['compression_count']}") @@ -91,19 +85,17 @@ def demo_load_session(session_id: str): # Load session manager = MemoryManager.from_session( - session_id=session_id, - llm=llm, - db_path="data/demo_memory.db" + session_id=session_id, llm=llm, db_path="data/demo_memory.db" ) - print(f"\n✅ Loaded session with:") + print("\n✅ Loaded session with:") print(f" {len(manager.system_messages)} system messages") print(f" {manager.short_term.count()} messages in short-term") print(f" {len(manager.summaries)} summaries") print(f" {manager.current_tokens} current tokens") # Add more messages - print(f"\n📝 Adding more messages...") + print("\n📝 Adding more messages...") new_messages = [ LLMMessage(role="user", content="What about Spain?"), LLMMessage(role="assistant", content="The capital of Spain is Madrid."), @@ -115,11 +107,11 @@ def demo_load_session(session_id: str): # Save memory state manager.save_memory() - print(f"\n💾 Saved memory to database") + print("\n💾 Saved memory to database") # Get updated stats stats = manager.store.get_session_stats(session_id) - print(f"\n📊 Updated Stats:") + print("\n📊 Updated Stats:") print(f" Messages: {stats['message_count']}") print(f" Compressions: {stats['compression_count']}") @@ -132,6 +124,7 @@ def demo_list_sessions(): # Create a temporary manager to access the store from memory.store import MemoryStore + store = MemoryStore(db_path="data/demo_memory.db") sessions = store.list_sessions(limit=10) @@ -160,7 +153,7 @@ def main(): print("\n" + "=" * 80) print("✅ Demo complete!") print("\nTo view sessions, run:") - print(f" python tools/session_manager.py list --db data/demo_memory.db") + print(" python tools/session_manager.py list --db data/demo_memory.db") print(f" python tools/session_manager.py show {session_id} --db data/demo_memory.db") print("=" * 80 + "\n") diff --git a/examples/plan_execute_example.py b/examples/plan_execute_example.py index 08681fa..2a315b3 100644 --- a/examples/plan_execute_example.py +++ b/examples/plan_execute_example.py @@ -1,14 +1,16 @@ """Example usage of Plan-and-Execute Agent.""" -import sys + import os +import sys # Add parent directory to path to import modules sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from config import Config from agent.plan_execute_agent import PlanExecuteAgent +from config import Config +from llm import LiteLLMLLM from tools.calculator import CalculatorTool -from tools.file_ops import FileReadTool, FileWriteTool, FileSearchTool +from tools.file_ops import FileReadTool, FileSearchTool, FileWriteTool from tools.web_search import WebSearchTool @@ -23,14 +25,20 @@ def main(): Config.validate() except ValueError as e: print(f"Error: {e}") - print("Please set ANTHROPIC_API_KEY in your .env file") + print("Please configure your .env file (see .env.example)") return + llm = LiteLLMLLM( + model=Config.LITELLM_MODEL, + api_base=Config.LITELLM_API_BASE, + retry_config=Config.get_retry_config(), + drop_params=Config.LITELLM_DROP_PARAMS, + timeout=Config.LITELLM_TIMEOUT, + ) + # Initialize agent with tools agent = PlanExecuteAgent( - api_key=Config.ANTHROPIC_API_KEY, - model=Config.MODEL, - max_iterations=10, + llm=llm, tools=[ CalculatorTool(), FileReadTool(), @@ -38,6 +46,7 @@ def main(): FileSearchTool(), WebSearchTool(), ], + max_iterations=10, ) # Example 1: Multi-step calculation and file writing diff --git a/examples/quick_start_persistence.py b/examples/quick_start_persistence.py index c1f06c7..f11555a 100644 --- a/examples/quick_start_persistence.py +++ b/examples/quick_start_persistence.py @@ -5,8 +5,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) -from memory import MemoryConfig, MemoryManager from llm.base import LLMMessage +from memory import MemoryConfig, MemoryManager # Mock LLM for demo (replace with real LLM in production) @@ -31,35 +31,27 @@ def main(): # Option 1: Create new session (persistence is automatic) print("\n1️⃣ Creating new session (automatically persisted)...") - manager = MemoryManager( - config=MemoryConfig(), - llm=llm, - db_path="data/my_app.db" - ) + manager = MemoryManager(config=MemoryConfig(), llm=llm, db_path="data/my_app.db") session_id = manager.session_id print(f" Session ID: {session_id}") # Add messages manager.add_message(LLMMessage(role="user", content="Hello!")) manager.add_message(LLMMessage(role="assistant", content="Hi there!")) - print(f" ✓ Added 2 messages") + print(" ✓ Added 2 messages") # Save memory state (normally done automatically after agent.run()) manager.save_memory() - print(f" ✓ Saved to database") + print(" ✓ Saved to database") # Option 2: Load existing session print(f"\n2️⃣ Loading session {session_id[:8]}...") - manager2 = MemoryManager.from_session( - session_id=session_id, - llm=llm, - db_path="data/my_app.db" - ) + manager2 = MemoryManager.from_session(session_id=session_id, llm=llm, db_path="data/my_app.db") print(f" ✓ Loaded {manager2.short_term.count()} messages") # Continue conversation manager2.add_message(LLMMessage(role="user", content="How are you?")) - print(f" ✓ Continued conversation") + print(" ✓ Continued conversation") # View sessions print("\n3️⃣ Viewing all sessions...") @@ -68,7 +60,7 @@ def main(): print(f" • {s['id'][:8]}... - {s['message_count']} messages") print("\n✅ Done! View sessions with:") - print(f" python tools/session_manager.py list --db data/my_app.db") + print(" python tools/session_manager.py list --db data/my_app.db") print(f" python tools/session_manager.py show {session_id} --db data/my_app.db\n") diff --git a/examples/react_example.py b/examples/react_example.py index de3490f..626f284 100644 --- a/examples/react_example.py +++ b/examples/react_example.py @@ -1,12 +1,14 @@ """Example usage of ReAct Agent.""" -import sys + import os +import sys # Add parent directory to path to import modules sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from config import Config from agent.react_agent import ReActAgent +from config import Config +from llm import LiteLLMLLM from tools.calculator import CalculatorTool from tools.file_ops import FileReadTool, FileWriteTool from tools.web_search import WebSearchTool @@ -23,20 +25,27 @@ def main(): Config.validate() except ValueError as e: print(f"Error: {e}") - print("Please set ANTHROPIC_API_KEY in your .env file") + print("Please configure your .env file (see .env.example)") return + llm = LiteLLMLLM( + model=Config.LITELLM_MODEL, + api_base=Config.LITELLM_API_BASE, + retry_config=Config.get_retry_config(), + drop_params=Config.LITELLM_DROP_PARAMS, + timeout=Config.LITELLM_TIMEOUT, + ) + # Initialize agent with tools agent = ReActAgent( - api_key=Config.ANTHROPIC_API_KEY, - model=Config.MODEL, - max_iterations=10, + llm=llm, tools=[ CalculatorTool(), FileReadTool(), FileWriteTool(), WebSearchTool(), ], + max_iterations=10, ) # Example 1: Simple calculation @@ -54,9 +63,7 @@ def main(): # Example 3: Web search print("\n\n--- Example 3: Web Search ---") - result3 = agent.run( - "Search for 'Python agentic frameworks' and tell me the top 3 results" - ) + result3 = agent.run("Search for 'Python agentic frameworks' and tell me the top 3 results") print(f"\nResult: {result3}") print("\n" + "=" * 60) diff --git a/examples/web_fetch_example.py b/examples/web_fetch_example.py index 2ea5341..df0b7ed 100644 --- a/examples/web_fetch_example.py +++ b/examples/web_fetch_example.py @@ -1,12 +1,13 @@ """Example usage of WebFetchTool with ReAct Agent.""" + import os import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from config import Config from agent.react_agent import ReActAgent -from llm import create_llm +from config import Config +from llm import LiteLLMLLM from tools.web_fetch import WebFetchTool @@ -23,12 +24,12 @@ def main(): print("Please set your API key in the .env file") return - llm = create_llm( - provider=Config.LLM_PROVIDER, - api_key=Config.get_api_key(), - model=Config.get_default_model(), + llm = LiteLLMLLM( + model=Config.LITELLM_MODEL, + api_base=Config.LITELLM_API_BASE, retry_config=Config.get_retry_config(), - base_url=Config.get_base_url(), + drop_params=Config.LITELLM_DROP_PARAMS, + timeout=Config.LITELLM_TIMEOUT, ) agent = ReActAgent( diff --git a/interactive.py b/interactive.py index c766591..66a26d2 100644 --- a/interactive.py +++ b/interactive.py @@ -1,13 +1,15 @@ """Interactive multi-turn conversation mode for the agent.""" + import json from datetime import datetime from pathlib import Path + from prompt_toolkit import prompt from prompt_toolkit.styles import Style from config import Config -from utils import get_log_file_path, terminal_ui from memory.store import MemoryStore +from utils import get_log_file_path, terminal_ui def run_interactive_mode(agent, mode: str): @@ -19,35 +21,38 @@ def run_interactive_mode(agent, mode: str): """ terminal_ui.print_header( "🤖 Agentic Loop System - Interactive Mode", - subtitle="Multi-turn conversation with AI Agent" + subtitle="Multi-turn conversation with AI Agent", ) # Display configuration config_dict = { - "LLM Provider": Config.LITELLM_MODEL.split('/')[0].upper() if '/' in Config.LITELLM_MODEL else "UNKNOWN", + "LLM Provider": ( + Config.LITELLM_MODEL.split("/")[0].upper() if "/" in Config.LITELLM_MODEL else "UNKNOWN" + ), "Model": Config.LITELLM_MODEL, "Mode": mode.upper(), - "Commands": "/help, /clear, /stats, /history, /dump-memory, /exit" + "Commands": "/help, /clear, /stats, /history, /dump-memory, /exit", } terminal_ui.print_config(config_dict) - terminal_ui.console.print("\n[bold green]Interactive mode started. Type your message or use commands.[/bold green]") + terminal_ui.console.print( + "\n[bold green]Interactive mode started. Type your message or use commands.[/bold green]" + ) terminal_ui.console.print("[dim]Tip: Use /help to see available commands[/dim]\n") # Define prompt style for better visual feedback - prompt_style = Style.from_dict({ - 'prompt': '#00ffff bold', # Cyan bold for "You:" - }) + prompt_style = Style.from_dict( + { + "prompt": "#00ffff bold", # Cyan bold for "You:" + } + ) conversation_count = 0 while True: try: # Get user input using prompt_toolkit for better Unicode support - user_input = prompt( - [('class:prompt', 'You: ')], - style=prompt_style - ).strip() + user_input = prompt([("class:prompt", "You: ")], style=prompt_style).strip() # Handle empty input if not user_input: @@ -59,7 +64,9 @@ def run_interactive_mode(agent, mode: str): command = command_parts[0].lower() if command == "/exit" or command == "/quit": - terminal_ui.console.print("\n[bold yellow]Exiting interactive mode. Goodbye![/bold yellow]") + terminal_ui.console.print( + "\n[bold yellow]Exiting interactive mode. Goodbye![/bold yellow]" + ) break elif command == "/help": @@ -69,7 +76,9 @@ def run_interactive_mode(agent, mode: str): elif command == "/clear": agent.memory.reset() conversation_count = 0 - terminal_ui.console.print("\n[bold green]✓ Memory cleared. Starting fresh conversation.[/bold green]\n") + terminal_ui.console.print( + "\n[bold green]✓ Memory cleared. Starting fresh conversation.[/bold green]\n" + ) continue elif command == "/stats": @@ -82,7 +91,9 @@ def run_interactive_mode(agent, mode: str): elif command == "/dump-memory": if len(command_parts) < 2: - terminal_ui.console.print("[bold red]Error:[/bold red] Please provide a session ID") + terminal_ui.console.print( + "[bold red]Error:[/bold red] Please provide a session ID" + ) terminal_ui.console.print("[dim]Usage: /dump-memory [/dim]\n") else: session_id = command_parts[1] @@ -108,17 +119,23 @@ def run_interactive_mode(agent, mode: str): terminal_ui.console.print() except KeyboardInterrupt: - terminal_ui.console.print("\n[bold yellow]Task interrupted by user.[/bold yellow]\n") + terminal_ui.console.print( + "\n[bold yellow]Task interrupted by user.[/bold yellow]\n" + ) continue except Exception as e: terminal_ui.console.print(f"\n[bold red]Error:[/bold red] {str(e)}\n") continue except KeyboardInterrupt: - terminal_ui.console.print("\n\n[bold yellow]Interrupted. Type /exit to quit or continue chatting.[/bold yellow]\n") + terminal_ui.console.print( + "\n\n[bold yellow]Interrupted. Type /exit to quit or continue chatting.[/bold yellow]\n" + ) continue except EOFError: - terminal_ui.console.print("\n[bold yellow]Exiting interactive mode. Goodbye![/bold yellow]") + terminal_ui.console.print( + "\n[bold yellow]Exiting interactive mode. Goodbye![/bold yellow]" + ) break # Show final statistics @@ -136,10 +153,18 @@ def _show_help(): """Display help message with available commands.""" terminal_ui.console.print("\n[bold]Available Commands:[/bold]") terminal_ui.console.print(" [cyan]/help[/cyan] - Show this help message") - terminal_ui.console.print(" [cyan]/clear[/cyan] - Clear conversation memory and start fresh") - terminal_ui.console.print(" [cyan]/stats[/cyan] - Show memory and token usage statistics") - terminal_ui.console.print(" [cyan]/history[/cyan] - List all saved conversation sessions") - terminal_ui.console.print(" [cyan]/dump-memory [/cyan] - Export a session's memory to a JSON file") + terminal_ui.console.print( + " [cyan]/clear[/cyan] - Clear conversation memory and start fresh" + ) + terminal_ui.console.print( + " [cyan]/stats[/cyan] - Show memory and token usage statistics" + ) + terminal_ui.console.print( + " [cyan]/history[/cyan] - List all saved conversation sessions" + ) + terminal_ui.console.print( + " [cyan]/dump-memory [/cyan] - Export a session's memory to a JSON file" + ) terminal_ui.console.print(" [cyan]/exit[/cyan] - Exit interactive mode") terminal_ui.console.print(" [cyan]/quit[/cyan] - Same as /exit\n") @@ -161,7 +186,9 @@ def _show_history(): if not sessions: terminal_ui.console.print("\n[yellow]No saved sessions found.[/yellow]") - terminal_ui.console.print("[dim]Sessions will be saved when using persistent memory mode.[/dim]\n") + terminal_ui.console.print( + "[dim]Sessions will be saved when using persistent memory mode.[/dim]\n" + ) return terminal_ui.console.print("\n[bold]📚 Saved Sessions (showing most recent 20):[/bold]\n") @@ -185,7 +212,9 @@ def _show_history(): terminal_ui.console.print(table) terminal_ui.console.print() - terminal_ui.console.print("[dim]Tip: Use /dump-memory to export a session's memory[/dim]\n") + terminal_ui.console.print( + "[dim]Tip: Use /dump-memory to export a session's memory[/dim]\n" + ) except Exception as e: terminal_ui.console.print(f"\n[bold red]Error loading sessions:[/bold red] {str(e)}\n") @@ -205,7 +234,9 @@ def _dump_memory(session_id: str): session_data = store.load_session(session_id) if not session_data: - terminal_ui.console.print(f"\n[bold red]Error:[/bold red] Session {session_id} not found\n") + terminal_ui.console.print( + f"\n[bold red]Error:[/bold red] Session {session_id} not found\n" + ) return # Prepare export data @@ -218,8 +249,7 @@ def _dump_memory(session_id: str): for msg in session_data["system_messages"] ], "messages": [ - {"role": msg.role, "content": msg.content} - for msg in session_data["messages"] + {"role": msg.role, "content": msg.content} for msg in session_data["messages"] ], "summaries": [ { @@ -230,8 +260,7 @@ def _dump_memory(session_id: str): "compression_ratio": s.compression_ratio, "token_savings": s.token_savings, "preserved_messages": [ - {"role": msg.role, "content": msg.content} - for msg in s.preserved_messages + {"role": msg.role, "content": msg.content} for msg in s.preserved_messages ], "metadata": s.metadata, } @@ -253,11 +282,11 @@ def _dump_memory(session_id: str): with open(output_path, "w", encoding="utf-8") as f: json.dump(export_data, f, indent=2, ensure_ascii=False, default=str) - terminal_ui.console.print(f"\n[bold green]✓ Memory dumped successfully![/bold green]") + terminal_ui.console.print("\n[bold green]✓ Memory dumped successfully![/bold green]") terminal_ui.console.print(f"[dim]Location:[/dim] {output_path}") # Show summary - terminal_ui.console.print(f"\n[bold]Summary:[/bold]") + terminal_ui.console.print("\n[bold]Summary:[/bold]") terminal_ui.console.print(f" Session ID: {session_id}") terminal_ui.console.print(f" Messages: {len(export_data['messages'])}") terminal_ui.console.print(f" System Messages: {len(export_data['system_messages'])}") diff --git a/llm/__init__.py b/llm/__init__.py index 642c164..546be3a 100644 --- a/llm/__init__.py +++ b/llm/__init__.py @@ -1,6 +1,8 @@ """LLM module - LiteLLM adapter for unified access to 100+ providers.""" + from .base import LLMMessage, LLMResponse, ToolCall, ToolResult from .litellm_adapter import LiteLLMLLM +from .retry import RetryConfig __all__ = [ "LLMMessage", @@ -8,4 +10,5 @@ "ToolCall", "ToolResult", "LiteLLMLLM", + "RetryConfig", ] diff --git a/llm/base.py b/llm/base.py index e63657d..4832e46 100644 --- a/llm/base.py +++ b/llm/base.py @@ -1,11 +1,13 @@ """Base data structures for LLM interface.""" -from typing import Any, Dict, Optional + from dataclasses import dataclass +from typing import Any, Dict, Optional @dataclass class LLMMessage: """Unified message format across all LLM providers.""" + role: str # "user", "assistant", "system" content: Any # Can be string or list of content blocks @@ -13,14 +15,18 @@ class LLMMessage: @dataclass class LLMResponse: """Unified response format across all LLM providers.""" + message: Any # Response content (text or content blocks) stop_reason: str # "end_turn", "tool_use", "max_tokens", etc. - usage: Optional[Dict[str, int]] = None # Token usage: {"input_tokens": int, "output_tokens": int} + usage: Optional[Dict[str, int]] = ( + None # Token usage: {"input_tokens": int, "output_tokens": int} + ) @dataclass class ToolCall: """Unified tool call format.""" + id: str name: str arguments: Dict[str, Any] @@ -29,5 +35,6 @@ class ToolCall: @dataclass class ToolResult: """Unified tool result format.""" + tool_call_id: str content: str diff --git a/llm/litellm_adapter.py b/llm/litellm_adapter.py index e80c1b2..6d55ad7 100644 --- a/llm/litellm_adapter.py +++ b/llm/litellm_adapter.py @@ -1,13 +1,16 @@ """LiteLLM adapter for unified LLM access across 100+ providers.""" -from typing import List, Dict, Any, Optional + import json import logging +from typing import Any, Dict, List, Optional + import litellm -from .base import LLMMessage, LLMResponse, ToolCall, ToolResult -from .retry import with_retry, RetryConfig from utils import get_logger +from .base import LLMMessage, LLMResponse, ToolCall, ToolResult +from .retry import RetryConfig, with_retry + logger = get_logger(__name__) # Suppress LiteLLM's verbose logging to console @@ -37,17 +40,15 @@ def __init__(self, model: str, **kwargs): self.provider = model.split("/")[0] if "/" in model else "unknown" # Extract configuration from kwargs - self.api_key = kwargs.pop('api_key', None) - self.api_base = kwargs.pop('api_base', None) - self.drop_params = kwargs.pop('drop_params', True) - self.timeout = kwargs.pop('timeout', 600) + self.api_key = kwargs.pop("api_key", None) + self.api_base = kwargs.pop("api_base", None) + self.drop_params = kwargs.pop("drop_params", True) + self.timeout = kwargs.pop("timeout", 600) # Configure retry behavior - self.retry_config = kwargs.pop('retry_config', RetryConfig( - max_retries=3, - initial_delay=1.0, - max_delay=60.0 - )) + self.retry_config = kwargs.pop( + "retry_config", RetryConfig(max_retries=3, initial_delay=1.0, max_delay=60.0) + ) # Configure LiteLLM global settings litellm.drop_params = self.drop_params @@ -71,7 +72,7 @@ def call( messages: List[LLMMessage], tools: Optional[List[Dict[str, Any]]] = None, max_tokens: int = 4096, - **kwargs + **kwargs, ) -> LLMResponse: """Call LLM via LiteLLM with automatic retry. @@ -111,11 +112,13 @@ def call( call_params.update(kwargs) # Make API call with retry logic - logger.debug(f"Calling LiteLLM with model: {self.model}, messages: {len(litellm_messages)}, tools: {len(tools) if tools else 0}") + logger.debug( + f"Calling LiteLLM with model: {self.model}, messages: {len(litellm_messages)}, tools: {len(tools) if tools else 0}" + ) response = self._make_api_call(**call_params) # Log token usage - if hasattr(response, 'usage') and response.usage: + if hasattr(response, "usage") and response.usage: usage = response.usage logger.debug( f"Token Usage: Input={usage.get('prompt_tokens', 0)}, " @@ -133,41 +136,26 @@ def _convert_messages(self, messages: List[LLMMessage]) -> List[Dict]: for msg in messages: # Handle system messages if msg.role == "system": - litellm_messages.append({ - "role": "system", - "content": msg.content - }) + litellm_messages.append({"role": "system", "content": msg.content}) # Handle user messages elif msg.role == "user": if isinstance(msg.content, str): - litellm_messages.append({ - "role": "user", - "content": msg.content - }) + litellm_messages.append({"role": "user", "content": msg.content}) elif isinstance(msg.content, list): # Handle tool results (Anthropic format) content = self._convert_tool_results_to_text(msg.content) - litellm_messages.append({ - "role": "user", - "content": content - }) + litellm_messages.append({"role": "user", "content": content}) # Handle assistant messages elif msg.role == "assistant": if isinstance(msg.content, str): - litellm_messages.append({ - "role": "assistant", - "content": msg.content - }) + litellm_messages.append({"role": "assistant", "content": msg.content}) else: # Handle complex content (tool calls, etc.) content = self._extract_assistant_content(msg.content) if content: - litellm_messages.append({ - "role": "assistant", - "content": content - }) + litellm_messages.append({"role": "assistant", "content": content}) return litellm_messages @@ -177,8 +165,8 @@ def _convert_tool_results_to_text(self, content: List) -> str: results = [] for block in content: if isinstance(block, dict) and block.get("type") == "tool_result": - tool_id = block.get('tool_use_id', 'unknown') - tool_content = block.get('content', '') + tool_id = block.get("tool_use_id", "unknown") + tool_content = block.get("content", "") results.append(f"Tool result (ID: {tool_id}):\n{tool_content}") return "\n\n".join(results) if results else str(content) @@ -206,14 +194,16 @@ def _convert_tools(self, tools: List[Dict[str, Any]]) -> List[Dict]: """Convert Anthropic tool format to OpenAI format.""" openai_tools = [] for tool in tools: - openai_tools.append({ - "type": "function", - "function": { - "name": tool["name"], - "description": tool["description"], - "parameters": tool["input_schema"] + openai_tools.append( + { + "type": "function", + "function": { + "name": tool["name"], + "description": tool["description"], + "parameters": tool["input_schema"], + }, } - }) + ) return openai_tools def _convert_response(self, response) -> LLMResponse: @@ -234,22 +224,18 @@ def _convert_response(self, response) -> LLMResponse: # Extract token usage usage_dict = None - if hasattr(response, 'usage') and response.usage: + if hasattr(response, "usage") and response.usage: usage_dict = { "input_tokens": response.usage.get("prompt_tokens", 0), - "output_tokens": response.usage.get("completion_tokens", 0) + "output_tokens": response.usage.get("completion_tokens", 0), } - return LLMResponse( - message=message, - stop_reason=stop_reason, - usage=usage_dict - ) + return LLMResponse(message=message, stop_reason=stop_reason, usage=usage_dict) def extract_text(self, response: LLMResponse) -> str: """Extract text from LiteLLM response.""" message = response.message - return message.content if hasattr(message, 'content') and message.content else "" + return message.content if hasattr(message, "content") and message.content else "" def extract_tool_calls(self, response: LLMResponse) -> List[ToolCall]: """Extract tool calls from LiteLLM response.""" @@ -258,11 +244,11 @@ def extract_tool_calls(self, response: LLMResponse) -> List[ToolCall]: if hasattr(message, "tool_calls") and message.tool_calls: for tc in message.tool_calls: - tool_calls.append(ToolCall( - id=tc.id, - name=tc.function.name, - arguments=json.loads(tc.function.arguments) - )) + tool_calls.append( + ToolCall( + id=tc.id, name=tc.function.name, arguments=json.loads(tc.function.arguments) + ) + ) else: logger.debug(f"No tool calls found in the response. {message}") @@ -274,11 +260,13 @@ def format_tool_results(self, results: List[ToolResult]) -> LLMMessage: # Format as Anthropic-style for compatibility with existing code content = [] for result in results: - content.append({ - "type": "tool_result", - "tool_use_id": result.tool_call_id, - "content": result.content - }) + content.append( + { + "type": "tool_result", + "tool_use_id": result.tool_call_id, + "content": result.content, + } + ) return LLMMessage(role="user", content=content) diff --git a/llm/retry.py b/llm/retry.py index 6808ce6..4a54c60 100644 --- a/llm/retry.py +++ b/llm/retry.py @@ -1,12 +1,14 @@ """Retry utilities for LLM API calls with exponential backoff.""" -import time + import random -from typing import Callable, TypeVar, Any +import time from functools import wraps +from typing import Callable, TypeVar + from utils import get_logger logger = get_logger(__name__) -T = TypeVar('T') +T = TypeVar("T") class RetryConfig: @@ -18,7 +20,7 @@ def __init__( initial_delay: float = 1.0, max_delay: float = 60.0, exponential_base: float = 2.0, - jitter: bool = True + jitter: bool = True, ): """Initialize retry configuration. @@ -45,10 +47,7 @@ def get_delay(self, attempt: int) -> float: Delay in seconds """ # Calculate exponential backoff - delay = min( - self.initial_delay * (self.exponential_base ** attempt), - self.max_delay - ) + delay = min(self.initial_delay * (self.exponential_base**attempt), self.max_delay) # Add jitter to avoid thundering herd if self.jitter: @@ -67,15 +66,14 @@ def is_rate_limit_error(error: Exception) -> bool: True if this is a rate limit error """ error_str = str(error).lower() - error_type = type(error).__name__ # Common rate limit indicators rate_limit_indicators = [ - '429', - 'rate limit', - 'quota', - 'too many requests', - 'resourceexhausted', + "429", + "rate limit", + "quota", + "too many requests", + "resourceexhausted", ] return any(indicator in error_str for indicator in rate_limit_indicators) @@ -103,13 +101,13 @@ def is_retryable_error(error: Exception) -> bool: # Other retryable errors retryable_indicators = [ - 'timeout', - 'connection', - 'server error', - '500', - '502', - '503', - '504', + "timeout", + "connection", + "server error", + "500", + "502", + "503", + "504", ] return any(indicator in error_str for indicator in retryable_indicators) @@ -124,12 +122,13 @@ def with_retry(config: RetryConfig = None): Returns: Decorator function """ + def decorator(func: Callable[..., T]) -> Callable[..., T]: @wraps(func) def wrapper(*args, **kwargs) -> T: # Try to get config from instance (self) if available retry_config = config - if retry_config is None and args and hasattr(args[0], 'retry_config'): + if retry_config is None and args and hasattr(args[0], "retry_config"): retry_config = args[0].retry_config if retry_config is None: retry_config = RetryConfig() @@ -156,7 +155,9 @@ def wrapper(*args, **kwargs) -> T: # Log retry attempt error_type = "Rate limit" if is_rate_limit_error(e) else "Retryable" logger.warning(f"{error_type} error: {str(e)}") - logger.warning(f"Retrying in {delay:.1f}s... (attempt {attempt + 1}/{retry_config.max_retries})") + logger.warning( + f"Retrying in {delay:.1f}s... (attempt {attempt + 1}/{retry_config.max_retries})" + ) # Wait before retry time.sleep(delay) @@ -165,15 +166,11 @@ def wrapper(*args, **kwargs) -> T: raise last_error return wrapper + return decorator -def retry_with_backoff( - func: Callable[..., T], - *args, - config: RetryConfig = None, - **kwargs -) -> T: +def retry_with_backoff(func: Callable[..., T], *args, config: RetryConfig = None, **kwargs) -> T: """Execute a function with retry logic. Args: diff --git a/main.py b/main.py index b6f9db5..e609c60 100644 --- a/main.py +++ b/main.py @@ -1,33 +1,39 @@ """Main entry point for the agentic loop system.""" + import argparse import warnings -warnings.filterwarnings( - "ignore", - message="Pydantic serializer warnings.*", - category=UserWarning -) + +from agent.plan_execute_agent import PlanExecuteAgent +from agent.react_agent import ReActAgent from config import Config +from interactive import run_interactive_mode from llm import LiteLLMLLM -from agent.react_agent import ReActAgent -from agent.plan_execute_agent import PlanExecuteAgent -from tools.file_ops import FileReadTool, FileWriteTool, FileSearchTool +from tools.advanced_file_ops import EditTool, GlobTool, GrepTool from tools.calculator import CalculatorTool -from tools.shell import ShellTool -from tools.web_search import WebSearchTool -from tools.web_fetch import WebFetchTool -from tools.advanced_file_ops import GlobTool, GrepTool, EditTool -from tools.smart_edit import SmartEditTool from tools.code_navigator import CodeNavigatorTool -from tools.todo import TodoTool from tools.delegation import DelegationTool +from tools.file_ops import FileReadTool, FileSearchTool, FileWriteTool from tools.git_tools import ( - GitStatusTool, GitDiffTool, GitAddTool, GitCommitTool, - GitLogTool, GitBranchTool, GitCheckoutTool, GitPushTool, - GitPullTool, GitRemoteTool, GitStashTool, GitCleanTool + GitAddTool, + GitBranchTool, + GitCheckoutTool, + GitCleanTool, + GitCommitTool, + GitDiffTool, + GitLogTool, + GitPullTool, + GitPushTool, + GitRemoteTool, + GitStashTool, + GitStatusTool, ) -from agent.todo import TodoList -from utils import setup_logger, get_log_file_path, terminal_ui -from interactive import run_interactive_mode +from tools.shell import ShellTool +from tools.smart_edit import SmartEditTool +from tools.web_fetch import WebFetchTool +from tools.web_search import WebSearchTool +from utils import get_log_file_path, setup_logger, terminal_ui + +warnings.filterwarnings("ignore", message="Pydantic serializer warnings.*", category=UserWarning) def create_agent(mode: str = "react"): @@ -74,7 +80,7 @@ def create_agent(mode: str = "react"): api_base=Config.LITELLM_API_BASE, retry_config=Config.get_retry_config(), drop_params=Config.LITELLM_DROP_PARAMS, - timeout=Config.LITELLM_TIMEOUT + timeout=Config.LITELLM_TIMEOUT, ) # Create agent based on mode @@ -103,9 +109,7 @@ def main(): # Initialize logging setup_logger() - parser = argparse.ArgumentParser( - description="Run an AI agent with tool-calling capabilities" - ) + parser = argparse.ArgumentParser(description="Run an AI agent with tool-calling capabilities") parser.add_argument( "--mode", "-m", @@ -117,7 +121,7 @@ def main(): "--task", "-t", type=str, - help="Task for the agent to complete (if not provided, enters interactive mode)" + help="Task for the agent to complete (if not provided, enters interactive mode)", ) args = parser.parse_args() @@ -142,15 +146,16 @@ def main(): # Display header and config terminal_ui.print_header( - "🤖 Agentic Loop System", - subtitle="Intelligent AI Agent with Tool-Calling Capabilities" + "🤖 Agentic Loop System", subtitle="Intelligent AI Agent with Tool-Calling Capabilities" ) config_dict = { - "LLM Provider": Config.LITELLM_MODEL.split('/')[0].upper() if '/' in Config.LITELLM_MODEL else "UNKNOWN", + "LLM Provider": ( + Config.LITELLM_MODEL.split("/")[0].upper() if "/" in Config.LITELLM_MODEL else "UNKNOWN" + ), "Model": Config.LITELLM_MODEL, "Mode": args.mode.upper(), - "Task": task if len(task) < 100 else task[:97] + "..." + "Task": task if len(task) < 100 else task[:97] + "...", } terminal_ui.print_config(config_dict) diff --git a/memory/__init__.py b/memory/__init__.py index ae888aa..986011c 100644 --- a/memory/__init__.py +++ b/memory/__init__.py @@ -4,12 +4,12 @@ token tracking, cost optimization, and optional persistence. """ -from .types import MemoryConfig, CompressedMemory, CompressionStrategy +from .compressor import WorkingMemoryCompressor from .manager import MemoryManager from .short_term import ShortTermMemory -from .compressor import WorkingMemoryCompressor -from .token_tracker import TokenTracker from .store import MemoryStore +from .token_tracker import TokenTracker +from .types import CompressedMemory, CompressionStrategy, MemoryConfig __all__ = [ "MemoryConfig", diff --git a/memory/compressor.py b/memory/compressor.py index 1605598..19a3b91 100644 --- a/memory/compressor.py +++ b/memory/compressor.py @@ -1,12 +1,17 @@ """Memory compression using LLM-based summarization.""" -from typing import List, Tuple -from llm.base import LLMMessage + import logging +from typing import TYPE_CHECKING, List, Optional, Set, Tuple + +from llm.base import LLMMessage -from .types import CompressedMemory, MemoryConfig, CompressionStrategy +from .types import CompressedMemory, CompressionStrategy, MemoryConfig logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from llm import LiteLLMLLM + class WorkingMemoryCompressor: """Compresses conversation history using LLM summarization.""" @@ -27,7 +32,7 @@ class WorkingMemoryCompressor: {messages} -Provide a concise but comprehensive summary that captures the essential information. Be specific and include concrete details. Target length: {target_tokens} tokens.""" + Provide a concise but comprehensive summary that captures the essential information. Be specific and include concrete details. Target length: {target_tokens} tokens.""" def __init__(self, llm: "LiteLLMLLM", config: MemoryConfig): """Initialize compressor. @@ -43,8 +48,8 @@ def compress( self, messages: List[LLMMessage], strategy: str = CompressionStrategy.SLIDING_WINDOW, - target_tokens: int = None, - orphaned_tool_use_ids: set = None, + target_tokens: Optional[int] = None, + orphaned_tool_use_ids: Optional[Set[str]] = None, ) -> CompressedMemory: """Compress messages using specified strategy. @@ -115,7 +120,9 @@ def _compress_sliding_window( summary = self.llm.extract_text(response) # Calculate compression metrics - compressed_tokens = self._estimate_tokens([LLMMessage(role="assistant", content=summary)]) + compressed_tokens = self._estimate_tokens( + [LLMMessage(role="assistant", content=summary)] + ) compression_ratio = compressed_tokens / original_tokens if original_tokens > 0 else 0 return CompressedMemory( @@ -196,9 +203,13 @@ def _compress_selective( response = self.llm.call(messages=[prompt], max_tokens=available_for_summary * 2) summary = self.llm.extract_text(response) - summary_tokens = self._estimate_tokens([LLMMessage(role="assistant", content=summary)]) + summary_tokens = self._estimate_tokens( + [LLMMessage(role="assistant", content=summary)] + ) compressed_tokens = preserved_tokens + summary_tokens - compression_ratio = compressed_tokens / original_tokens if original_tokens > 0 else 0 + compression_ratio = ( + compressed_tokens / original_tokens if original_tokens > 0 else 0 + ) return CompressedMemory( summary=summary, diff --git a/memory/manager.py b/memory/manager.py index 9be68a1..7f5a7a7 100644 --- a/memory/manager.py +++ b/memory/manager.py @@ -1,17 +1,21 @@ """Core memory manager that orchestrates all memory operations.""" -from typing import List, Optional, Dict, Any + import logging +from typing import TYPE_CHECKING, Any, Dict, List, Optional from llm.base import LLMMessage -from .types import MemoryConfig, CompressedMemory, CompressionStrategy -from .short_term import ShortTermMemory from .compressor import WorkingMemoryCompressor -from .token_tracker import TokenTracker +from .short_term import ShortTermMemory from .store import MemoryStore +from .token_tracker import TokenTracker +from .types import CompressedMemory, CompressionStrategy, MemoryConfig logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from llm import LiteLLMLLM + class MemoryManager: """Central memory management system with built-in persistence.""" @@ -22,7 +26,7 @@ def __init__( llm: "LiteLLMLLM", store: Optional[MemoryStore] = None, session_id: Optional[str] = None, - db_path: str = "data/memory.db" + db_path: str = "data/memory.db", ): """Initialize memory manager. @@ -67,9 +71,9 @@ def __init__( def from_session( cls, session_id: str, - llm: "BaseLLM", + llm: "LiteLLMLLM", store: Optional[MemoryStore] = None, - db_path: str = "data/memory.db" + db_path: str = "data/memory.db", ) -> "MemoryManager": """Load a MemoryManager from a saved session. @@ -95,12 +99,7 @@ def from_session( config = session_data["config"] or MemoryConfig() # Create manager - manager = cls( - config=config, - llm=llm, - store=store, - session_id=session_id - ) + manager = cls(config=config, llm=llm, store=store, session_id=session_id) # Restore state manager.system_messages = session_data["system_messages"] @@ -251,7 +250,7 @@ def compress(self, strategy: str = None) -> Optional[CompressedMemory]: messages, strategy=strategy, target_tokens=self._calculate_target_tokens(), - orphaned_tool_use_ids=orphaned_tool_use_ids + orphaned_tool_use_ids=orphaned_tool_use_ids, ) # Track compression results @@ -293,6 +292,9 @@ def _should_compress(self) -> tuple[bool, Optional[str]]: Returns: Tuple of (should_compress, reason) """ + if not self.config.enable_compression: + return False, "compression_disabled" + # Hard limit: must compress if self.current_tokens > self.config.compression_threshold: return True, f"hard_limit ({self.current_tokens} > {self.config.compression_threshold})" @@ -440,7 +442,8 @@ def get_stats(self) -> Dict[str, Any]: "compression_count": self.compression_count, "total_savings": self.token_tracker.compression_savings, "compression_cost": self.token_tracker.compression_cost, - "net_savings": self.token_tracker.compression_savings - self.token_tracker.compression_cost, + "net_savings": self.token_tracker.compression_savings + - self.token_tracker.compression_cost, "short_term_count": self.short_term.count(), "summary_count": len(self.summaries), "total_cost": self.token_tracker.get_total_cost(self.llm.model), @@ -471,7 +474,7 @@ def save_memory(self): session_id=self.session_id, system_messages=self.system_messages, messages=messages, - summaries=self.summaries + summaries=self.summaries, ) logger.info(f"Saved memory state for session {self.session_id}") diff --git a/memory/short_term.py b/memory/short_term.py index 59b934b..8a956ba 100644 --- a/memory/short_term.py +++ b/memory/short_term.py @@ -1,8 +1,11 @@ """Short-term memory management with fixed-size window.""" -from typing import List + from collections import deque +from typing import List + from llm.base import LLMMessage + class ShortTermMemory: """Manages recent messages in a fixed-size sliding window.""" diff --git a/memory/store.py b/memory/store.py index 4020c5d..a5a6cde 100644 --- a/memory/store.py +++ b/memory/store.py @@ -1,14 +1,15 @@ """Persistent storage for memory using embedded SQLite database.""" -import sqlite3 + import json +import logging +import sqlite3 import uuid from datetime import datetime -from typing import List, Dict, Any, Optional from pathlib import Path -import logging +from typing import Any, Dict, List, Optional from llm.base import LLMMessage -from memory.types import MemoryConfig, CompressedMemory +from memory.types import CompressedMemory, MemoryConfig logger = logging.getLogger(__name__) @@ -45,7 +46,8 @@ def _init_db(self): cursor = conn.cursor() # Single sessions table with all data stored as JSON - cursor.execute(""" + cursor.execute( + """ CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, created_at TIMESTAMP NOT NULL, @@ -53,15 +55,14 @@ def _init_db(self): system_messages TEXT, summaries TEXT ) - """) + """ + ) conn.commit() logger.debug("Database schema initialized") def create_session( - self, - metadata: Optional[Dict[str, Any]] = None, - config: Optional[MemoryConfig] = None + self, metadata: Optional[Dict[str, Any]] = None, config: Optional[MemoryConfig] = None ) -> str: """Create a new session. @@ -88,20 +89,15 @@ def create_session( now, json.dumps([]), # Empty messages list json.dumps([]), # Empty system_messages list - json.dumps([]) # Empty summaries list - ) + json.dumps([]), # Empty summaries list + ), ) conn.commit() logger.info(f"Created session {session_id}") return session_id - def save_message( - self, - session_id: str, - message: LLMMessage, - tokens: int = 0 - ): + def save_message(self, session_id: str, message: LLMMessage, tokens: int = 0): """Save a message to the database. Args: @@ -119,10 +115,7 @@ def save_message( field = "messages" # Load current messages - cursor.execute( - f"SELECT {field} FROM sessions WHERE id = ?", - (session_id,) - ) + cursor.execute(f"SELECT {field} FROM sessions WHERE id = ?", (session_id,)) row = cursor.fetchone() if not row: logger.warning(f"Session {session_id} not found") @@ -131,25 +124,22 @@ def save_message( messages = json.loads(row[0]) if row[0] else [] # Append new message with serialized content - messages.append({ - "role": message.role, - "content": self._serialize_content(message.content), - "tokens": tokens - }) + messages.append( + { + "role": message.role, + "content": self._serialize_content(message.content), + "tokens": tokens, + } + ) # Update the field cursor.execute( - f"UPDATE sessions SET {field} = ? WHERE id = ?", - (json.dumps(messages), session_id) + f"UPDATE sessions SET {field} = ? WHERE id = ?", (json.dumps(messages), session_id) ) conn.commit() - def save_summary( - self, - session_id: str, - summary: CompressedMemory - ): + def save_summary(self, session_id: str, summary: CompressedMemory): """Save a compression summary to the database. Args: @@ -160,10 +150,7 @@ def save_summary( cursor = conn.cursor() # Load current summaries - cursor.execute( - "SELECT summaries FROM sessions WHERE id = ?", - (session_id,) - ) + cursor.execute("SELECT summaries FROM sessions WHERE id = ?", (session_id,)) row = cursor.fetchone() if not row: logger.warning(f"Session {session_id} not found") @@ -172,24 +159,26 @@ def save_summary( summaries = json.loads(row[0]) if row[0] else [] # Append new summary with serialized content - summaries.append({ - "summary": summary.summary, - "preserved_messages": [ - {"role": msg.role, "content": self._serialize_content(msg.content)} - for msg in summary.preserved_messages - ], - "original_message_count": summary.original_message_count, - "original_tokens": summary.original_tokens, - "compressed_tokens": summary.compressed_tokens, - "compression_ratio": summary.compression_ratio, - "metadata": summary.metadata, - "created_at": summary.created_at.isoformat() - }) + summaries.append( + { + "summary": summary.summary, + "preserved_messages": [ + {"role": msg.role, "content": self._serialize_content(msg.content)} + for msg in summary.preserved_messages + ], + "original_message_count": summary.original_message_count, + "original_tokens": summary.original_tokens, + "compressed_tokens": summary.compressed_tokens, + "compression_ratio": summary.compression_ratio, + "metadata": summary.metadata, + "created_at": summary.created_at.isoformat(), + } + ) # Update summaries field cursor.execute( "UPDATE sessions SET summaries = ? WHERE id = ?", - (json.dumps(summaries), session_id) + (json.dumps(summaries), session_id), ) conn.commit() @@ -223,7 +212,7 @@ def save_memory( session_id: str, system_messages: List[LLMMessage], messages: List[LLMMessage], - summaries: List[CompressedMemory] + summaries: List[CompressedMemory], ): """Save complete memory state to the database. @@ -246,34 +235,40 @@ def save_memory( return # Serialize system messages - system_messages_json = json.dumps([ - {"role": msg.role, "content": self._serialize_content(msg.content)} - for msg in system_messages - ]) + system_messages_json = json.dumps( + [ + {"role": msg.role, "content": self._serialize_content(msg.content)} + for msg in system_messages + ] + ) # Serialize regular messages - messages_json = json.dumps([ - {"role": msg.role, "content": self._serialize_content(msg.content), "tokens": 0} - for msg in messages - ]) + messages_json = json.dumps( + [ + {"role": msg.role, "content": self._serialize_content(msg.content), "tokens": 0} + for msg in messages + ] + ) # Serialize summaries - summaries_json = json.dumps([ - { - "summary": summary.summary, - "preserved_messages": [ - {"role": msg.role, "content": self._serialize_content(msg.content)} - for msg in summary.preserved_messages - ], - "original_message_count": summary.original_message_count, - "original_tokens": summary.original_tokens, - "compressed_tokens": summary.compressed_tokens, - "compression_ratio": summary.compression_ratio, - "metadata": summary.metadata, - "created_at": summary.created_at.isoformat() - } - for summary in summaries - ]) + summaries_json = json.dumps( + [ + { + "summary": summary.summary, + "preserved_messages": [ + {"role": msg.role, "content": self._serialize_content(msg.content)} + for msg in summary.preserved_messages + ], + "original_message_count": summary.original_message_count, + "original_tokens": summary.original_tokens, + "compressed_tokens": summary.compressed_tokens, + "compression_ratio": summary.compression_ratio, + "metadata": summary.metadata, + "created_at": summary.created_at.isoformat(), + } + for summary in summaries + ] + ) # Update all fields in one transaction cursor.execute( @@ -284,14 +279,16 @@ def save_memory( summaries = ? WHERE id = ? """, - (system_messages_json, messages_json, summaries_json, session_id) + (system_messages_json, messages_json, summaries_json, session_id), ) conn.commit() - logger.debug(f"Saved memory for session {session_id}: " - f"{len(system_messages)} system msgs, " - f"{len(messages)} messages, " - f"{len(summaries)} summaries") + logger.debug( + f"Saved memory for session {session_id}: " + f"{len(system_messages)} system msgs, " + f"{len(messages)} messages, " + f"{len(summaries)} summaries" + ) def load_session(self, session_id: str) -> Optional[Dict[str, Any]]: """Load complete session state. @@ -314,10 +311,7 @@ def load_session(self, session_id: str) -> Optional[Dict[str, Any]]: cursor = conn.cursor() # Load session info - cursor.execute( - "SELECT * FROM sessions WHERE id = ?", - (session_id,) - ) + cursor.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)) session_row = cursor.fetchone() if not session_row: @@ -325,21 +319,23 @@ def load_session(self, session_id: str) -> Optional[Dict[str, Any]]: return None # Parse system messages from JSON - system_messages_data = json.loads(session_row["system_messages"]) if session_row["system_messages"] else [] + system_messages_data = ( + json.loads(session_row["system_messages"]) if session_row["system_messages"] else [] + ) system_messages = [ - LLMMessage(role=msg["role"], content=msg["content"]) - for msg in system_messages_data + LLMMessage(role=msg["role"], content=msg["content"]) for msg in system_messages_data ] # Parse regular messages from JSON messages_data = json.loads(session_row["messages"]) if session_row["messages"] else [] messages = [ - LLMMessage(role=msg["role"], content=msg["content"]) - for msg in messages_data + LLMMessage(role=msg["role"], content=msg["content"]) for msg in messages_data ] # Parse summaries from JSON - summaries_data = json.loads(session_row["summaries"]) if session_row["summaries"] else [] + summaries_data = ( + json.loads(session_row["summaries"]) if session_row["summaries"] else [] + ) summaries = [] for summary_data in summaries_data: preserved_msgs = [ @@ -347,16 +343,22 @@ def load_session(self, session_id: str) -> Optional[Dict[str, Any]]: for m in summary_data.get("preserved_messages", []) ] - summaries.append(CompressedMemory( - summary=summary_data.get("summary", ""), - preserved_messages=preserved_msgs, - original_message_count=summary_data.get("original_message_count", 0), - original_tokens=summary_data.get("original_tokens", 0), - compressed_tokens=summary_data.get("compressed_tokens", 0), - compression_ratio=summary_data.get("compression_ratio", 0.0), - metadata=summary_data.get("metadata", {}), - created_at=datetime.fromisoformat(summary_data["created_at"]) if "created_at" in summary_data else datetime.now() - )) + summaries.append( + CompressedMemory( + summary=summary_data.get("summary", ""), + preserved_messages=preserved_msgs, + original_message_count=summary_data.get("original_message_count", 0), + original_tokens=summary_data.get("original_tokens", 0), + compressed_tokens=summary_data.get("compressed_tokens", 0), + compression_ratio=summary_data.get("compression_ratio", 0.0), + metadata=summary_data.get("metadata", {}), + created_at=( + datetime.fromisoformat(summary_data["created_at"]) + if "created_at" in summary_data + else datetime.now() + ), + ) + ) return { "config": None, # Config management removed in simplified version @@ -366,14 +368,11 @@ def load_session(self, session_id: str) -> Optional[Dict[str, Any]]: "stats": { "created_at": session_row["created_at"], "compression_count": len(summaries), - } + }, } def list_sessions( - self, - limit: int = 50, - offset: int = 0, - order_by: str = "created_at" + self, limit: int = 50, offset: int = 0, order_by: str = "created_at" ) -> List[Dict[str, Any]]: """List all sessions. @@ -400,22 +399,26 @@ def list_sessions( ORDER BY {order_by} DESC LIMIT ? OFFSET ? """, - (limit, offset) + (limit, offset), ) sessions = [] for row in cursor.fetchall(): messages_data = json.loads(row["messages"]) if row["messages"] else [] - system_messages_data = json.loads(row["system_messages"]) if row["system_messages"] else [] + system_messages_data = ( + json.loads(row["system_messages"]) if row["system_messages"] else [] + ) summaries_data = json.loads(row["summaries"]) if row["summaries"] else [] - sessions.append({ - "id": row["id"], - "created_at": row["created_at"], - "message_count": len(messages_data), - "system_message_count": len(system_messages_data), - "summary_count": len(summaries_data), - }) + sessions.append( + { + "id": row["id"], + "created_at": row["created_at"], + "message_count": len(messages_data), + "system_message_count": len(system_messages_data), + "summary_count": len(summaries_data), + } + ) return sessions @@ -454,10 +457,7 @@ def get_session_stats(self, session_id: str) -> Optional[Dict[str, Any]]: conn.row_factory = sqlite3.Row cursor = conn.cursor() - cursor.execute( - "SELECT * FROM sessions WHERE id = ?", - (session_id,) - ) + cursor.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)) row = cursor.fetchone() if not row: @@ -465,7 +465,9 @@ def get_session_stats(self, session_id: str) -> Optional[Dict[str, Any]]: # Parse JSON fields messages_data = json.loads(row["messages"]) if row["messages"] else [] - system_messages_data = json.loads(row["system_messages"]) if row["system_messages"] else [] + system_messages_data = ( + json.loads(row["system_messages"]) if row["system_messages"] else [] + ) summaries_data = json.loads(row["summaries"]) if row["summaries"] else [] # Calculate token statistics from summaries diff --git a/memory/token_tracker.py b/memory/token_tracker.py index e30a3eb..3886c53 100644 --- a/memory/token_tracker.py +++ b/memory/token_tracker.py @@ -1,8 +1,10 @@ """Token counting and cost tracking for memory management.""" -from typing import Dict, Optional + +import logging +from typing import Dict + from llm.base import LLMMessage from utils.model_pricing import MODEL_PRICING -import logging logger = logging.getLogger(__name__) @@ -121,7 +123,9 @@ def add_compression_cost(self, cost: int): """Record tokens spent on compression.""" self.compression_cost += cost - def calculate_cost(self, model: str, input_tokens: int = None, output_tokens: int = None) -> float: + def calculate_cost( + self, model: str, input_tokens: int = None, output_tokens: int = None + ) -> float: """Calculate cost for given token usage. Args: @@ -145,7 +149,9 @@ def calculate_cost(self, model: str, input_tokens: int = None, output_tokens: in break if not pricing: - logger.info(f"No pricing found for model {model}, using default pricing (DeepSeek-Reasoner equivalent)") + logger.info( + f"No pricing found for model {model}, using default pricing (DeepSeek-Reasoner equivalent)" + ) # Fallback to default pricing (using reasonable mid-tier estimate) pricing = self.PRICING["default"] @@ -178,8 +184,12 @@ def get_net_savings(self, model: str) -> Dict[str, float]: net_tokens = self.compression_savings - self.compression_cost # Calculate cost of saved tokens - saved_cost = self.calculate_cost(model, input_tokens=self.compression_savings, output_tokens=0) - compression_cost = self.calculate_cost(model, input_tokens=0, output_tokens=self.compression_cost) + saved_cost = self.calculate_cost( + model, input_tokens=self.compression_savings, output_tokens=0 + ) + compression_cost = self.calculate_cost( + model, input_tokens=0, output_tokens=self.compression_cost + ) net_cost = saved_cost - compression_cost # Calculate percentage diff --git a/memory/types.py b/memory/types.py index 5c714e7..928face 100644 --- a/memory/types.py +++ b/memory/types.py @@ -1,7 +1,9 @@ """Data types for memory management system.""" + from dataclasses import dataclass, field -from typing import List, Dict, Any, Optional from datetime import datetime +from typing import Any, Dict, List, Optional + from llm.base import LLMMessage diff --git a/pyproject.toml b/pyproject.toml index fdc0079..a2b3b9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "AgenticLoop" version = "0.1.0" description = "General AI Agent System" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.12" license = {text = "MIT"} authors = [ {name = "luohaha", email = "your.email@example.com"} @@ -18,11 +18,8 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Scientific/Engineering :: Artificial Intelligence", ] @@ -46,6 +43,9 @@ dev = [ "black>=23.0.0", "isort>=5.12.0", "mypy>=1.0.0", + "ruff>=0.6.0", + "pre-commit>=3.0.0", + "types-requests>=2.31.0.0", ] [project.urls] @@ -72,3 +72,28 @@ target-version = ['py38', 'py39', 'py310', 'py311', 'py312'] [tool.isort] profile = "black" line_length = 100 + +[tool.mypy] +python_version = "3.12" +ignore_missing_imports = true +implicit_optional = true +disable_error_code = [ + "override", + "name-defined", + "var-annotated", + "assignment", + "misc", +] + +[tool.ruff] +line-length = 100 +target-version = "py38" + +[tool.ruff.lint] +select = ["E", "F"] +ignore = ["E501"] + +[tool.pytest.ini_options] +markers = [ + "integration: live LLM integration tests (skipped unless RUN_INTEGRATION_TESTS=1)", +] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index a9274b3..0000000 --- a/requirements.txt +++ /dev/null @@ -1,18 +0,0 @@ -# Core dependencies -python-dotenv>=1.0.0 -ddgs>=1.0.0 -rich>=13.0.0 # For beautiful terminal output -prompt_toolkit>=3.0.0 # For advanced terminal input with Unicode support -requests>=2.31.0 -readability-lxml>=0.8.1 -lxml>=5.0.0 -html2text>=2024.2.26 - -# Testing -pytest>=7.0.0 - -# LiteLLM - Unified LLM interface for 100+ providers -litellm>=1.72.6 - -# Token counting -tiktoken>=0.5.0 diff --git a/scripts/_env.sh b/scripts/_env.sh new file mode 100644 index 0000000..97d215c --- /dev/null +++ b/scripts/_env.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -euo pipefail + +if ! command -v uv >/dev/null 2>&1; then + echo "❌ uv not found. Install uv (https://github.com/astral-sh/uv) first." + exit 1 +fi + +if [[ ! -x ".venv/bin/python" ]]; then + echo "❌ .venv not found. Run ./scripts/bootstrap.sh first." + exit 1 +fi + +export PYTHON="${PYTHON:-.venv/bin/python}" diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh new file mode 100755 index 0000000..722919f --- /dev/null +++ b/scripts/bootstrap.sh @@ -0,0 +1,87 @@ +#!/bin/bash +set -euo pipefail + +usage() { + cat <<'EOF' +Bootstrap a local dev environment for AgenticLoop. + +Creates/uses `.venv`, installs `.[dev]`, and initializes `.env` (if missing). + +Usage: + ./scripts/bootstrap.sh [--no-env] [--no-dev] + +Options: + --no-env Do not create `.env` from `.env.example` + --no-dev Install without dev extras (installs `-e .` instead of `-e ".[dev]"`) +EOF +} + +INIT_ENV="true" +WITH_DEV="true" + +while [[ $# -gt 0 ]]; do + case "$1" in + --no-env) + INIT_ENV="false" + shift + ;; + --no-dev) + WITH_DEV="false" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" + usage + exit 2 + ;; + esac +done + +if ! command -v uv >/dev/null 2>&1; then + echo "❌ uv not found. Install uv (https://github.com/astral-sh/uv) first." + exit 1 +fi + +if [[ ! -d ".venv" ]]; then + echo "📦 Creating virtual environment in .venv ..." + uv venv .venv +fi + +VENV_PY=".venv/bin/python" +if [[ ! -x "$VENV_PY" ]]; then + echo "❌ .venv exists but $VENV_PY is not executable." + exit 1 +fi + +echo "⬆️ Upgrading pip ..." +uv pip install --python "$VENV_PY" -U pip + +echo "📦 Installing dependencies ..." +install_args=() +if [[ "$WITH_DEV" == "true" ]]; then + install_args=(-e ".[dev]") +else + install_args=(-e .) +fi + +# Install into the venv explicitly. +uv pip install --python "$VENV_PY" "${install_args[@]}" + +if [[ "$INIT_ENV" == "true" ]]; then + if [[ ! -f ".env" ]] && [[ -f ".env.example" ]]; then + echo "🧩 Initializing .env from .env.example ..." + cp .env.example .env + fi +fi + +echo "" +echo "✅ Bootstrap complete" +echo "" +echo "Next:" +echo " source .venv/bin/activate" +echo " ./scripts/dev.sh test -q" +echo " python main.py --task \"Calculate 1+1\"" diff --git a/scripts/build.sh b/scripts/build.sh index 5dde6ca..da49ba2 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -5,17 +5,19 @@ set -e # Exit on error echo "🔨 Building AgenticLoop package..." +source ./scripts/_env.sh + # Clean previous builds echo "Cleaning previous builds..." rm -rf build/ dist/ *.egg-info # Install build tools echo "Installing build tools..." -python3 -m pip install --upgrade build twine +uv pip install --python "$PYTHON" --upgrade build twine # Build the package echo "Building package..." -python3 -m build +"$PYTHON" -m build echo "✅ Build complete! Distribution files are in dist/" ls -lh dist/ diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..c9c2dcf --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,71 @@ +#!/bin/bash +set -euo pipefail + +usage() { + cat <<'EOF' +AgenticLoop dev workflow helper. + +Usage: + ./scripts/dev.sh [args...] + +Commands: + install Install editable with dev deps (requires .venv; use bootstrap for creation) + test Run pytest (passes args through) + format Run black + isort + lint Check formatting (black/isort) + precommit Run pre-commit on all files + typecheck Run mypy (best-effort; set TYPECHECK_STRICT=1 to fail) + build Build dist/ artifacts + publish Publish dist/ via twine (see scripts/publish.sh) + +Examples: + ./scripts/dev.sh install + ./scripts/dev.sh test -q + ./scripts/dev.sh format + ./scripts/dev.sh lint + ./scripts/dev.sh precommit + ./scripts/dev.sh typecheck + ./scripts/dev.sh build + ./scripts/dev.sh publish --test +EOF +} + +cmd="${1:-}" +if [[ -z "$cmd" ]] || [[ "$cmd" == "-h" ]] || [[ "$cmd" == "--help" ]]; then + usage + exit 0 +fi +shift || true + +case "$cmd" in + install) + source ./scripts/_env.sh + uv pip install --python "$PYTHON" -e ".[dev]" "$@" + ;; + test) + ./scripts/test.sh "$@" + ;; + format) + ./scripts/format.sh "$@" + ;; + lint) + ./scripts/lint.sh "$@" + ;; + precommit) + ./scripts/precommit.sh "$@" + ;; + typecheck) + ./scripts/typecheck.sh "$@" + ;; + build) + ./scripts/build.sh "$@" + ;; + publish) + ./scripts/publish.sh "$@" + ;; + *) + echo "Unknown command: $cmd" + usage + exit 2 + ;; +esac diff --git a/scripts/format.sh b/scripts/format.sh new file mode 100755 index 0000000..126334a --- /dev/null +++ b/scripts/format.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -euo pipefail + +source ./scripts/_env.sh + +"$PYTHON" -m black . +"$PYTHON" -m isort . diff --git a/scripts/install_local.sh b/scripts/install_local.sh deleted file mode 100755 index f910b8a..0000000 --- a/scripts/install_local.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash -# Install the package locally for development/testing - -set -e - -echo "📦 Installing AgenticLoop locally..." - -# Install in editable mode with dependencies -python3 -m pip install -e . - -echo "" -echo "✅ Installation complete!" -echo "" -echo "Try it out:" -echo " aloop --help" -echo " aloop interactive" -echo "" -echo "Or import in Python:" -echo " from agent.react_agent import ReActAgent" diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 0000000..8ea9bcd --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -euo pipefail + +source ./scripts/_env.sh + +"$PYTHON" -m black --check . +"$PYTHON" -m isort --check-only . +"$PYTHON" -m ruff check . diff --git a/scripts/precommit.sh b/scripts/precommit.sh new file mode 100755 index 0000000..b29e8ab --- /dev/null +++ b/scripts/precommit.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -euo pipefail + +source ./scripts/_env.sh + +"$PYTHON" -m pre_commit run --all-files diff --git a/scripts/publish.sh b/scripts/publish.sh index 5d222cb..e388521 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -5,6 +5,55 @@ set -e echo "🚀 Publishing AgenticLoop to PyPI..." +source ./scripts/_env.sh + +usage() { + cat <<'EOF' +Usage: + ./scripts/publish.sh [--repository ] [--test] [--yes] + +Options: + --repository Twine repository (default: pypi) + --test Shortcut for --repository testpypi + --yes Skip confirmation prompt (dangerous) +EOF +} + +REPOSITORY="pypi" +YES="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --repository) + REPOSITORY="${2:-}" + shift 2 + ;; + --test) + REPOSITORY="testpypi" + shift + ;; + --yes) + YES="true" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" + usage + exit 2 + ;; + esac +done + +if [[ -z "$REPOSITORY" ]]; then + echo "❌ Missing value for --repository" + usage + exit 2 +fi + # Check if dist/ exists if [ ! -d "dist" ]; then echo "❌ No dist/ directory found. Run ./scripts/build.sh first." @@ -12,20 +61,29 @@ if [ ! -d "dist" ]; then fi # Install twine if needed -python3 -m pip install --upgrade twine +uv pip install --python "$PYTHON" --upgrade twine echo "" -echo "⚠️ This will upload to PyPI (production)." -echo "For testing, use: twine upload --repository testpypi dist/*" -read -p "Continue? (y/N) " -n 1 -r -echo - -if [[ $REPLY =~ ^[Yy]$ ]]; then - # Upload to PyPI - twine upload dist/* - echo "" - echo "✅ Published to PyPI!" - echo "Install with: pip install AgenticLoop" -else - echo "Cancelled." +echo "⚠️ This will upload to repository: $REPOSITORY" +echo "Dist files:" +ls -lh dist/ || true + +if [[ "$YES" != "true" ]]; then + if [[ -t 0 ]]; then + read -p "Continue? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Cancelled." + exit 1 + fi + else + echo "❌ Refusing to publish without a TTY. Re-run with --yes if you really mean it." + exit 1 + fi fi + +# Upload +"$PYTHON" -m twine upload --repository "$REPOSITORY" dist/* + +echo "" +echo "✅ Published!" diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..a3d4223 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -euo pipefail + +source ./scripts/_env.sh + +"$PYTHON" -m pytest test/ "$@" diff --git a/scripts/typecheck.sh b/scripts/typecheck.sh new file mode 100755 index 0000000..624ff33 --- /dev/null +++ b/scripts/typecheck.sh @@ -0,0 +1,18 @@ +#!/bin/bash +set -euo pipefail + +STRICT="${TYPECHECK_STRICT:-0}" + +source ./scripts/_env.sh + +set +e +"$PYTHON" -m mypy \ + agent llm memory tools utils main.py config.py +status=$? +set -e + +if [[ "$status" -ne 0 ]] && [[ "$STRICT" == "1" ]]; then + exit "$status" +fi + +exit 0 diff --git a/setup.py b/setup.py deleted file mode 100644 index fdfcf4c..0000000 --- a/setup.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Setup script for backwards compatibility.""" -from setuptools import setup - -# Configuration is in pyproject.toml -setup() diff --git a/test/README.md b/test/README.md index 2b13c6d..6e375f5 100644 --- a/test/README.md +++ b/test/README.md @@ -11,10 +11,11 @@ This directory contains test files for the AgenticLoop project. ### Prerequisites -Make sure you have installed all dependencies: +Bootstrap a local dev environment (recommended): ```bash -pip install -r requirements.txt +./scripts/bootstrap.sh +source .venv/bin/activate ``` ### Run Individual Tests @@ -22,11 +23,9 @@ pip install -r requirements.txt From the project root directory: ```bash -# Run basic tests -python3 test/test_basic.py - -# Run memory tests -python3 test/test_memory.py +# Run a subset (fast) +python3 -m pytest test/test_basic.py -q +python3 -m pytest test/memory/ -q ``` ### Run All Tests @@ -34,9 +33,6 @@ python3 test/test_memory.py ```bash # From project root python3 -m pytest test/ - -# Or run all test files -for test in test/test_*.py; do python3 "$test"; done ``` ## Test Coverage @@ -46,6 +42,6 @@ for test in test/test_*.py; do python3 "$test"; done ## Notes -- Some tests may require environment variables (e.g., `ANTHROPIC_API_KEY`) +- Live LLM integration tests are skipped by default (set `RUN_INTEGRATION_TESTS=1` to enable). - Set up your `.env` file before running tests that require API access - Memory tests use a mock LLM and don't require API keys diff --git a/test/memory/conftest.py b/test/memory/conftest.py index 9419946..772797f 100644 --- a/test/memory/conftest.py +++ b/test/memory/conftest.py @@ -1,5 +1,7 @@ """Pytest fixtures for memory module tests.""" + import pytest + from llm.base import LLMMessage, LLMResponse @@ -21,7 +23,7 @@ def call(self, messages, tools=None, max_tokens=4096, **kwargs): return LLMResponse( message=self.response_text, stop_reason="end_turn", - usage={"input_tokens": 100, "output_tokens": 50} + usage={"input_tokens": 100, "output_tokens": 50}, ) def extract_text(self, response): @@ -74,24 +76,14 @@ def tool_use_messages(): "type": "tool_use", "id": "tool_1", "name": "calculator", - "input": {"expression": "2+2"} - } - ] + "input": {"expression": "2+2"}, + }, + ], ), LLMMessage( - role="user", - content=[ - { - "type": "tool_result", - "tool_use_id": "tool_1", - "content": "4" - } - ] - ), - LLMMessage( - role="assistant", - content="The result is 4." + role="user", content=[{"type": "tool_result", "tool_use_id": "tool_1", "content": "4"}] ), + LLMMessage(role="assistant", content="The result is 4."), ] @@ -108,24 +100,17 @@ def protected_tool_messages(): "type": "tool_use", "id": "tool_todo_1", "name": "manage_todo_list", - "input": {"action": "add", "item": "Test item"} - } - ] + "input": {"action": "add", "item": "Test item"}, + }, + ], ), LLMMessage( role="user", content=[ - { - "type": "tool_result", - "tool_use_id": "tool_todo_1", - "content": "Todo item added" - } - ] - ), - LLMMessage( - role="assistant", - content="Todo item has been added." + {"type": "tool_result", "tool_use_id": "tool_todo_1", "content": "Todo item added"} + ], ), + LLMMessage(role="assistant", content="Todo item has been added."), ] @@ -136,36 +121,16 @@ def mismatched_tool_messages(): LLMMessage(role="user", content="Do something"), LLMMessage( role="assistant", - content=[ - { - "type": "tool_use", - "id": "tool_1", - "name": "tool_a", - "input": {} - } - ] + content=[{"type": "tool_use", "id": "tool_1", "name": "tool_a", "input": {}}], ), # Missing tool_result for tool_1 LLMMessage(role="user", content="Another request"), LLMMessage( role="assistant", - content=[ - { - "type": "tool_use", - "id": "tool_2", - "name": "tool_b", - "input": {} - } - ] + content=[{"type": "tool_use", "id": "tool_2", "name": "tool_b", "input": {}}], ), LLMMessage( role="user", - content=[ - { - "type": "tool_result", - "tool_use_id": "tool_2", - "content": "result" - } - ] + content=[{"type": "tool_result", "tool_use_id": "tool_2", "content": "result"}], ), ] diff --git a/test/memory/test_compressor.py b/test/memory/test_compressor.py index 998058f..eb53d93 100644 --- a/test/memory/test_compressor.py +++ b/test/memory/test_compressor.py @@ -1,8 +1,8 @@ """Unit tests for WorkingMemoryCompressor.""" -import pytest -from memory.compressor import WorkingMemoryCompressor -from memory.types import MemoryConfig, CompressionStrategy + from llm.base import LLMMessage +from memory.compressor import WorkingMemoryCompressor +from memory.types import CompressionStrategy, MemoryConfig class TestCompressorBasics: @@ -47,9 +47,7 @@ def test_sliding_window_strategy(self, mock_llm, simple_messages): compressor = WorkingMemoryCompressor(mock_llm, config) result = compressor.compress( - simple_messages, - strategy=CompressionStrategy.SLIDING_WINDOW, - target_tokens=100 + simple_messages, strategy=CompressionStrategy.SLIDING_WINDOW, target_tokens=100 ) assert result is not None @@ -63,10 +61,7 @@ def test_deletion_strategy(self, mock_llm, simple_messages): config = MemoryConfig() compressor = WorkingMemoryCompressor(mock_llm, config) - result = compressor.compress( - simple_messages, - strategy=CompressionStrategy.DELETION - ) + result = compressor.compress(simple_messages, strategy=CompressionStrategy.DELETION) assert result is not None assert result.summary == "" @@ -80,9 +75,7 @@ def test_selective_strategy_with_tools(self, mock_llm, tool_use_messages): compressor = WorkingMemoryCompressor(mock_llm, config) result = compressor.compress( - tool_use_messages, - strategy=CompressionStrategy.SELECTIVE, - target_tokens=200 + tool_use_messages, strategy=CompressionStrategy.SELECTIVE, target_tokens=200 ) assert result is not None @@ -102,9 +95,7 @@ def test_selective_strategy_preserves_system_messages(self, mock_llm): ] result = compressor.compress( - messages, - strategy=CompressionStrategy.SELECTIVE, - target_tokens=100 + messages, strategy=CompressionStrategy.SELECTIVE, target_tokens=100 ) # System message should be preserved @@ -138,20 +129,31 @@ def test_find_tool_pairs_multiple(self, mock_llm): messages = [] for i in range(3): - messages.extend([ - LLMMessage( - role="assistant", - content=[ - {"type": "tool_use", "id": f"tool_{i}", "name": f"tool_{i}", "input": {}} - ] - ), - LLMMessage( - role="user", - content=[ - {"type": "tool_result", "tool_use_id": f"tool_{i}", "content": f"result_{i}"} - ] - ), - ]) + messages.extend( + [ + LLMMessage( + role="assistant", + content=[ + { + "type": "tool_use", + "id": f"tool_{i}", + "name": f"tool_{i}", + "input": {}, + } + ], + ), + LLMMessage( + role="user", + content=[ + { + "type": "tool_result", + "tool_use_id": f"tool_{i}", + "content": f"result_{i}", + } + ], + ), + ] + ) pairs, orphaned = compressor._find_tool_pairs(messages) assert len(pairs) == 3 @@ -228,7 +230,10 @@ def test_protected_tools_always_preserved(self, mock_llm, protected_tool_message if isinstance(msg.content, list): for block in msg.content: if isinstance(block, dict): - if block.get("type") == "tool_use" and block.get("name") == "manage_todo_list": + if ( + block.get("type") == "tool_use" + and block.get("name") == "manage_todo_list" + ): found_protected = True break @@ -309,13 +314,15 @@ def test_tool_pair_preservation_rule(self, mock_llm, tool_use_messages): # CRITICAL: Tool pairs should not be split between preserved and compressed # If a tool_use is preserved, its result should be preserved for tool_id in preserved_tool_use_ids: - assert tool_id in preserved_tool_result_ids, \ - f"Tool use {tool_id} is preserved but its result is not" + assert ( + tool_id in preserved_tool_result_ids + ), f"Tool use {tool_id} is preserved but its result is not" # If a tool_result is preserved, its use should be preserved for tool_id in preserved_tool_result_ids: - assert tool_id in preserved_tool_use_ids, \ - f"Tool result for {tool_id} is preserved but its use is not" + assert ( + tool_id in preserved_tool_use_ids + ), f"Tool result for {tool_id} is preserved but its use is not" class TestTokenEstimation: @@ -364,8 +371,8 @@ def test_extract_text_content_from_dict(self, mock_llm): role="assistant", content=[ {"type": "text", "text": "Hello"}, - {"type": "tool_use", "id": "t1", "name": "tool", "input": {}} - ] + {"type": "tool_use", "id": "t1", "name": "tool", "input": {}}, + ], ) text = compressor._extract_text_content(msg) @@ -381,9 +388,7 @@ def test_compression_ratio_calculation(self, mock_llm, simple_messages): compressor = WorkingMemoryCompressor(mock_llm, config) result = compressor.compress( - simple_messages, - strategy=CompressionStrategy.SLIDING_WINDOW, - target_tokens=50 + simple_messages, strategy=CompressionStrategy.SLIDING_WINDOW, target_tokens=50 ) assert result.compression_ratio > 0 @@ -396,10 +401,7 @@ def test_token_savings_calculation(self, mock_llm, simple_messages): config = MemoryConfig() compressor = WorkingMemoryCompressor(mock_llm, config) - result = compressor.compress( - simple_messages, - strategy=CompressionStrategy.SLIDING_WINDOW - ) + result = compressor.compress(simple_messages, strategy=CompressionStrategy.SLIDING_WINDOW) savings = result.token_savings assert savings >= 0 @@ -410,10 +412,7 @@ def test_savings_percentage_calculation(self, mock_llm, simple_messages): config = MemoryConfig() compressor = WorkingMemoryCompressor(mock_llm, config) - result = compressor.compress( - simple_messages, - strategy=CompressionStrategy.SLIDING_WINDOW - ) + result = compressor.compress(simple_messages, strategy=CompressionStrategy.SLIDING_WINDOW) percentage = result.savings_percentage assert 0 <= percentage <= 100 @@ -434,10 +433,7 @@ def error_call(*args, **kwargs): mock_llm.call = error_call # Should handle error gracefully - result = compressor.compress( - simple_messages, - strategy=CompressionStrategy.SLIDING_WINDOW - ) + result = compressor.compress(simple_messages, strategy=CompressionStrategy.SLIDING_WINDOW) assert result is not None # Should fallback to preserving key messages @@ -450,10 +446,7 @@ def test_unknown_strategy_fallback(self, mock_llm, simple_messages): compressor = WorkingMemoryCompressor(mock_llm, config) # Use invalid strategy name - result = compressor.compress( - simple_messages, - strategy="invalid_strategy" - ) + result = compressor.compress(simple_messages, strategy="invalid_strategy") # Should fallback to sliding window assert result is not None diff --git a/test/memory/test_integration.py b/test/memory/test_integration.py index 8a6f967..84b890c 100644 --- a/test/memory/test_integration.py +++ b/test/memory/test_integration.py @@ -3,10 +3,10 @@ These tests verify that different components work together correctly, especially focusing on edge cases and the tool_call/tool_result matching issue. """ -import pytest + +from llm.base import LLMMessage from memory import MemoryConfig, MemoryManager from memory.types import CompressionStrategy -from llm.base import LLMMessage class TestToolCallResultIntegration: @@ -26,22 +26,33 @@ def test_tool_pairs_survive_compression_cycle(self, mock_llm): # Add a sequence of tool calls messages = [] for i in range(3): - messages.extend([ - LLMMessage(role="user", content=f"Request {i}"), - LLMMessage( - role="assistant", - content=[ - {"type": "tool_use", "id": f"tool_{i}", "name": f"tool_{i}", "input": {}} - ] - ), - LLMMessage( - role="user", - content=[ - {"type": "tool_result", "tool_use_id": f"tool_{i}", "content": f"result_{i}"} - ] - ), - LLMMessage(role="assistant", content=f"Response {i}"), - ]) + messages.extend( + [ + LLMMessage(role="user", content=f"Request {i}"), + LLMMessage( + role="assistant", + content=[ + { + "type": "tool_use", + "id": f"tool_{i}", + "name": f"tool_{i}", + "input": {}, + } + ], + ), + LLMMessage( + role="user", + content=[ + { + "type": "tool_result", + "tool_use_id": f"tool_{i}", + "content": f"result_{i}", + } + ], + ), + LLMMessage(role="assistant", content=f"Response {i}"), + ] + ) # Add messages and trigger compression for msg in messages: @@ -68,16 +79,25 @@ def test_tool_pairs_with_multiple_compressions(self, mock_llm): LLMMessage( role="assistant", content=[ - {"type": "tool_use", "id": f"tool_{idx}", "name": f"tool_{idx}", "input": {}} - ] + { + "type": "tool_use", + "id": f"tool_{idx}", + "name": f"tool_{idx}", + "input": {}, + } + ], ) ) manager.add_message( LLMMessage( role="user", content=[ - {"type": "tool_result", "tool_use_id": f"tool_{idx}", "content": f"result_{idx}"} - ] + { + "type": "tool_result", + "tool_use_id": f"tool_{idx}", + "content": f"result_{idx}", + } + ], ) ) manager.add_message(LLMMessage(role="assistant", content=f"Response {idx}")) @@ -99,7 +119,7 @@ def test_interleaved_tool_calls(self, mock_llm): content=[ {"type": "tool_use", "id": "tool_1", "name": "tool_a", "input": {}}, {"type": "tool_use", "id": "tool_2", "name": "tool_b", "input": {}}, - ] + ], ) ) # Results come back together @@ -109,7 +129,7 @@ def test_interleaved_tool_calls(self, mock_llm): content=[ {"type": "tool_result", "tool_use_id": "tool_1", "content": "result_1"}, {"type": "tool_result", "tool_use_id": "tool_2", "content": "result_2"}, - ] + ], ) ) manager.add_message(LLMMessage(role="assistant", content="Final response")) @@ -130,9 +150,7 @@ def test_orphaned_tool_use_detection(self, mock_llm): manager.add_message( LLMMessage( role="assistant", - content=[ - {"type": "tool_use", "id": "orphan_tool", "name": "tool", "input": {}} - ] + content=[{"type": "tool_use", "id": "orphan_tool", "name": "tool", "input": {}}], ) ) # Missing tool_result! @@ -173,7 +191,7 @@ def test_orphaned_tool_result_detection(self, mock_llm): role="user", content=[ {"type": "tool_result", "tool_use_id": "phantom_tool", "content": "result"} - ] + ], ) ) @@ -213,8 +231,9 @@ def _verify_tool_pairs_matched(self, messages): elif block.get("type") == "tool_result": tool_result_ids.add(block.get("tool_use_id")) - assert tool_use_ids == tool_result_ids, \ - f"Mismatched tools: use={tool_use_ids}, result={tool_result_ids}" + assert ( + tool_use_ids == tool_result_ids + ), f"Mismatched tools: use={tool_use_ids}, result={tool_result_ids}" class TestCompressionIntegration: @@ -231,7 +250,9 @@ def test_full_conversation_lifecycle(self, mock_llm): # Simulate a long conversation for i in range(20): manager.add_message(LLMMessage(role="user", content=f"User message {i} " * 20)) - manager.add_message(LLMMessage(role="assistant", content=f"Assistant response {i} " * 20)) + manager.add_message( + LLMMessage(role="assistant", content=f"Assistant response {i} " * 20) + ) stats = manager.get_stats() @@ -262,14 +283,13 @@ def test_mixed_content_conversation(self, mock_llm): role="assistant", content=[ {"type": "text", "text": "I'll use the tool"}, - {"type": "tool_use", "id": "t1", "name": "calculator", "input": {}} - ] + {"type": "tool_use", "id": "t1", "name": "calculator", "input": {}}, + ], ) ) manager.add_message( LLMMessage( - role="user", - content=[{"type": "tool_result", "tool_use_id": "t1", "content": "42"}] + role="user", content=[{"type": "tool_result", "tool_use_id": "t1", "content": "42"}] ) ) @@ -369,13 +389,13 @@ def test_alternating_compression_strategies(self, mock_llm): manager.add_message( LLMMessage( role="assistant", - content=[{"type": "tool_use", "id": "t1", "name": "tool", "input": {}}] + content=[{"type": "tool_use", "id": "t1", "name": "tool", "input": {}}], ) ) manager.add_message( LLMMessage( role="user", - content=[{"type": "tool_result", "tool_use_id": "t1", "content": "result"}] + content=[{"type": "tool_result", "tool_use_id": "t1", "content": "result"}], ) ) @@ -397,7 +417,7 @@ def test_empty_content_blocks(self, mock_llm): content=[ {"type": "text", "text": ""}, {"type": "text", "text": "Actual content"}, - ] + ], ) ) @@ -448,7 +468,7 @@ def test_reuse_after_reset(self, mock_llm): """Test that manager can be reused after reset.""" config = MemoryConfig( short_term_message_count=10, # Large enough to avoid compression - target_working_memory_tokens=100000 + target_working_memory_tokens=100000, ) manager = MemoryManager(config, mock_llm) diff --git a/test/memory/test_memory_manager.py b/test/memory/test_memory_manager.py index db814eb..5fa8594 100644 --- a/test/memory/test_memory_manager.py +++ b/test/memory/test_memory_manager.py @@ -1,8 +1,8 @@ """Unit tests for MemoryManager.""" -import pytest + +from llm.base import LLMMessage from memory import MemoryConfig, MemoryManager from memory.types import CompressionStrategy -from llm.base import LLMMessage class TestMemoryManagerBasics: @@ -225,8 +225,9 @@ def test_tool_pairs_preserved_together(self, mock_llm, tool_use_messages): tool_result_ids.add(block.get("tool_use_id")) # Every tool_use should have a matching tool_result - assert tool_use_ids == tool_result_ids, \ - f"Mismatched tool calls: tool_use_ids={tool_use_ids}, tool_result_ids={tool_result_ids}" + assert ( + tool_use_ids == tool_result_ids + ), f"Mismatched tool calls: tool_use_ids={tool_use_ids}, tool_result_ids={tool_result_ids}" def test_mismatched_tool_calls_detected(self, mock_llm, mismatched_tool_messages): """Test behavior with mismatched tool_use/tool_result pairs.""" @@ -264,7 +265,9 @@ def test_mismatched_tool_calls_detected(self, mock_llm, mismatched_tool_messages if tool_use_ids != tool_result_ids: missing_results = tool_use_ids - tool_result_ids missing_uses = tool_result_ids - tool_use_ids - print(f"Detected mismatch - missing results: {missing_results}, missing uses: {missing_uses}") + print( + f"Detected mismatch - missing results: {missing_results}, missing uses: {missing_uses}" + ) def test_protected_tool_always_preserved(self, mock_llm, protected_tool_messages): """Test that protected tools (like manage_todo_list) are always preserved.""" @@ -293,7 +296,10 @@ def test_protected_tool_always_preserved(self, mock_llm, protected_tool_messages if isinstance(msg.content, list): for block in msg.content: if isinstance(block, dict): - if block.get("type") == "tool_use" and block.get("name") == "manage_todo_list": + if ( + block.get("type") == "tool_use" + and block.get("name") == "manage_todo_list" + ): found_protected = True break @@ -310,31 +316,33 @@ def test_multiple_tool_pairs_in_sequence(self, mock_llm): # Create multiple tool pairs messages = [] for i in range(3): - messages.extend([ - LLMMessage(role="user", content=f"Request {i}"), - LLMMessage( - role="assistant", - content=[ - { - "type": "tool_use", - "id": f"tool_{i}", - "name": f"tool_{i}", - "input": {} - } - ] - ), - LLMMessage( - role="user", - content=[ - { - "type": "tool_result", - "tool_use_id": f"tool_{i}", - "content": f"result_{i}" - } - ] - ), - LLMMessage(role="assistant", content=f"Response {i}"), - ]) + messages.extend( + [ + LLMMessage(role="user", content=f"Request {i}"), + LLMMessage( + role="assistant", + content=[ + { + "type": "tool_use", + "id": f"tool_{i}", + "name": f"tool_{i}", + "input": {}, + } + ], + ), + LLMMessage( + role="user", + content=[ + { + "type": "tool_result", + "tool_use_id": f"tool_{i}", + "content": f"result_{i}", + } + ], + ), + LLMMessage(role="assistant", content=f"Response {i}"), + ] + ) for msg in messages: manager.add_message(msg) @@ -406,12 +414,12 @@ def test_compression_with_mixed_content(self, mock_llm): role="assistant", content=[ {"type": "text", "text": "Response with tool"}, - {"type": "tool_use", "id": "t1", "name": "tool", "input": {}} - ] + {"type": "tool_use", "id": "t1", "name": "tool", "input": {}}, + ], ), LLMMessage( role="user", - content=[{"type": "tool_result", "tool_use_id": "t1", "content": "result"}] + content=[{"type": "tool_result", "tool_use_id": "t1", "content": "result"}], ), LLMMessage(role="assistant", content="Final response"), ] diff --git a/test/memory/test_short_term.py b/test/memory/test_short_term.py index 6afff21..d8fff26 100644 --- a/test/memory/test_short_term.py +++ b/test/memory/test_short_term.py @@ -1,7 +1,7 @@ """Unit tests for ShortTermMemory.""" -import pytest -from memory.short_term import ShortTermMemory + from llm.base import LLMMessage +from memory.short_term import ShortTermMemory class TestShortTermMemoryBasics: @@ -272,7 +272,7 @@ def test_message_with_complex_content(self): content=[ {"type": "text", "text": "Hello"}, {"type": "tool_use", "id": "t1", "name": "tool", "input": {"key": "value"}}, - ] + ], ) stm.add_message(complex_msg) diff --git a/test/memory/test_store.py b/test/memory/test_store.py index 537654d..628f870 100644 --- a/test/memory/test_store.py +++ b/test/memory/test_store.py @@ -1,12 +1,14 @@ """Unit tests for MemoryStore (database persistence).""" -import pytest -import tempfile + import os +import tempfile from pathlib import Path -from memory.store import MemoryStore -from memory.types import MemoryConfig, CompressedMemory +import pytest + from llm.base import LLMMessage +from memory.store import MemoryStore +from memory.types import CompressedMemory @pytest.fixture @@ -21,7 +23,7 @@ def temp_db(): # Cleanup try: os.unlink(path) - except: + except OSError: pass @@ -112,8 +114,8 @@ def test_save_message_with_complex_content(self, store): role="assistant", content=[ {"type": "text", "text": "I'll use a tool"}, - {"type": "tool_use", "id": "tool_1", "name": "calculator", "input": {}} - ] + {"type": "tool_use", "id": "tool_1", "name": "calculator", "input": {}}, + ], ) store.save_message(session_id, msg, tokens=20) @@ -133,9 +135,7 @@ def test_save_memory(self, store): session_id = store.create_session() # Create memory components - system_messages = [ - LLMMessage(role="system", content="You are helpful") - ] + system_messages = [LLMMessage(role="system", content="You are helpful")] messages = [ LLMMessage(role="user", content="Hello"), @@ -150,7 +150,7 @@ def test_save_memory(self, store): original_message_count=5, original_tokens=500, compressed_tokens=150, - compression_ratio=0.3 + compression_ratio=0.3, ) ] @@ -177,7 +177,7 @@ def test_save_memory_replaces_content(self, store): session_id, [LLMMessage(role="system", content="First")], [LLMMessage(role="user", content="Message 1")], - [] + [], ) # Second save (should replace, not append) @@ -185,7 +185,7 @@ def test_save_memory_replaces_content(self, store): session_id, [LLMMessage(role="system", content="Second")], [LLMMessage(role="user", content="Message 2")], - [] + [], ) # Load and verify - should only have second save's content @@ -205,14 +205,12 @@ def test_save_summary(self, store): summary = CompressedMemory( summary="This is a summary", - preserved_messages=[ - LLMMessage(role="user", content="Important message") - ], + preserved_messages=[LLMMessage(role="user", content="Important message")], original_message_count=10, original_tokens=1000, compressed_tokens=300, compression_ratio=0.3, - metadata={"strategy": "selective"} + metadata={"strategy": "selective"}, ) store.save_summary(session_id, summary) @@ -239,7 +237,7 @@ def test_save_multiple_summaries(self, store): original_message_count=5, original_tokens=500, compressed_tokens=150, - compression_ratio=0.3 + compression_ratio=0.3, ) store.save_summary(session_id, summary) @@ -297,7 +295,7 @@ def test_get_session_stats(self, store): original_message_count=5, original_tokens=500, compressed_tokens=150, - compression_ratio=0.3 + compression_ratio=0.3, ) store.save_summary(session_id, summary) @@ -339,7 +337,6 @@ def test_delete_nonexistent_session(self, store): assert not success - class TestIntegration: """Integration tests for complete workflows.""" @@ -350,22 +347,16 @@ def test_complete_session_lifecycle(self, store): # Add system message store.save_message( - session_id, - LLMMessage(role="system", content="You are helpful"), - tokens=0 + session_id, LLMMessage(role="system", content="You are helpful"), tokens=0 ) # Add conversation for i in range(10): store.save_message( - session_id, - LLMMessage(role="user", content=f"Question {i}"), - tokens=5 + session_id, LLMMessage(role="user", content=f"Question {i}"), tokens=5 ) store.save_message( - session_id, - LLMMessage(role="assistant", content=f"Answer {i}"), - tokens=10 + session_id, LLMMessage(role="assistant", content=f"Answer {i}"), tokens=10 ) # Add summaries @@ -376,7 +367,7 @@ def test_complete_session_lifecycle(self, store): original_message_count=5, original_tokens=75, compressed_tokens=25, - compression_ratio=0.33 + compression_ratio=0.33, ) store.save_summary(session_id, summary) diff --git a/test/test_basic.py b/test/test_basic.py index f2ee2c3..e383965 100644 --- a/test/test_basic.py +++ b/test/test_basic.py @@ -1,79 +1,45 @@ -"""Basic test to verify the agentic loop system works.""" +"""Basic tests to verify core tools and imports work. + +These tests are intentionally offline and should not require any API keys. +""" + import os -import sys -# Add parent directory to path -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -# Test imports -try: - from config import Config - from agent.react_agent import ReActAgent +def test_imports(): + from agent.react_agent import ReActAgent # noqa: F401 + from config import Config # noqa: F401 + from tools.calculator import CalculatorTool # noqa: F401 + from tools.file_ops import FileReadTool, FileWriteTool # noqa: F401 + + +def test_tools_execute_and_cleanup(tmp_path): from tools.calculator import CalculatorTool from tools.file_ops import FileReadTool, FileWriteTool - print("✓ All imports successful") -except Exception as e: - print(f"✗ Import failed: {e}") - sys.exit(1) - -# Test tool instantiation -try: calc = CalculatorTool() - print(f"✓ Calculator tool created: {calc.name}") - file_read = FileReadTool() - print(f"✓ File read tool created: {file_read.name}") - file_write = FileWriteTool() - print(f"✓ File write tool created: {file_write.name}") -except Exception as e: - print(f"✗ Tool instantiation failed: {e}") - sys.exit(1) -# Test tool execution -try: result = calc.execute(code="print(2 + 2)") - print(f"✓ Calculator execution: {result.strip()}") + assert result.strip() == "4" + + test_file = tmp_path / "test_temp.txt" + write_result = file_write.execute(file_path=str(test_file), content="Hello, Agent!") + assert "Successfully wrote" in write_result + + read_result = file_read.execute(file_path=str(test_file)) + assert "Hello, Agent!" in read_result - # Test file write and read - test_file = "test_temp.txt" - write_result = file_write.execute(file_path=test_file, content="Hello, Agent!") - print(f"✓ File write: {write_result}") + assert test_file.exists() + os.remove(test_file) + assert not test_file.exists() - read_result = file_read.execute(file_path=test_file) - print(f"✓ File read: {read_result}") - # Cleanup - if os.path.exists(test_file): - os.remove(test_file) - print("✓ Cleanup successful") -except Exception as e: - print(f"✗ Tool execution failed: {e}") - sys.exit(1) +def test_tool_schema_generation(): + from tools.calculator import CalculatorTool -# Test tool schema generation -try: - schema = calc.to_anthropic_schema() + schema = CalculatorTool().to_anthropic_schema() assert "name" in schema assert "description" in schema assert "input_schema" in schema - print(f"✓ Tool schema generation successful") -except Exception as e: - print(f"✗ Schema generation failed: {e}") - sys.exit(1) - -# Check API key -if not Config.ANTHROPIC_API_KEY: - print("\n⚠ Warning: ANTHROPIC_API_KEY not set") - print("To use the agent, create a .env file with:") - print("ANTHROPIC_API_KEY=your_api_key_here") -else: - print(f"\n✓ API key configured (starts with: {Config.ANTHROPIC_API_KEY[:8]}...)") - -print("\n" + "=" * 60) -print("All basic tests passed!") -print("=" * 60) -print("\nTo run the agent:") -print(" source venv/bin/activate") -print(" python main.py --task 'Calculate 123 * 456'") diff --git a/test/test_code_navigator.py b/test/test_code_navigator.py index 26e5258..3f4a508 100644 --- a/test/test_code_navigator.py +++ b/test/test_code_navigator.py @@ -1,8 +1,5 @@ """Tests for CodeNavigatorTool.""" -import tempfile -from pathlib import Path - import pytest from tools.code_navigator import CodeNavigatorTool @@ -13,7 +10,8 @@ def sample_python_files(tmp_path): """Create sample Python files for testing.""" # File 1: base.py with class and functions base_file = tmp_path / "base.py" - base_file.write_text('''"""Base module.""" + base_file.write_text( + '''"""Base module.""" import os from typing import List, Dict @@ -40,11 +38,13 @@ def transform_task(task: str) -> str: def helper_function(x, y): """Helper for calculations.""" return x + y -''') +''' + ) # File 2: agent.py with subclass agent_file = tmp_path / "agent.py" - agent_file.write_text('''"""Agent implementation.""" + agent_file.write_text( + '''"""Agent implementation.""" from base import BaseAgent, transform_task class ReActAgent(BaseAgent): @@ -62,11 +62,13 @@ def run(self, task: str) -> str: def _execute(self, task: str) -> str: """Execute with iterations.""" return task -''') +''' + ) # File 3: utils.py with utility functions utils_file = tmp_path / "utils.py" - utils_file.write_text('''"""Utility functions.""" + utils_file.write_text( + '''"""Utility functions.""" def transform_task(text: str) -> str: """Transform text.""" @@ -75,7 +77,8 @@ def transform_task(text: str) -> str: def calculate(a: int, b: int) -> int: """Calculate sum.""" return a + b -''') +''' + ) return tmp_path @@ -92,9 +95,7 @@ class TestFindFunction: def test_find_single_function(self, tool, sample_python_files): """Test finding a function that exists once.""" result = tool.execute( - target="helper_function", - search_type="find_function", - path=str(sample_python_files) + target="helper_function", search_type="find_function", path=str(sample_python_files) ) assert "helper_function" in result @@ -105,9 +106,7 @@ def test_find_single_function(self, tool, sample_python_files): def test_find_multiple_functions(self, tool, sample_python_files): """Test finding a function that exists in multiple files.""" result = tool.execute( - target="transform_task", - search_type="find_function", - path=str(sample_python_files) + target="transform_task", search_type="find_function", path=str(sample_python_files) ) assert "Found 2 function(s)" in result @@ -119,7 +118,7 @@ def test_function_not_found(self, tool, sample_python_files): result = tool.execute( target="nonexistent_function", search_type="find_function", - path=str(sample_python_files) + path=str(sample_python_files), ) assert "No function named" in result @@ -128,9 +127,7 @@ def test_function_not_found(self, tool, sample_python_files): def test_function_with_type_hints(self, tool, sample_python_files): """Test that type hints are captured in signature.""" result = tool.execute( - target="run", - search_type="find_function", - path=str(sample_python_files) + target="run", search_type="find_function", path=str(sample_python_files) ) assert "task: str" in result @@ -143,9 +140,7 @@ class TestFindClass: def test_find_class(self, tool, sample_python_files): """Test finding a class.""" result = tool.execute( - target="BaseAgent", - search_type="find_class", - path=str(sample_python_files) + target="BaseAgent", search_type="find_class", path=str(sample_python_files) ) assert "BaseAgent" in result @@ -158,9 +153,7 @@ def test_find_class(self, tool, sample_python_files): def test_find_subclass(self, tool, sample_python_files): """Test finding a subclass shows inheritance.""" result = tool.execute( - target="ReActAgent", - search_type="find_class", - path=str(sample_python_files) + target="ReActAgent", search_type="find_class", path=str(sample_python_files) ) assert "ReActAgent" in result @@ -170,9 +163,7 @@ def test_find_subclass(self, tool, sample_python_files): def test_class_not_found(self, tool, sample_python_files): """Test searching for non-existent class.""" result = tool.execute( - target="NonExistentClass", - search_type="find_class", - path=str(sample_python_files) + target="NonExistentClass", search_type="find_class", path=str(sample_python_files) ) assert "No class named" in result @@ -184,10 +175,7 @@ class TestShowStructure: def test_show_structure(self, tool, sample_python_files): """Test showing file structure.""" base_file = sample_python_files / "base.py" - result = tool.execute( - target=str(base_file), - search_type="show_structure" - ) + result = tool.execute(target=str(base_file), search_type="show_structure") # Check for imports section assert "IMPORTS" in result @@ -205,10 +193,7 @@ def test_show_structure(self, tool, sample_python_files): def test_show_structure_nonexistent_file(self, tool): """Test show_structure on non-existent file.""" - result = tool.execute( - target="/nonexistent/file.py", - search_type="show_structure" - ) + result = tool.execute(target="/nonexistent/file.py", search_type="show_structure") assert "Error" in result assert "does not exist" in result @@ -216,10 +201,7 @@ def test_show_structure_nonexistent_file(self, tool): def test_show_structure_with_line_numbers(self, tool, sample_python_files): """Test that line numbers are included.""" base_file = sample_python_files / "base.py" - result = tool.execute( - target=str(base_file), - search_type="show_structure" - ) + result = tool.execute(target=str(base_file), search_type="show_structure") assert "Line" in result @@ -230,9 +212,7 @@ class TestFindUsages: def test_find_function_usages(self, tool, sample_python_files): """Test finding where a function is called.""" result = tool.execute( - target="transform_task", - search_type="find_usages", - path=str(sample_python_files) + target="transform_task", search_type="find_usages", path=str(sample_python_files) ) assert "transform_task" in result @@ -242,9 +222,7 @@ def test_find_function_usages(self, tool, sample_python_files): def test_find_class_usages(self, tool, sample_python_files): """Test finding where a class is used.""" result = tool.execute( - target="BaseAgent", - search_type="find_usages", - path=str(sample_python_files) + target="BaseAgent", search_type="find_usages", path=str(sample_python_files) ) # Should find import and inheritance @@ -253,9 +231,7 @@ def test_find_class_usages(self, tool, sample_python_files): def test_usages_not_found(self, tool, sample_python_files): """Test finding usages of something not used.""" result = tool.execute( - target="never_used_function", - search_type="find_usages", - path=str(sample_python_files) + target="never_used_function", search_type="find_usages", path=str(sample_python_files) ) assert "No usages" in result @@ -267,9 +243,7 @@ class TestErrorHandling: def test_invalid_search_type(self, tool, sample_python_files): """Test invalid search type.""" result = tool.execute( - target="something", - search_type="invalid_type", - path=str(sample_python_files) + target="something", search_type="invalid_type", path=str(sample_python_files) ) assert "Error" in result @@ -278,9 +252,7 @@ def test_invalid_search_type(self, tool, sample_python_files): def test_invalid_path(self, tool): """Test with invalid path.""" result = tool.execute( - target="something", - search_type="find_function", - path="/nonexistent/path" + target="something", search_type="find_function", path="/nonexistent/path" ) assert "Error" in result @@ -301,11 +273,7 @@ def test_handles_syntax_errors_gracefully(self, tool, tmp_path): good_file.write_text("def working():\n pass") # Should still find the working function - result = tool.execute( - target="working", - search_type="find_function", - path=str(tmp_path) - ) + result = tool.execute(target="working", search_type="find_function", path=str(tmp_path)) assert "working" in result assert "good.py" in result @@ -318,11 +286,7 @@ def test_large_number_of_files(self, tool, tmp_path): file.write_text(f"def function_{i}():\n pass") # Should find all functions quickly - result = tool.execute( - target="function_10", - search_type="find_function", - path=str(tmp_path) - ) + result = tool.execute(target="function_10", search_type="find_function", path=str(tmp_path)) assert "function_10" in result @@ -333,9 +297,7 @@ class TestRealWorldScenarios: def test_find_init_method(self, tool, sample_python_files): """Test finding __init__ methods.""" result = tool.execute( - target="__init__", - search_type="find_function", - path=str(sample_python_files) + target="__init__", search_type="find_function", path=str(sample_python_files) ) assert "__init__" in result @@ -344,9 +306,7 @@ def test_find_init_method(self, tool, sample_python_files): def test_private_methods(self, tool, sample_python_files): """Test finding private methods.""" result = tool.execute( - target="_process", - search_type="find_function", - path=str(sample_python_files) + target="_process", search_type="find_function", path=str(sample_python_files) ) assert "_process" in result diff --git a/test/test_memory.py b/test/test_memory.py index 0636c88..465884d 100644 --- a/test/test_memory.py +++ b/test/test_memory.py @@ -3,14 +3,15 @@ This example shows how memory automatically compresses conversations to reduce token usage and costs. """ -import sys + import os +import sys # Add parent directory to path sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from memory import MemoryConfig, MemoryManager from llm import LLMMessage +from memory import MemoryConfig, MemoryManager class MockLLM: @@ -22,6 +23,7 @@ def __init__(self): def call(self, messages, tools=None, max_tokens=4096, **kwargs): """Mock LLM call that returns a summary.""" + # Return a mock summary class MockResponse: content = "This is a summary of the conversation so far." @@ -51,19 +53,17 @@ def main(): mock_llm = MockLLM() memory = MemoryManager(config, mock_llm) - print(f"\nConfiguration:") + print("\nConfiguration:") print(f" Target tokens: {config.target_working_memory_tokens}") print(f" Compression threshold: {config.compression_threshold}") print(f" Short-term size: {config.short_term_message_count}") # Add system message - print(f"\n1. Adding system message...") - memory.add_message( - LLMMessage(role="system", content="You are a helpful assistant.") - ) + print("\n1. Adding system message...") + memory.add_message(LLMMessage(role="system", content="You are a helpful assistant.")) # Simulate a conversation - print(f"\n2. Simulating conversation with 15 messages...") + print("\n2. Simulating conversation with 15 messages...") for i in range(15): # User message user_msg = f"This is user message {i+1}. " + "Some content. " * 20 @@ -75,12 +75,10 @@ def main(): # Show compression events if memory.was_compressed_last_iteration: - print( - f" 💾 Compression triggered! Saved {memory.last_compression_savings} tokens" - ) + print(f" 💾 Compression triggered! Saved {memory.last_compression_savings} tokens") # Get final statistics - print(f"\n3. Final Memory Statistics:") + print("\n3. Final Memory Statistics:") print("=" * 60) stats = memory.get_stats() @@ -95,7 +93,7 @@ def main(): print(f"Summaries: {stats['summary_count']}") # Show context structure - print(f"\n4. Context Structure:") + print("\n4. Context Structure:") print("=" * 60) context = memory.get_context_for_llm() print(f"Total messages in context: {len(context)}") @@ -104,7 +102,7 @@ def main(): content_preview = str(msg.content)[:50] + "..." print(f" [{i+1}] {role}: {content_preview}") - print(f"\n✅ Demo complete!") + print("\n✅ Demo complete!") print( f"\nKey takeaway: Original {stats['total_input_tokens'] + stats['total_output_tokens']} tokens " f"compressed to ~{stats['current_tokens']} tokens in context" diff --git a/test/test_smart_edit.py b/test/test_smart_edit.py index 040846d..ea6f698 100644 --- a/test/test_smart_edit.py +++ b/test/test_smart_edit.py @@ -11,7 +11,7 @@ @pytest.fixture def temp_file(): """Create a temporary file for testing.""" - with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.py') as f: + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".py") as f: content = '''def calculate(x, y): """Calculate sum of x and y.""" result = x + y @@ -31,7 +31,7 @@ def subtract(self, a, b): # Cleanup temp_path.unlink(missing_ok=True) - backup_path = temp_path.with_suffix(temp_path.suffix + '.bak') + backup_path = temp_path.with_suffix(temp_path.suffix + ".bak") backup_path.unlink(missing_ok=True) @@ -51,7 +51,7 @@ def test_exact_match_replace(self, tool, temp_file): mode="diff_replace", old_code="result = x + y", new_code="result = x + y # computed sum", - create_backup=False + create_backup=False, ) assert "Successfully edited" in result @@ -64,10 +64,10 @@ def test_fuzzy_match_with_whitespace(self, tool, temp_file): result = tool.execute( file_path=str(temp_file), mode="diff_replace", - old_code="def calculate(x, y):\n\"\"\"Calculate sum of x and y.\"\"\"\nresult = x + y", - new_code="def calculate(x, y):\n \"\"\"Calculate sum of x and y.\"\"\"\n result = x + y # updated\n print(f'Result: {result}')", + old_code='def calculate(x, y):\n"""Calculate sum of x and y."""\nresult = x + y', + new_code='def calculate(x, y):\n """Calculate sum of x and y."""\n result = x + y # updated\n print(f\'Result: {result}\')', fuzzy_match=True, - create_backup=False + create_backup=False, ) assert "Successfully edited" in result or "Fuzzy match" in result @@ -82,7 +82,7 @@ def test_no_match_found(self, tool, temp_file): old_code="nonexistent code", new_code="new code", fuzzy_match=False, - create_backup=False + create_backup=False, ) assert "Error" in result @@ -95,11 +95,11 @@ def test_backup_creation(self, tool, temp_file): mode="diff_replace", old_code="result = x + y", new_code="result = x + y # backup test", - create_backup=True + create_backup=True, ) assert "Created backup" in result - backup_path = temp_file.with_suffix(temp_file.suffix + '.bak') + backup_path = temp_file.with_suffix(temp_file.suffix + ".bak") assert backup_path.exists() # Verify backup has original content @@ -117,7 +117,7 @@ def test_dry_run_no_changes(self, tool, temp_file): old_code="result = x + y", new_code="result = x + y # dry run test", dry_run=True, - create_backup=False + create_backup=False, ) assert "DRY RUN" in result @@ -136,7 +136,7 @@ def test_insert_after_anchor(self, tool, temp_file): anchor="class Calculator:", code=" # New calculator class", position="after", - create_backup=False + create_backup=False, ) assert "Successfully inserted" in result @@ -156,7 +156,7 @@ def test_insert_before_anchor(self, tool, temp_file): anchor="def calculate", code="# Helper function\n", position="before", - create_backup=False + create_backup=False, ) assert "Successfully inserted" in result @@ -171,7 +171,7 @@ def test_anchor_not_found(self, tool, temp_file): anchor="nonexistent anchor", code="new code", position="after", - create_backup=False + create_backup=False, ) assert "Error" in result @@ -190,7 +190,7 @@ def test_replace_line_range(self, tool, temp_file): start_line=2, end_line=4, new_code=' """New docstring."""\n return x + y # simplified\n', - create_backup=False + create_backup=False, ) assert "Successfully edited lines" in result @@ -206,7 +206,7 @@ def test_invalid_line_range(self, tool, temp_file): start_line=100, end_line=200, new_code="new content", - create_backup=False + create_backup=False, ) assert "Error" in result @@ -221,7 +221,7 @@ def test_line_numbers_validation(self, tool, temp_file): start_line=5, end_line=2, new_code="new content", - create_backup=False + create_backup=False, ) assert "Error" in result @@ -233,7 +233,7 @@ def test_line_numbers_validation(self, tool, temp_file): start_line=-1, end_line=5, new_code="new content", - create_backup=False + create_backup=False, ) assert "Error" in result @@ -250,7 +250,7 @@ def test_diff_shown_by_default(self, tool, temp_file): old_code="result = x + y", new_code="result = x + y # with comment", show_diff=True, - create_backup=False + create_backup=False, ) assert "Diff preview" in result @@ -266,7 +266,7 @@ def test_diff_hidden_when_disabled(self, tool, temp_file): new_code="result = x + y # no preview", show_diff=False, dry_run=False, - create_backup=False + create_backup=False, ) # Diff should not be in output when show_diff=False and dry_run=False @@ -283,7 +283,7 @@ def test_file_not_exist(self, tool): file_path="/nonexistent/file.py", mode="diff_replace", old_code="code", - new_code="new code" + new_code="new code", ) assert "Error" in result @@ -292,21 +292,13 @@ def test_file_not_exist(self, tool): def test_missing_required_params(self, tool, temp_file): """Test error for missing required parameters.""" # diff_replace without old_code - result = tool.execute( - file_path=str(temp_file), - mode="diff_replace", - new_code="new code" - ) + result = tool.execute(file_path=str(temp_file), mode="diff_replace", new_code="new code") assert "Error" in result assert "old_code" in result.lower() # smart_insert without anchor - result = tool.execute( - file_path=str(temp_file), - mode="smart_insert", - code="new code" - ) + result = tool.execute(file_path=str(temp_file), mode="smart_insert", code="new code") assert "Error" in result @@ -323,7 +315,7 @@ def test_fuzzy_threshold(self, tool, temp_file): old_code="completely different code that doesn't exist", new_code="new code", fuzzy_match=True, - create_backup=False + create_backup=False, ) assert "Error" in result or "not find" in result.lower() @@ -337,7 +329,7 @@ def test_fuzzy_match_info(self, tool, temp_file): old_code='def calculate(x,y):\n """Calculate sum of x and y."""\n result=x+y\n return result', new_code='def calculate(x, y):\n """Calculate sum of x and y."""\n result = x + y + 1\n return result', fuzzy_match=True, - create_backup=False + create_backup=False, ) # Should show fuzzy match info if similarity < 99% diff --git a/test/test_smart_edit_integration.py b/test/test_smart_edit_integration.py index 5f581d7..fbb339e 100644 --- a/test/test_smart_edit_integration.py +++ b/test/test_smart_edit_integration.py @@ -1,31 +1,57 @@ """Quick integration test for SmartEditTool with Agent.""" + +import os import tempfile from pathlib import Path -from config import Config -from llm import create_llm +import pytest + from agent.react_agent import ReActAgent -from tools.smart_edit import SmartEditTool +from config import Config +from llm import LiteLLMLLM from tools.file_ops import FileReadTool, FileWriteTool +from tools.smart_edit import SmartEditTool + +def _has_api_key_for_model(model: str) -> bool: + provider = model.split("/")[0] if model else "" + if provider == "anthropic": + return bool(os.getenv("ANTHROPIC_API_KEY")) + if provider == "openai": + return bool(os.getenv("OPENAI_API_KEY")) + if provider in {"gemini", "google"}: + return bool(os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")) + if provider == "ollama": + return bool(os.getenv("OLLAMA_HOST")) + return False + + +@pytest.mark.integration def test_smart_edit_in_agent(): """Test that SmartEditTool works when used by an agent.""" + if os.getenv("RUN_INTEGRATION_TESTS") != "1": + pytest.skip("Set RUN_INTEGRATION_TESTS=1 to run live LLM integration tests") + if not _has_api_key_for_model(Config.LITELLM_MODEL): + pytest.skip(f"Missing API key (or server) for model: {Config.LITELLM_MODEL}") # Create a temporary test file - with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.py') as f: - f.write('''def calculate(x, y): + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".py") as f: + f.write( + """def calculate(x, y): result = x + y return result -''') +""" + ) temp_path = f.name try: # Create minimal agent with just SmartEditTool - llm = create_llm( - provider=Config.LLM_PROVIDER, - api_key=Config.get_api_key(), - model=Config.get_default_model(), - retry_config=Config.get_retry_config() + llm = LiteLLMLLM( + model=Config.LITELLM_MODEL, + api_base=Config.LITELLM_API_BASE, + drop_params=Config.LITELLM_DROP_PARAMS, + timeout=Config.LITELLM_TIMEOUT, + retry_config=Config.get_retry_config(), ) tools = [ @@ -34,18 +60,14 @@ def test_smart_edit_in_agent(): SmartEditTool(), ] - agent = ReActAgent( - llm=llm, - tools=tools, - max_iterations=5 - ) + agent = ReActAgent(llm=llm, tools=tools, max_iterations=5) # Task: use smart_edit to add a comment task = f"""Use the smart_edit tool to add a comment '# computed sum' after 'result = x + y' in {temp_path}. Use mode="diff_replace", old_code="result = x + y", new_code="result = x + y # computed sum".""" - print(f"Testing SmartEditTool integration...") + print("Testing SmartEditTool integration...") print(f"Temp file: {temp_path}") print(f"Task: {task}") print("-" * 60) @@ -65,7 +87,8 @@ def test_smart_edit_in_agent(): finally: # Cleanup Path(temp_path).unlink(missing_ok=True) - Path(temp_path + '.bak').unlink(missing_ok=True) + Path(temp_path + ".bak").unlink(missing_ok=True) + if __name__ == "__main__": test_smart_edit_in_agent() diff --git a/test/test_web_fetch.py b/test/test_web_fetch.py index a831498..063ce28 100644 --- a/test/test_web_fetch.py +++ b/test/test_web_fetch.py @@ -1,11 +1,12 @@ """Tests for WebFetchTool.""" + import json import types import pytest from requests.structures import CaseInsensitiveDict -from tools.web_fetch import WebFetchTool, MAX_RESPONSE_BYTES +from tools.web_fetch import MAX_RESPONSE_BYTES, WebFetchTool class FakeResponse: @@ -36,7 +37,9 @@ def fake_request(self, session, url, headers, timeout): return responses[url] monkeypatch.setattr(tool, "_request", types.MethodType(fake_request, tool)) - monkeypatch.setattr(tool, "_resolve_host", types.MethodType(lambda _self, _host, _port: ["93.184.216.34"], tool)) + monkeypatch.setattr( + tool, "_resolve_host", types.MethodType(lambda _self, _host, _port: ["93.184.216.34"], tool) + ) return tool diff --git a/tools/advanced_file_ops.py b/tools/advanced_file_ops.py index 058fd97..67b7d50 100644 --- a/tools/advanced_file_ops.py +++ b/tools/advanced_file_ops.py @@ -1,4 +1,5 @@ """Advanced file operation tools inspired by Claude Code.""" + import re from pathlib import Path from typing import Any, Dict @@ -30,12 +31,12 @@ def parameters(self) -> Dict[str, Any]: return { "pattern": { "type": "string", - "description": "Glob pattern to match files (e.g., '**/*.py', 'src/**/*.js')" + "description": "Glob pattern to match files (e.g., '**/*.py', 'src/**/*.js')", }, "path": { "type": "string", - "description": "Base directory to search in (default: current directory)" - } + "description": "Base directory to search in (default: current directory)", + }, } def execute(self, pattern: str, path: str = ".") -> str: @@ -100,35 +101,32 @@ def description(self) -> str: @property def parameters(self) -> Dict[str, Any]: return { - "pattern": { - "type": "string", - "description": "Regex pattern to search for" - }, + "pattern": {"type": "string", "description": "Regex pattern to search for"}, "path": { "type": "string", - "description": "Directory to search in (default: current directory)" + "description": "Directory to search in (default: current directory)", }, "mode": { "type": "string", - "description": "Output mode: files_only, with_context, or count (default: files_only)" + "description": "Output mode: files_only, with_context, or count (default: files_only)", }, "case_sensitive": { "type": "boolean", - "description": "Whether search is case sensitive (default: true)" + "description": "Whether search is case sensitive (default: true)", }, "file_pattern": { "type": "string", - "description": "Optional glob pattern to filter files before content search (e.g., '**/*.py', 'src/**/*.js')" + "description": "Optional glob pattern to filter files before content search (e.g., '**/*.py', 'src/**/*.js')", }, "exclude_patterns": { "type": "array", "items": {"type": "string"}, - "description": "Optional list of glob patterns to exclude (e.g., ['**/*.pyc', 'node_modules/**'])" + "description": "Optional list of glob patterns to exclude (e.g., ['**/*.pyc', 'node_modules/**'])", }, "max_matches_per_file": { "type": "integer", - "description": "Maximum matches to show per file in with_context mode (default: 5)" - } + "description": "Maximum matches to show per file in with_context mode (default: 5)", + }, } def execute( @@ -139,7 +137,7 @@ def execute( case_sensitive: bool = True, file_pattern: str = None, exclude_patterns: list = None, - max_matches_per_file: int = 5 + max_matches_per_file: int = 5, ) -> str: """Search for pattern in files with optional file filtering.""" try: @@ -154,7 +152,21 @@ def execute( return f"Error: Path does not exist: {path}" # Default exclusions if not specified - default_excludes = ['*.pyc', '*.so', '*.dylib', '*.dll', '*.exe', '*.bin', '*.jpg', '*.png', '*.gif', '*.pdf', '*.zip', '*.tar', '*.gz'] + default_excludes = [ + "*.pyc", + "*.so", + "*.dylib", + "*.dll", + "*.exe", + "*.bin", + "*.jpg", + "*.png", + "*.gif", + "*.pdf", + "*.zip", + "*.tar", + "*.gz", + ] # Determine files to search if file_pattern: @@ -196,7 +208,7 @@ def execute( files_searched += 1 try: - content = file_path.read_text(encoding='utf-8') + content = file_path.read_text(encoding="utf-8") matches = list(regex.finditer(content)) if not matches: @@ -209,7 +221,7 @@ def execute( elif mode == "with_context": lines = content.splitlines() for match in matches[:max_matches_per_file]: - line_no = content[:match.start()].count('\n') + 1 + line_no = content[: match.start()].count("\n") + 1 if line_no <= len(lines): results.append(f"{file_path}:{line_no}: {lines[line_no-1].strip()}") except (UnicodeDecodeError, PermissionError): @@ -221,7 +233,9 @@ def execute( break if not results: - return f"No matches found for pattern '{pattern}' in {files_searched} files searched" + return ( + f"No matches found for pattern '{pattern}' in {files_searched} files searched" + ) return "\n".join(results) except Exception as e: @@ -251,30 +265,27 @@ def description(self) -> str: @property def parameters(self) -> Dict[str, Any]: return { - "file_path": { - "type": "string", - "description": "Path to the file to edit" - }, + "file_path": {"type": "string", "description": "Path to the file to edit"}, "operation": { "type": "string", - "description": "Operation to perform: replace, append, or insert_at_line" + "description": "Operation to perform: replace, append, or insert_at_line", }, "old_text": { "type": "string", - "description": "Text to find and replace (for replace operation)" + "description": "Text to find and replace (for replace operation)", }, "new_text": { "type": "string", - "description": "New text to insert (for replace operation)" + "description": "New text to insert (for replace operation)", }, "text": { "type": "string", - "description": "Text to add (for append or insert_at_line operations)" + "description": "Text to add (for append or insert_at_line operations)", }, "line_number": { "type": "integer", - "description": "Line number to insert at (for insert_at_line operation, 1-indexed)" - } + "description": "Line number to insert at (for insert_at_line operation, 1-indexed)", + }, } def execute( @@ -285,7 +296,7 @@ def execute( new_text: str = "", text: str = "", line_number: int = 0, - **kwargs + **kwargs, ) -> str: """Perform surgical file edit.""" try: @@ -298,21 +309,21 @@ def execute( if not old_text: return "Error: old_text parameter is required for replace operation" - content = path.read_text(encoding='utf-8') + content = path.read_text(encoding="utf-8") if old_text not in content: return f"Error: Text not found in {file_path}" # Replace only the first occurrence content = content.replace(old_text, new_text, 1) - path.write_text(content, encoding='utf-8') + path.write_text(content, encoding="utf-8") return f"Successfully replaced text in {file_path}" elif operation == "append": if not text: return "Error: text parameter is required for append operation" - with path.open("a", encoding='utf-8') as f: + with path.open("a", encoding="utf-8") as f: f.write(text) return f"Successfully appended to {file_path}" @@ -322,17 +333,17 @@ def execute( if line_number <= 0: return "Error: line_number must be positive (1-indexed)" - lines = path.read_text(encoding='utf-8').splitlines(keepends=True) + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) # Insert at the specified line (1-indexed) if line_number > len(lines) + 1: return f"Error: line_number {line_number} is beyond file length {len(lines)}" # Ensure text ends with newline if inserting in middle - insert_text = text if text.endswith('\n') else text + '\n' + insert_text = text if text.endswith("\n") else text + "\n" lines.insert(line_number - 1, insert_text) - path.write_text(''.join(lines), encoding='utf-8') + path.write_text("".join(lines), encoding="utf-8") return f"Successfully inserted text at line {line_number} in {file_path}" else: diff --git a/tools/base.py b/tools/base.py index 7aefa4d..968631f 100644 --- a/tools/base.py +++ b/tools/base.py @@ -1,4 +1,5 @@ """Base tool interface for all agent tools.""" + from abc import ABC, abstractmethod from typing import Any, Dict @@ -33,10 +34,7 @@ def to_anthropic_schema(self) -> Dict[str, Any]: """Convert to Anthropic tool schema format.""" params = self.parameters # Parameters without a 'default' value are required - required = [ - key for key, value in params.items() - if "default" not in value - ] + required = [key for key, value in params.items() if "default" not in value] return { "name": self.name, diff --git a/tools/calculator.py b/tools/calculator.py index 2b5e02f..91c83e0 100644 --- a/tools/calculator.py +++ b/tools/calculator.py @@ -1,7 +1,8 @@ """Calculator tool for executing Python code.""" -from typing import Dict, Any + import io import sys +from typing import Any, Dict from .base import BaseTool diff --git a/tools/code_navigator.py b/tools/code_navigator.py index 53d0a52..e9f3d86 100644 --- a/tools/code_navigator.py +++ b/tools/code_navigator.py @@ -8,14 +8,29 @@ """ import ast -import json from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple -from collections import defaultdict +from typing import Any, Dict, List, Optional, TypedDict from tools.base import BaseTool +class _FunctionResult(TypedDict): + file: str + line: int + signature: str + docstring: str + decorators: List[str] + + +class _ClassResult(TypedDict): + file: str + line: int + bases: List[str] + methods: List[str] + docstring: str + decorators: List[str] + + class CodeNavigatorTool(BaseTool): """Navigate code using AST analysis - fast and accurate.""" @@ -74,26 +89,20 @@ def parameters(self) -> Dict[str, Any]: return { "target": { "type": "string", - "description": "What to search for: function name, class name, or file path (for show_structure)" + "description": "What to search for: function name, class name, or file path (for show_structure)", }, "search_type": { "type": "string", "description": "Type of search: find_function, find_class, show_structure, or find_usages", - "enum": ["find_function", "find_class", "show_structure", "find_usages"] + "enum": ["find_function", "find_class", "show_structure", "find_usages"], }, "path": { "type": "string", - "description": "Optional: limit search to specific directory (default: current directory)" - } + "description": "Optional: limit search to specific directory (default: current directory)", + }, } - def execute( - self, - target: str, - search_type: str, - path: str = ".", - **kwargs - ) -> str: + def execute(self, target: str, search_type: str, path: str = ".", **kwargs) -> str: """Execute code navigation search.""" try: base_path = Path(path) @@ -116,11 +125,11 @@ def execute( def _find_function(self, name: str, base_path: Path) -> str: """Find all function definitions matching the name.""" - results = [] + results: List[_FunctionResult] = [] for py_file in base_path.rglob("*.py"): try: - content = py_file.read_text(encoding='utf-8') + content = py_file.read_text(encoding="utf-8") tree = ast.parse(content, filename=str(py_file)) for node in ast.walk(tree): @@ -134,7 +143,7 @@ def _find_function(self, name: str, base_path: Path) -> str: try: return_type = ast.unparse(node.returns) signature += f" -> {return_type}" - except: + except Exception: pass # Get docstring @@ -143,13 +152,15 @@ def _find_function(self, name: str, base_path: Path) -> str: # Get decorator names decorators = [self._format_decorator(d) for d in node.decorator_list] - results.append({ - "file": str(py_file.relative_to(base_path)), - "line": node.lineno, - "signature": signature, - "docstring": docstring or "(no docstring)", - "decorators": decorators - }) + results.append( + { + "file": str(py_file.relative_to(base_path)), + "line": node.lineno, + "signature": signature, + "docstring": docstring or "(no docstring)", + "decorators": decorators, + } + ) except SyntaxError: # Skip files with syntax errors continue @@ -164,24 +175,24 @@ def _find_function(self, name: str, base_path: Path) -> str: output_parts = [f"Found {len(results)} function(s) named '{name}':\n"] for r in results: output_parts.append(f"📍 {r['file']}:{r['line']}") - if r['decorators']: + if r["decorators"]: output_parts.append(f" Decorators: {', '.join(r['decorators'])}") output_parts.append(f" {r['signature']}") # Truncate long docstrings - doc = r['docstring'] + doc = r["docstring"] if len(doc) > 100: doc = doc[:100] + "..." - output_parts.append(f" \"{doc}\"\n") + output_parts.append(f' "{doc}"\n') return "\n".join(output_parts) def _find_class(self, name: str, base_path: Path) -> str: """Find all class definitions matching the name.""" - results = [] + results: List[_ClassResult] = [] for py_file in base_path.rglob("*.py"): try: - content = py_file.read_text(encoding='utf-8') + content = py_file.read_text(encoding="utf-8") tree = ast.parse(content, filename=str(py_file)) for node in ast.walk(tree): @@ -201,14 +212,16 @@ def _find_class(self, name: str, base_path: Path) -> str: # Get decorator names decorators = [self._format_decorator(d) for d in node.decorator_list] - results.append({ - "file": str(py_file.relative_to(base_path)), - "line": node.lineno, - "bases": bases, - "methods": methods, - "docstring": docstring or "(no docstring)", - "decorators": decorators - }) + results.append( + { + "file": str(py_file.relative_to(base_path)), + "line": node.lineno, + "bases": bases, + "methods": methods, + "docstring": docstring or "(no docstring)", + "decorators": decorators, + } + ) except SyntaxError: continue except Exception: @@ -221,18 +234,22 @@ def _find_class(self, name: str, base_path: Path) -> str: output_parts = [f"Found {len(results)} class(es) named '{name}':\n"] for r in results: output_parts.append(f"📍 {r['file']}:{r['line']}") - output_parts.append(f" class {name}({', '.join(r['bases']) if r['bases'] else 'object'})") - if r['decorators']: + output_parts.append( + f" class {name}({', '.join(r['bases']) if r['bases'] else 'object'})" + ) + if r["decorators"]: output_parts.append(f" Decorators: {', '.join(r['decorators'])}") - doc = r['docstring'] + doc = r["docstring"] if len(doc) > 100: doc = doc[:100] + "..." - output_parts.append(f" \"{doc}\"") + output_parts.append(f' "{doc}"') - if r['methods']: - output_parts.append(f" Methods ({len(r['methods'])}): {', '.join(r['methods'][:10])}") - if len(r['methods']) > 10: + if r["methods"]: + output_parts.append( + f" Methods ({len(r['methods'])}): {', '.join(r['methods'][:10])}" + ) + if len(r["methods"]) > 10: output_parts.append(f" ... and {len(r['methods']) - 10} more") output_parts.append("") @@ -245,7 +262,7 @@ def _show_structure(self, file_path: str) -> str: return f"Error: File does not exist: {file_path}" try: - content = path.read_text(encoding='utf-8') + content = path.read_text(encoding="utf-8") tree = ast.parse(content, filename=str(path)) structure = { @@ -258,42 +275,50 @@ def _show_structure(self, file_path: str) -> str: for node in ast.iter_child_nodes(tree): if isinstance(node, ast.Import): for alias in node.names: - structure["imports"].append({ - "line": node.lineno, - "type": "import", - "name": alias.name, - "as": alias.asname - }) + structure["imports"].append( + { + "line": node.lineno, + "type": "import", + "name": alias.name, + "as": alias.asname, + } + ) elif isinstance(node, ast.ImportFrom): for alias in node.names: - structure["imports"].append({ - "line": node.lineno, - "type": "from", - "module": node.module or "", - "name": alias.name, - "as": alias.asname - }) + structure["imports"].append( + { + "line": node.lineno, + "type": "from", + "module": node.module or "", + "name": alias.name, + "as": alias.asname, + } + ) elif isinstance(node, ast.ClassDef): methods = [ {"name": item.name, "line": item.lineno} for item in node.body if isinstance(item, ast.FunctionDef) ] - structure["classes"].append({ - "line": node.lineno, - "name": node.name, - "bases": [self._format_base_class(b) for b in node.bases], - "methods": methods, - "docstring": ast.get_docstring(node) - }) + structure["classes"].append( + { + "line": node.lineno, + "name": node.name, + "bases": [self._format_base_class(b) for b in node.bases], + "methods": methods, + "docstring": ast.get_docstring(node), + } + ) elif isinstance(node, ast.FunctionDef): args = self._format_function_args(node.args) - structure["functions"].append({ - "line": node.lineno, - "name": node.name, - "args": args, - "docstring": ast.get_docstring(node) - }) + structure["functions"].append( + { + "line": node.lineno, + "name": node.name, + "args": args, + "docstring": ast.get_docstring(node), + } + ) # Format output output_parts = [f"Structure of {file_path}:\n"] @@ -304,11 +329,11 @@ def _show_structure(self, file_path: str) -> str: for imp in structure["imports"][:20]: # Limit to 20 imports if imp["type"] == "import": line = f" Line {imp['line']}: import {imp['name']}" - if imp['as']: + if imp["as"]: line += f" as {imp['as']}" else: line = f" Line {imp['line']}: from {imp['module']} import {imp['name']}" - if imp['as']: + if imp["as"]: line += f" as {imp['as']}" output_parts.append(line) if len(structure["imports"]) > 20: @@ -319,16 +344,16 @@ def _show_structure(self, file_path: str) -> str: if structure["classes"]: output_parts.append("📘 CLASSES:") for cls in structure["classes"]: - bases_str = f"({', '.join(cls['bases'])})" if cls['bases'] else "" + bases_str = f"({', '.join(cls['bases'])})" if cls["bases"] else "" output_parts.append(f" Line {cls['line']}: class {cls['name']}{bases_str}") - if cls['docstring']: - doc = cls['docstring'] + if cls["docstring"]: + doc = cls["docstring"] if len(doc) > 60: doc = doc[:60] + "..." - output_parts.append(f" \"{doc}\"") - if cls['methods']: - methods_str = ', '.join(m['name'] for m in cls['methods'][:5]) - if len(cls['methods']) > 5: + output_parts.append(f' "{doc}"') + if cls["methods"]: + methods_str = ", ".join(m["name"] for m in cls["methods"][:5]) + if len(cls["methods"]) > 5: methods_str += f", ... (+{len(cls['methods']) - 5} more)" output_parts.append(f" Methods: {methods_str}") output_parts.append("") @@ -337,12 +362,14 @@ def _show_structure(self, file_path: str) -> str: if structure["functions"]: output_parts.append("🔧 FUNCTIONS:") for func in structure["functions"]: - output_parts.append(f" Line {func['line']}: def {func['name']}({func['args']})") - if func['docstring']: - doc = func['docstring'] + output_parts.append( + f" Line {func['line']}: def {func['name']}({func['args']})" + ) + if func["docstring"]: + doc = func["docstring"] if len(doc) > 60: doc = doc[:60] + "..." - output_parts.append(f" \"{doc}\"") + output_parts.append(f' "{doc}"') output_parts.append("") if not structure["imports"] and not structure["classes"] and not structure["functions"]: @@ -361,7 +388,7 @@ def _find_usages(self, name: str, base_path: Path) -> str: for py_file in base_path.rglob("*.py"): try: - content = py_file.read_text(encoding='utf-8') + content = py_file.read_text(encoding="utf-8") lines = content.splitlines() tree = ast.parse(content, filename=str(py_file)) @@ -374,24 +401,28 @@ def _find_usages(self, name: str, base_path: Path) -> str: line_num = node.lineno context = lines[line_num - 1].strip() if line_num <= len(lines) else "" - results.append({ - "file": str(py_file.relative_to(Path.cwd())), - "line": line_num, - "type": "function_call", - "context": context - }) + results.append( + { + "file": str(py_file.relative_to(Path.cwd())), + "line": line_num, + "type": "function_call", + "context": context, + } + ) # Name references (variables, attributes) elif isinstance(node, ast.Name) and node.id == name: line_num = node.lineno context = lines[line_num - 1].strip() if line_num <= len(lines) else "" - results.append({ - "file": str(py_file.relative_to(base_path)), - "line": line_num, - "type": "name_reference", - "context": context - }) + results.append( + { + "file": str(py_file.relative_to(base_path)), + "line": line_num, + "type": "name_reference", + "context": context, + } + ) except SyntaxError: continue @@ -405,7 +436,7 @@ def _find_usages(self, name: str, base_path: Path) -> str: seen = set() unique_results = [] for r in results: - key = (r['file'], r['line']) + key = (r["file"], r["line"]) if key not in seen: seen.add(key) unique_results.append(r) @@ -423,7 +454,7 @@ def _find_usages(self, name: str, base_path: Path) -> str: for r in unique_results: output_parts.append(f"📍 {r['file']}:{r['line']}") # Truncate long contexts - context = r['context'] + context = r["context"] if len(context) > 80: context = context[:80] + "..." output_parts.append(f" {context}\n") @@ -466,14 +497,14 @@ def _format_base_class(self, node: ast.expr) -> str: """Format a base class node as string.""" try: return ast.unparse(node) - except: + except Exception: return "?" def _format_decorator(self, node: ast.expr) -> str: """Format a decorator node as string.""" try: return "@" + ast.unparse(node) - except: + except Exception: return "@?" def _get_call_name(self, node: ast.expr) -> Optional[str]: diff --git a/tools/delegation.py b/tools/delegation.py index 10f413b..f5b0571 100644 --- a/tools/delegation.py +++ b/tools/delegation.py @@ -1,5 +1,6 @@ """Delegation tool for sub-agent task execution.""" -from typing import Dict, Any + +from typing import Any, Dict from .base import BaseTool @@ -56,16 +57,16 @@ def parameters(self) -> Dict[str, Any]: return { "subtask_description": { "type": "string", - "description": "Clear, detailed description of the subtask to execute" + "description": "Clear, detailed description of the subtask to execute", }, "max_iterations": { "type": "integer", - "description": "Maximum iterations for subtask execution (default: 5)" + "description": "Maximum iterations for subtask execution (default: 5)", }, "include_context": { "type": "boolean", - "description": "Include system context (git, env info) for sub-agent (default: false)" - } + "description": "Include system context (git, env info) for sub-agent (default: false)", + }, } def to_anthropic_schema(self) -> Dict[str, Any]: @@ -81,10 +82,7 @@ def to_anthropic_schema(self) -> Dict[str, Any]: } def execute( - self, - subtask_description: str, - max_iterations: int = 5, - include_context: bool = False + self, subtask_description: str, max_iterations: int = 5, include_context: bool = False ) -> str: """Execute subtask delegation. @@ -99,5 +97,5 @@ def execute( return self.agent.delegate_subtask( subtask_description=subtask_description, max_iterations=max_iterations, - include_context=include_context + include_context=include_context, ) diff --git a/tools/file_ops.py b/tools/file_ops.py index 1765bbd..ca94a54 100644 --- a/tools/file_ops.py +++ b/tools/file_ops.py @@ -1,7 +1,8 @@ """File operation tools for reading, writing, and searching files.""" -from typing import Dict, Any -import os + import glob +import os +from typing import Any, Dict from .base import BaseTool diff --git a/tools/git_tools.py b/tools/git_tools.py index 77730dc..de30243 100644 --- a/tools/git_tools.py +++ b/tools/git_tools.py @@ -3,10 +3,10 @@ This module provides comprehensive git tools for version control operations, enabling agents to interact with git repositories effectively. """ -import subprocess + import os -from typing import Dict, Any, List, Optional -from pathlib import Path +import subprocess +from typing import Any, Dict, List, Optional from .base import BaseTool @@ -67,7 +67,7 @@ def parameters(self) -> Dict[str, Any]: return { "path": { "type": "string", - "description": "Path to git repository (default: current directory)" + "description": "Path to git repository (default: current directory)", } } @@ -104,21 +104,18 @@ def parameters(self) -> Dict[str, Any]: "mode": { "type": "string", "description": "Diff mode: staged, unstaged, files, or commits", - "enum": ["staged", "unstaged", "files", "commits"] + "enum": ["staged", "unstaged", "files", "commits"], }, "files": { "type": "array", "items": {"type": "string"}, - "description": "Specific files to diff (for 'files' mode)" + "description": "Specific files to diff (for 'files' mode)", }, "commit_range": { "type": "string", - "description": "Commit range like 'HEAD~1..HEAD' or 'branch1..branch2' (for 'commits' mode)" + "description": "Commit range like 'HEAD~1..HEAD' or 'branch1..branch2' (for 'commits' mode)", }, - "path": { - "type": "string", - "description": "Path to git repository" - } + "path": {"type": "string", "description": "Path to git repository"}, } def execute( @@ -127,7 +124,7 @@ def execute( files: Optional[List[str]] = None, commit_range: str = "", path: str = ".", - **kwargs + **kwargs, ) -> str: """Execute git diff command.""" args = ["diff"] @@ -170,12 +167,9 @@ def parameters(self) -> Dict[str, Any]: "files": { "type": "array", "items": {"type": "string"}, - "description": "Files to stage (e.g., ['file.py'], ['.'], ['-A'])" + "description": "Files to stage (e.g., ['file.py'], ['.'], ['-A'])", }, - "path": { - "type": "string", - "description": "Path to git repository" - } + "path": {"type": "string", "description": "Path to git repository"}, } def execute(self, files: List[str], path: str = ".", **kwargs) -> str: @@ -223,19 +217,13 @@ def description(self) -> str: @property def parameters(self) -> Dict[str, Any]: return { - "message": { - "type": "string", - "description": "Commit message (required)" - }, + "message": {"type": "string", "description": "Commit message (required)"}, "verify": { "type": "boolean", "description": "Run pre-commit hooks (default: false)", - "default": False + "default": False, }, - "path": { - "type": "string", - "description": "Path to git repository" - } + "path": {"type": "string", "description": "Path to git repository"}, } def execute(self, message: str, verify: bool = False, path: str = ".", **kwargs) -> str: @@ -271,24 +259,10 @@ def description(self) -> str: @property def parameters(self) -> Dict[str, Any]: return { - "limit": { - "type": "integer", - "description": "Number of commits to show", - "default": 10 - }, - "oneline": { - "type": "boolean", - "description": "Use compact format", - "default": True - }, - "branch": { - "type": "string", - "description": "Specific branch (optional)" - }, - "path": { - "type": "string", - "description": "Path to git repository" - } + "limit": {"type": "integer", "description": "Number of commits to show", "default": 10}, + "oneline": {"type": "boolean", "description": "Use compact format", "default": True}, + "branch": {"type": "string", "description": "Specific branch (optional)"}, + "path": {"type": "string", "description": "Path to git repository"}, } def execute( @@ -297,7 +271,7 @@ def execute( oneline: bool = True, branch: Optional[str] = None, path: str = ".", - **kwargs + **kwargs, ) -> str: """Execute git log command.""" args = ["log"] @@ -343,21 +317,18 @@ def parameters(self) -> Dict[str, Any]: "type": "string", "description": "Operation: list, create, delete, or current", "enum": ["list", "create", "delete", "current"], - "default": "list" + "default": "list", }, "branch": { "type": "string", - "description": "Branch name (for create/delete operations)" + "description": "Branch name (for create/delete operations)", }, "force": { "type": "boolean", "description": "Force delete (default: false)", - "default": False + "default": False, }, - "path": { - "type": "string", - "description": "Path to git repository" - } + "path": {"type": "string", "description": "Path to git repository"}, } def execute( @@ -366,7 +337,7 @@ def execute( branch: Optional[str] = None, force: bool = False, path: str = ".", - **kwargs + **kwargs, ) -> str: """Execute git branch command.""" if operation == "list": @@ -419,26 +390,11 @@ def description(self) -> str: @property def parameters(self) -> Dict[str, Any]: return { - "branch": { - "type": "string", - "description": "Switch to existing branch" - }, - "new_branch": { - "type": "string", - "description": "Create and switch to new branch" - }, - "file": { - "type": "string", - "description": "Restore specific file from HEAD" - }, - "commit": { - "type": "string", - "description": "Checkout specific commit/tag" - }, - "path": { - "type": "string", - "description": "Path to git repository" - } + "branch": {"type": "string", "description": "Switch to existing branch"}, + "new_branch": {"type": "string", "description": "Create and switch to new branch"}, + "file": {"type": "string", "description": "Restore specific file from HEAD"}, + "commit": {"type": "string", "description": "Checkout specific commit/tag"}, + "path": {"type": "string", "description": "Path to git repository"}, } def execute( @@ -448,7 +404,7 @@ def execute( file: Optional[str] = None, commit: Optional[str] = None, path: str = ".", - **kwargs + **kwargs, ) -> str: """Execute git checkout command.""" args = ["checkout"] @@ -499,26 +455,20 @@ def parameters(self) -> Dict[str, Any]: "remote": { "type": "string", "description": "Remote name (default: origin)", - "default": "origin" - }, - "branch": { - "type": "string", - "description": "Branch to push (default: current branch)" + "default": "origin", }, + "branch": {"type": "string", "description": "Branch to push (default: current branch)"}, "force": { "type": "boolean", "description": "Force push (WARNING: rewrites history)", - "default": False + "default": False, }, "set_upstream": { "type": "boolean", "description": "Set upstream tracking", - "default": False + "default": False, }, - "path": { - "type": "string", - "description": "Path to git repository" - } + "path": {"type": "string", "description": "Path to git repository"}, } def execute( @@ -528,7 +478,7 @@ def execute( force: bool = False, set_upstream: bool = False, path: str = ".", - **kwargs + **kwargs, ) -> str: """Execute git push command.""" args = ["push"] @@ -575,21 +525,15 @@ def parameters(self) -> Dict[str, Any]: "remote": { "type": "string", "description": "Remote name (default: origin)", - "default": "origin" - }, - "branch": { - "type": "string", - "description": "Branch to pull (default: current branch)" + "default": "origin", }, + "branch": {"type": "string", "description": "Branch to pull (default: current branch)"}, "rebase": { "type": "boolean", "description": "Use rebase instead of merge", - "default": False + "default": False, }, - "path": { - "type": "string", - "description": "Path to git repository" - } + "path": {"type": "string", "description": "Path to git repository"}, } def execute( @@ -598,7 +542,7 @@ def execute( branch: Optional[str] = None, rebase: bool = False, path: str = ".", - **kwargs + **kwargs, ) -> str: """Execute git pull command.""" args = ["pull"] @@ -644,20 +588,11 @@ def parameters(self) -> Dict[str, Any]: "operation": { "type": "string", "description": "Operation: list, add, remove, get-url, set-url", - "enum": ["list", "add", "remove", "get-url", "set-url"] + "enum": ["list", "add", "remove", "get-url", "set-url"], }, - "name": { - "type": "string", - "description": "Remote name" - }, - "url": { - "type": "string", - "description": "Remote URL (for add/set-url)" - }, - "path": { - "type": "string", - "description": "Path to git repository" - } + "name": {"type": "string", "description": "Remote name"}, + "url": {"type": "string", "description": "Remote URL (for add/set-url)"}, + "path": {"type": "string", "description": "Path to git repository"}, } def execute( @@ -666,7 +601,7 @@ def execute( name: Optional[str] = None, url: Optional[str] = None, path: str = ".", - **kwargs + **kwargs, ) -> str: """Execute git remote command.""" args = ["remote"] @@ -727,24 +662,14 @@ def parameters(self) -> Dict[str, Any]: "operation": { "type": "string", "description": "Operation: push, list, pop, drop", - "enum": ["push", "list", "pop", "drop"] - }, - "message": { - "type": "string", - "description": "Stash message (for push)" + "enum": ["push", "list", "pop", "drop"], }, - "path": { - "type": "string", - "description": "Path to git repository" - } + "message": {"type": "string", "description": "Stash message (for push)"}, + "path": {"type": "string", "description": "Path to git repository"}, } def execute( - self, - operation: str = "push", - message: Optional[str] = None, - path: str = ".", - **kwargs + self, operation: str = "push", message: Optional[str] = None, path: str = ".", **kwargs ) -> str: """Execute git stash command.""" args = ["stash"] @@ -796,22 +721,19 @@ def parameters(self) -> Dict[str, Any]: "dry_run": { "type": "boolean", "description": "Show what would be removed without deleting", - "default": True + "default": True, }, "force": { "type": "boolean", "description": "Actually remove files (DANGEROUS)", - "default": False + "default": False, }, "directories": { "type": "boolean", "description": "Remove untracked directories too", - "default": False + "default": False, }, - "path": { - "type": "string", - "description": "Path to git repository" - } + "path": {"type": "string", "description": "Path to git repository"}, } def execute( @@ -820,7 +742,7 @@ def execute( force: bool = False, directories: bool = False, path: str = ".", - **kwargs + **kwargs, ) -> str: """Execute git clean command.""" if not force and not dry_run: diff --git a/tools/session_manager.py b/tools/session_manager.py index be7ac4b..f932e57 100644 --- a/tools/session_manager.py +++ b/tools/session_manager.py @@ -9,9 +9,8 @@ """ import argparse import sys -import json -from pathlib import Path from datetime import datetime +from pathlib import Path # Add parent directory to path sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -24,7 +23,7 @@ def format_timestamp(ts: str) -> str: try: dt = datetime.fromisoformat(ts) return dt.strftime("%Y-%m-%d %H:%M:%S") - except: + except ValueError: return ts @@ -66,7 +65,7 @@ def show_session(store: MemoryStore, session_id: str, show_messages: bool = Fals print("=" * 100) # Stats - print(f"\n📊 Statistics:") + print("\n📊 Statistics:") print(f" Created: {format_timestamp(stats['created_at'])}") print(f" System Messages: {len(session_data['system_messages'])}") print(f" Messages: {len(session_data['messages'])}") @@ -112,26 +111,26 @@ def show_stats(store: MemoryStore, session_id: str): print(f"\n📊 Session Statistics: {session_id}") print("=" * 80) - print(f"\n⏰ Timing:") + print("\n⏰ Timing:") print(f" Created: {format_timestamp(stats['created_at'])}") - print(f"\n📨 Messages:") + print("\n📨 Messages:") print(f" System Messages: {stats['system_message_count']}") print(f" Regular Messages: {stats['message_count']}") print(f" Total Messages: {stats['system_message_count'] + stats['message_count']}") - print(f"\n🗜️ Compression:") + print("\n🗜️ Compression:") print(f" Compressions: {stats['compression_count']}") print(f" Summaries: {stats['summary_count']}") - print(f"\n🎫 Tokens:") + print("\n🎫 Tokens:") print(f" Message Tokens: {stats['total_message_tokens']:,}") print(f" Original Tokens (pre-compression): {stats['total_original_tokens']:,}") print(f" Compressed Tokens: {stats['total_compressed_tokens']:,}") print(f" Token Savings: {stats['token_savings']:,}") - if stats['total_original_tokens'] > 0: - savings_pct = (stats['token_savings'] / stats['total_original_tokens']) * 100 + if stats["total_original_tokens"] > 0: + savings_pct = (stats["token_savings"] / stats["total_original_tokens"]) * 100 print(f" Savings Percentage: {savings_pct:.1f}%") print("=" * 80) @@ -172,14 +171,14 @@ def main(): Delete a session: python tools/session_manager.py delete - """ + """, ) parser.add_argument( "--db", type=str, default="data/memory.db", - help="Path to database file (default: data/memory.db)" + help="Path to database file (default: data/memory.db)", ) subparsers = parser.add_subparsers(dest="command", help="Command to run") diff --git a/tools/shell.py b/tools/shell.py index eeb7dcd..f1d2105 100644 --- a/tools/shell.py +++ b/tools/shell.py @@ -1,6 +1,7 @@ """Shell command execution tool.""" -from typing import Dict, Any + import subprocess +from typing import Any, Dict from .base import BaseTool diff --git a/tools/smart_edit.py b/tools/smart_edit.py index 5d9632a..8e3ee99 100644 --- a/tools/smart_edit.py +++ b/tools/smart_edit.py @@ -77,60 +77,51 @@ def description(self) -> str: @property def parameters(self) -> Dict[str, Any]: return { - "file_path": { - "type": "string", - "description": "Path to the file to edit" - }, + "file_path": {"type": "string", "description": "Path to the file to edit"}, "mode": { "type": "string", "description": "Edit mode: diff_replace, smart_insert, or block_edit", - "enum": ["diff_replace", "smart_insert", "block_edit"] + "enum": ["diff_replace", "smart_insert", "block_edit"], }, "old_code": { "type": "string", - "description": "Code to find and replace (diff_replace mode). Can be approximate - fuzzy matching will find it." - }, - "new_code": { - "type": "string", - "description": "New code to insert (diff_replace mode)" + "description": "Code to find and replace (diff_replace mode). Can be approximate - fuzzy matching will find it.", }, + "new_code": {"type": "string", "description": "New code to insert (diff_replace mode)"}, "anchor": { "type": "string", - "description": "Anchor line to insert relative to (smart_insert mode)" - }, - "code": { - "type": "string", - "description": "Code to insert (smart_insert mode)" + "description": "Anchor line to insert relative to (smart_insert mode)", }, + "code": {"type": "string", "description": "Code to insert (smart_insert mode)"}, "position": { "type": "string", "description": "Where to insert: 'before' or 'after' anchor (smart_insert mode)", - "enum": ["before", "after"] + "enum": ["before", "after"], }, "start_line": { "type": "integer", - "description": "Starting line number (block_edit mode, 1-indexed)" + "description": "Starting line number (block_edit mode, 1-indexed)", }, "end_line": { "type": "integer", - "description": "Ending line number (block_edit mode, 1-indexed, inclusive)" + "description": "Ending line number (block_edit mode, 1-indexed, inclusive)", }, "fuzzy_match": { "type": "boolean", - "description": "Enable fuzzy matching for whitespace differences (default: true)" + "description": "Enable fuzzy matching for whitespace differences (default: true)", }, "dry_run": { "type": "boolean", - "description": "Preview changes without applying (default: false)" + "description": "Preview changes without applying (default: false)", }, "create_backup": { "type": "boolean", - "description": "Create .bak backup file (default: true)" + "description": "Create .bak backup file (default: true)", }, "show_diff": { "type": "boolean", - "description": "Show diff preview even when not dry_run (default: true)" - } + "description": "Show diff preview even when not dry_run (default: true)", + }, } def execute( @@ -148,7 +139,7 @@ def execute( dry_run: bool = False, create_backup: bool = True, show_diff: bool = True, - **kwargs + **kwargs, ) -> str: """Execute smart edit operation.""" try: @@ -159,23 +150,41 @@ def execute( return f"Error: File does not exist: {file_path}" # Read original content - original_content = path.read_text(encoding='utf-8') + original_content = path.read_text(encoding="utf-8") # Execute the appropriate edit mode if mode == "diff_replace": result = self._diff_replace( - path, original_content, old_code, new_code, - fuzzy_match, dry_run, create_backup, show_diff + path, + original_content, + old_code, + new_code, + fuzzy_match, + dry_run, + create_backup, + show_diff, ) elif mode == "smart_insert": result = self._smart_insert( - path, original_content, anchor, code, position, - dry_run, create_backup, show_diff + path, + original_content, + anchor, + code, + position, + dry_run, + create_backup, + show_diff, ) elif mode == "block_edit": result = self._block_edit( - path, original_content, start_line, end_line, new_code, - dry_run, create_backup, show_diff + path, + original_content, + start_line, + end_line, + new_code, + dry_run, + create_backup, + show_diff, ) else: return f"Error: Unknown mode '{mode}'. Supported: diff_replace, smart_insert, block_edit" @@ -194,7 +203,7 @@ def _diff_replace( fuzzy_match: bool, dry_run: bool, create_backup: bool, - show_diff: bool + show_diff: bool, ) -> str: """Replace code with fuzzy matching.""" if not old_code: @@ -222,19 +231,10 @@ def _diff_replace( return f"Error: Exact match not found and fuzzy_match is disabled.\n\nSearched for:\n{old_code[:200]}..." # Create new content with replacement - new_content = ( - original_content[:match_start] + - new_code + - original_content[match_end:] - ) + new_content = original_content[:match_start] + new_code + original_content[match_end:] # Generate diff for preview - diff = self._generate_diff( - original_content, - new_content, - str(path), - context_lines=3 - ) + diff = self._generate_diff(original_content, new_content, str(path), context_lines=3) # Show diff if requested output_parts = [] @@ -255,7 +255,7 @@ def _diff_replace( # Apply changes try: - path.write_text(new_content, encoding='utf-8') + path.write_text(new_content, encoding="utf-8") output_parts.append(f"✓ Successfully edited {path}") return "\n".join(output_parts) except Exception as e: @@ -276,7 +276,7 @@ def _smart_insert( position: str, dry_run: bool, create_backup: bool, - show_diff: bool + show_diff: bool, ) -> str: """Insert code relative to an anchor line.""" if not anchor: @@ -297,8 +297,8 @@ def _smart_insert( return f"Error: Anchor line not found: {anchor}" # Ensure code ends with newline - if not code.endswith('\n'): - code += '\n' + if not code.endswith("\n"): + code += "\n" # Insert at appropriate position if position == "before": @@ -306,7 +306,7 @@ def _smart_insert( else: # after lines.insert(anchor_idx + 1, code) - new_content = ''.join(lines) + new_content = "".join(lines) # Generate and show diff output_parts = [] @@ -323,7 +323,7 @@ def _smart_insert( backup_path = self._create_backup(path) output_parts.append(f"Created backup: {backup_path}") - path.write_text(new_content, encoding='utf-8') + path.write_text(new_content, encoding="utf-8") output_parts.append(f"✓ Successfully inserted code {position} anchor in {path}") return "\n".join(output_parts) @@ -336,7 +336,7 @@ def _block_edit( new_content_block: str, dry_run: bool, create_backup: bool, - show_diff: bool + show_diff: bool, ) -> str: """Edit a block of lines.""" if start_line <= 0 or end_line <= 0: @@ -350,16 +350,12 @@ def _block_edit( return f"Error: line range {start_line}-{end_line} exceeds file length {len(lines)}" # Ensure new content ends with newline - if not new_content_block.endswith('\n'): - new_content_block += '\n' + if not new_content_block.endswith("\n"): + new_content_block += "\n" # Replace the block - new_lines = ( - lines[:start_line-1] + - [new_content_block] + - lines[end_line:] - ) - new_content = ''.join(new_lines) + new_lines = lines[: start_line - 1] + [new_content_block] + lines[end_line:] + new_content = "".join(new_lines) # Generate and show diff output_parts = [] @@ -376,15 +372,11 @@ def _block_edit( backup_path = self._create_backup(path) output_parts.append(f"Created backup: {backup_path}") - path.write_text(new_content, encoding='utf-8') + path.write_text(new_content, encoding="utf-8") output_parts.append(f"✓ Successfully edited lines {start_line}-{end_line} in {path}") return "\n".join(output_parts) - def _fuzzy_find( - self, - target: str, - text: str - ) -> Optional[Tuple[int, int, float]]: + def _fuzzy_find(self, target: str, text: str) -> Optional[Tuple[int, int, float]]: """ Find target in text using fuzzy matching. @@ -406,8 +398,8 @@ def _fuzzy_find( break for i in range(len(text_lines) - window_size + 1): - window = text_lines[i:i+window_size] - window_text = '\n'.join(window) + window = text_lines[i : i + window_size] + window_text = "\n".join(window) window_normalized = self._normalize_whitespace(window_text) # Calculate similarity @@ -415,7 +407,7 @@ def _fuzzy_find( if ratio > best_ratio and ratio >= self.fuzzy_threshold: # Found better match - calculate actual character positions - char_start = len('\n'.join(text_lines[:i])) + char_start = len("\n".join(text_lines[:i])) if i > 0: char_start += 1 # Account for newline char_end = char_start + len(window_text) @@ -432,16 +424,12 @@ def _normalize_whitespace(self, text: str) -> str: lines = [] for line in text.splitlines(): # Strip leading/trailing whitespace but keep structure - normalized = ' '.join(line.split()) + normalized = " ".join(line.split()) lines.append(normalized) - return '\n'.join(lines) + return "\n".join(lines) def _generate_diff( - self, - old_content: str, - new_content: str, - filename: str, - context_lines: int = 3 + self, old_content: str, new_content: str, filename: str, context_lines: int = 3 ) -> str: """Generate unified diff between old and new content.""" old_lines = old_content.splitlines(keepends=True) @@ -452,14 +440,14 @@ def _generate_diff( new_lines, fromfile=f"{filename} (original)", tofile=f"{filename} (modified)", - lineterm='', - n=context_lines + lineterm="", + n=context_lines, ) - return ''.join(diff_lines) + return "".join(diff_lines) def _create_backup(self, path: Path) -> Path: """Create a backup file with .bak extension.""" - backup_path = path.with_suffix(path.suffix + '.bak') + backup_path = path.with_suffix(path.suffix + ".bak") shutil.copy2(path, backup_path) return backup_path diff --git a/tools/todo.py b/tools/todo.py index b45f94d..64c22ef 100644 --- a/tools/todo.py +++ b/tools/todo.py @@ -1,4 +1,5 @@ """Todo list tool for agents to manage complex multi-step tasks.""" + from typing import Any, Dict from agent.todo import TodoList @@ -53,24 +54,24 @@ def parameters(self) -> Dict[str, Any]: return { "operation": { "type": "string", - "description": "Operation to perform: add, update, list, remove, or clear_completed" + "description": "Operation to perform: add, update, list, remove, or clear_completed", }, "content": { "type": "string", - "description": "Todo content in imperative form (for add operation)" + "description": "Todo content in imperative form (for add operation)", }, "activeForm": { "type": "string", - "description": "Todo content in present continuous form (for add operation)" + "description": "Todo content in present continuous form (for add operation)", }, "index": { "type": "integer", - "description": "1-indexed position of todo item (for update/remove operations)" + "description": "1-indexed position of todo item (for update/remove operations)", }, "status": { "type": "string", - "description": "New status: pending, in_progress, or completed (for update operation)" - } + "description": "New status: pending, in_progress, or completed (for update operation)", + }, } def execute( @@ -80,7 +81,7 @@ def execute( activeForm: str = "", index: int = 0, status: str = "", - **kwargs + **kwargs, ) -> str: """Execute todo list operation. diff --git a/tools/web_fetch.py b/tools/web_fetch.py index dbf73ed..a901e6b 100644 --- a/tools/web_fetch.py +++ b/tools/web_fetch.py @@ -1,4 +1,5 @@ """Web fetch tool for retrieving content from URLs.""" + from __future__ import annotations import ipaddress @@ -6,13 +7,13 @@ import socket import time from typing import Any, Dict, List, Optional, Tuple, Union -from urllib.parse import urlparse, urljoin +from urllib.parse import urljoin, urlparse +import html2text import requests -from requests.utils import get_encoding_from_headers -from readability import Document from lxml import html as lxml_html -import html2text +from readability import Document +from requests.utils import get_encoding_from_headers from .base import BaseTool @@ -245,7 +246,7 @@ def _resolve_host(self, host: str, port: int) -> List[str]: for info in infos: sockaddr = info[4] if sockaddr: - addresses.append(sockaddr[0]) + addresses.append(str(sockaddr[0])) return list(dict.fromkeys(addresses)) def _is_ip_allowed(self, ip: Union[ipaddress.IPv4Address, ipaddress.IPv6Address]) -> bool: @@ -325,7 +326,9 @@ def _fetch_with_redirects( def _request( self, session: requests.Session, url: str, headers: Dict[str, str], timeout: float ) -> requests.Response: - return session.get(url, headers=headers, timeout=timeout, stream=True, allow_redirects=False) + return session.get( + url, headers=headers, timeout=timeout, stream=True, allow_redirects=False + ) def _read_response(self, response: requests.Response) -> bytes: content_length = response.headers.get("content-length") @@ -417,4 +420,3 @@ def _truncate_output(self, output: str) -> Tuple[str, bool, int]: suffix = "\n\n[... output truncated ...]" cutoff = max(0, MAX_OUTPUT_CHARS - len(suffix)) return output[:cutoff] + suffix, True, total - diff --git a/tools/web_search.py b/tools/web_search.py index 2f528e8..8552cbf 100644 --- a/tools/web_search.py +++ b/tools/web_search.py @@ -1,5 +1,11 @@ """Web search tool using DuckDuckGo.""" -from typing import Dict, Any + +from typing import Any, Dict + +try: + from ddgs import DDGS +except ImportError: # pragma: no cover + DDGS = None # type: ignore[assignment] from .base import BaseTool @@ -26,23 +32,14 @@ def parameters(self) -> Dict[str, Any]: def execute(self, query: str) -> str: """Execute web search and return results.""" - try: - # Try new package name first - try: - from ddgs import DDGS - except ImportError: - # Fallback to old package name - from duckduckgo_search import DDGS + if DDGS is None: + return "Error: Search dependency missing (ddgs). Reinstall dependencies." + try: results = [] with DDGS() as ddgs: for r in ddgs.text(query, max_results=5): results.append(f"[{r['title']}]({r['href']})\n{r['body']}\n") - - return ( - "\n---\n".join(results) if results else "No results found" - ) - except ImportError: - return "Error: Search package not installed. Run: pip install ddgs" + return "\n---\n".join(results) if results else "No results found" except Exception as e: return f"Error searching web: {str(e)}" diff --git a/utils/__init__.py b/utils/__init__.py index cac34e7..7a6254d 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,5 +1,6 @@ """Utility modules for agentic loop.""" -from .logger import setup_logger, get_logger, get_log_file_path + from . import terminal_ui +from .logger import get_log_file_path, get_logger, setup_logger -__all__ = ['setup_logger', 'get_logger', 'get_log_file_path', 'terminal_ui'] +__all__ = ["setup_logger", "get_logger", "get_log_file_path", "terminal_ui"] diff --git a/utils/logger.py b/utils/logger.py index 76b326a..db0b84b 100644 --- a/utils/logger.py +++ b/utils/logger.py @@ -1,8 +1,8 @@ """Logging configuration for the agentic loop system.""" + import logging -import os -from pathlib import Path from datetime import datetime +from pathlib import Path from typing import Optional # Global flag to track if logging has been initialized @@ -14,7 +14,7 @@ def setup_logger( log_dir: Optional[str] = None, log_level: str = "DEBUG", log_to_file: bool = True, - log_to_console: bool = False + log_to_console: bool = False, ) -> None: """Configure the logging system globally. @@ -35,6 +35,7 @@ def setup_logger( if log_dir is None: try: from config import Config + log_dir = Config.LOG_DIR log_level = Config.LOG_LEVEL log_to_file = Config.LOG_TO_FILE @@ -48,8 +49,7 @@ def setup_logger( # Create formatter formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' + "%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S" ) # Add file handler if enabled @@ -61,7 +61,7 @@ def setup_logger( log_file = log_path / f"agentic_loop_{timestamp}.log" _log_file_path = str(log_file) - file_handler = logging.FileHandler(log_file, encoding='utf-8') + file_handler = logging.FileHandler(log_file, encoding="utf-8") file_handler.setLevel(level) file_handler.setFormatter(formatter) logging.root.addHandler(file_handler) @@ -76,7 +76,9 @@ def setup_logger( _logging_initialized = True # Log initialization message - logging.info(f"Logging initialized. Level: {log_level}, File: {_log_file_path if log_to_file else 'disabled'}") + logging.info( + f"Logging initialized. Level: {log_level}, File: {_log_file_path if log_to_file else 'disabled'}" + ) def get_logger(name: str) -> logging.Logger: diff --git a/utils/model_pricing.py b/utils/model_pricing.py index fc631e6..9be7c71 100644 --- a/utils/model_pricing.py +++ b/utils/model_pricing.py @@ -10,7 +10,6 @@ "o1-mini": {"input": 1.10, "output": 4.40}, "o3": {"input": 2.00, "output": 8.00}, "o3-mini": {"input": 0.40, "output": 1.60}, - # --- Anthropic --- "claude-4-5-opus": {"input": 5.00, "output": 25.00}, "claude-4-5-sonnet": {"input": 3.00, "output": 15.00}, @@ -19,7 +18,6 @@ "claude-3-5-sonnet-20241022": {"input": 3.00, "output": 15.00}, "claude-3-5-haiku-20241022": {"input": 0.80, "output": 4.00}, "claude-3-opus-20240229": {"input": 15.00, "output": 75.00}, - # --- Google Gemini --- "gemini-3-pro": {"input": 2.00, "output": 12.00}, "gemini-3-flash": {"input": 0.50, "output": 3.00}, @@ -27,19 +25,15 @@ "gemini-2-5-flash": {"input": 0.30, "output": 2.50}, "gemini-1-5-pro": {"input": 1.25, "output": 5.00}, "gemini-1-5-flash": {"input": 0.075, "output": 0.30}, - # --- DeepSeek --- "deepseek-v3": {"input": 0.14, "output": 0.28}, "deepseek-reasoner": {"input": 0.55, "output": 2.19}, - # --- xAI (Grok) --- "grok-4": {"input": 3.00, "output": 15.00}, "grok-4-fast": {"input": 0.20, "output": 0.50}, - # --- Mistral --- "mistral-large-2": {"input": 2.00, "output": 6.00}, "mistral-small-3": {"input": 0.10, "output": 0.30}, - # --- Default --- "default": {"input": 0.55, "output": 2.19}, } diff --git a/utils/terminal_ui.py b/utils/terminal_ui.py index 2431887..febfacd 100644 --- a/utils/terminal_ui.py +++ b/utils/terminal_ui.py @@ -1,13 +1,14 @@ """Terminal UI utilities using Rich library for beautiful output.""" -from typing import Dict, Any, Optional + +from typing import Any, Dict, Optional + +from rich import box from rich.console import Console +from rich.markdown import Markdown from rich.panel import Panel +from rich.syntax import Syntax from rich.table import Table -from rich.progress import Progress, SpinnerColumn, TextColumn from rich.text import Text -from rich import box -from rich.syntax import Syntax -from rich.markdown import Markdown # Global console instance console = Console() @@ -24,12 +25,7 @@ def print_header(title: str, subtitle: Optional[str] = None) -> None: if subtitle: content += f"\n[dim]{subtitle}[/dim]" - console.print(Panel( - content, - border_style="cyan", - box=box.DOUBLE, - padding=(1, 2) - )) + console.print(Panel(content, border_style="cyan", box=box.DOUBLE, padding=(1, 2))) def print_config(config: Dict[str, Any]) -> None: @@ -38,12 +34,7 @@ def print_config(config: Dict[str, Any]) -> None: Args: config: Dictionary of configuration key-value pairs """ - table = Table( - show_header=False, - box=box.SIMPLE, - border_style="blue", - padding=(0, 2) - ) + table = Table(show_header=False, box=box.SIMPLE, border_style="blue", padding=(0, 2)) table.add_column("Key", style="cyan bold") table.add_column("Value", style="green") @@ -96,10 +87,8 @@ def print_tool_result(result: str, truncated: bool = False) -> None: result: Tool result string truncated: Whether the result was truncated """ - result_preview = result[:200] if len(result) > 200 else result - if truncated: - console.print(f"[yellow]⚠️ Result truncated[/yellow]") + console.print("[yellow]⚠️ Result truncated[/yellow]") # Only show preview in verbose mode # console.print(f"[dim]{result_preview}...[/dim]" if len(result) > 200 else f"[dim]{result_preview}[/dim]") @@ -114,13 +103,15 @@ def print_final_answer(answer: str) -> None: console.print() # Render markdown content md = Markdown(answer) - console.print(Panel( - md, - title="[bold green]✓ Final Answer[/bold green]", - border_style="green", - box=box.DOUBLE, - padding=(1, 2) - )) + console.print( + Panel( + md, + title="[bold green]✓ Final Answer[/bold green]", + border_style="green", + box=box.DOUBLE, + padding=(1, 2), + ) + ) def print_memory_stats(stats: Dict[str, Any]) -> None: @@ -137,29 +128,31 @@ def print_memory_stats(stats: Dict[str, Any]) -> None: header_style="bold cyan", box=box.ROUNDED, border_style="cyan", - padding=(0, 1) + padding=(0, 1), ) table.add_column("Metric", style="cyan") table.add_column("Value", justify="right", style="green") # Calculate total tokens - total_used = stats['total_input_tokens'] + stats['total_output_tokens'] + total_used = stats["total_input_tokens"] + stats["total_output_tokens"] # Add rows table.add_row("Total Tokens", f"{total_used:,}") table.add_row("├─ Input", f"{stats['total_input_tokens']:,}") table.add_row("└─ Output", f"{stats['total_output_tokens']:,}") table.add_row("Current Context", f"{stats['current_tokens']:,}") - table.add_row("Compressions", str(stats['compression_count'])) + table.add_row("Compressions", str(stats["compression_count"])) # Net savings with color - savings = stats['net_savings'] + savings = stats["net_savings"] savings_str = f"{savings:,}" if savings >= 0 else f"[red]{savings:,}[/red]" table.add_row("Net Savings", savings_str) table.add_row("Total Cost", f"${stats['total_cost']:.4f}") - table.add_row("Messages", f"{stats['short_term_count']} in memory, {stats['summary_count']} summaries") + table.add_row( + "Messages", f"{stats['short_term_count']} in memory, {stats['summary_count']} summaries" + ) console.print(table) @@ -171,12 +164,14 @@ def print_error(message: str, title: str = "Error") -> None: message: Error message title: Error title (default: "Error") """ - console.print(Panel( - f"[red]{message}[/red]", - title=f"[bold red]❌ {title}[/bold red]", - border_style="red", - box=box.ROUNDED - )) + console.print( + Panel( + f"[red]{message}[/red]", + title=f"[bold red]❌ {title}[/bold red]", + border_style="red", + box=box.ROUNDED, + ) + ) def print_warning(message: str) -> None: