diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fd8b9c8 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Copy this file to `.env` and adjust values for your environment. +# `.env` is gitignored; this template is committed as a reference. +# All values are optional — the application has safe defaults. +# Every key is prefixed with CODE_AGENT_ so it never collides with the +# provider's own environment variables (e.g. Ollama's OLLAMA_HOST). + +# Ollama connection (defaults: http://localhost:11434) +CODE_AGENT_OLLAMA_SCHEME=http +CODE_AGENT_OLLAMA_HOST=localhost +CODE_AGENT_OLLAMA_PORT=11434 +CODE_AGENT_OLLAMA_MODEL=gpt-oss:20b-cloud + +# OpenAI (optional; when omitted, OpenAIProvider falls back to OPENAI_API_KEY) +CODE_AGENT_OPENAI_API_KEY= +CODE_AGENT_OPENAI_MODEL=gpt-4o +CODE_AGENT_OPENAI_BASE_URL= + +# Sampling / behaviour +CODE_AGENT_PROVIDER=ollama +CODE_AGENT_TEMPERATURE=0.7 +CODE_AGENT_MAX_TOKENS=6000 +CODE_AGENT_STREAM=true +CODE_AGENT_ROOT_DIR=. +CODE_AGENT_VERBOSE=true +CODE_AGENT_LOG_LEVEL=INFO +CODE_AGENT_MAX_ITERATIONS=50 +CODE_AGENT_MAX_EXECUTION_TIME=5000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index d180ada..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: CI - -on: - push: - branches: [ main, master ] - pull_request: - branches: [ main, master ] - -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: [ '3.10', '3.11', '3.12', '3.13' ] - steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - cache: pip - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e .[dev] - - - name: Lint with ruff - run: ruff check . --output-format=github - - - name: Format check with black - run: black --check . - - - name: Run tests - run: pytest -q - - build-docs: - needs: test - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v4 - - - name: Set up Python 3.11 - uses: actions/setup-python@v5 - with: - python-version: '3.11' - cache: pip - - - name: Install Python dependencies - run: | - python -m pip install --upgrade pip - pip install -e .[docs] - - - name: Setup Quarto - uses: quarto-dev/quarto-actions/setup@v2 - with: - version: 'release' - - - name: Render Quarto documents - run: quarto render - - - name: Upload Quarto site artifact - uses: actions/upload-artifact@v4 - with: - name: _site - path: _site diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index 86ad146..0000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Build Quarto Docs - -on: - push: - branches: [ main, master ] - paths: - - 'docs/**' - - '_quarto.yml' - - '.github/workflows/docs.yml' - pull_request: - branches: [ main, master ] - workflow_dispatch: - -jobs: - build-docs: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - cache: 'pip' - - - name: Install Python dependencies - run: | - python -m pip install --upgrade pip - pip install -e .[docs] - - - name: Setup Quarto - uses: quarto-dev/quarto-actions/setup@v2 - with: - version: 'release' - - - name: Render Quarto documents - run: | - quarto render - - - name: Upload Quarto site artifact - uses: actions/upload-artifact@v4 - with: - name: _site - path: _site diff --git a/.gitignore b/.gitignore index 13b3f94..509573b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,13 +2,21 @@ .idea/ *.iml *.ipr +.* +apidocs +stubs/ +th* +docs/visualizations/*.mmd +.tmp +**/pdf_docs +mermaid-magic.json # CMake cmake-build-*/ - +_extensions # File-based project format *.iws - +!.pre-commit* # IntelliJ out/ @@ -31,10 +39,12 @@ __pycache__/ *.so # Distribution / packaging +**/node_modules/ .Python build/ develop-eggs/ dist/ +node_modules/ downloads/ eggs/ .eggs/ @@ -69,9 +79,6 @@ htmlcov/ .cache nosetests.xml coverage.xml -*.cover -*.py,cover -.hypothesis/ .pytest_cache/ cover/ @@ -80,8 +87,6 @@ cover/ *.pot # Django stuff: -*.log -local_settings.py db.sqlite3 db.sqlite3-journal @@ -100,7 +105,7 @@ docs/_build/ # IPython profile_default/ -ipython_config.py +# ipython_config.py # temp disabled during this work # pyenv # For a library or package, you might want to ignore these files since the code is @@ -216,3 +221,9 @@ _site/ docs/docs_web/ /renv/.gitignore /renv/activate.R + +# MemPalace per-project files (issue #185) +mempalace.yaml +entities.json +/.tmp/ +pdf_docs/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 659c01d..a3725b7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,27 +1,58 @@ exclude: '^packrat(/|$)' # Exclude the packrat folder - repos: + # Local hooks must be defined inline (repo: local does not read + # .pre-commit-hooks.yaml); the script entries live in project_management/. + - repo: local + hooks: + # - id: run-autofix + # name: run-autofix + # entry: project_management/run_autofix.sh + # language: script + # stages: [pre-commit, manual] + - id: pre-push-quick + name: pre-push-quick + entry: pre-push-quick + language: script + stages: [pre-push, manual] + - id: run-formatters + name: run-formatters + entry: project_management/run_formatters.sh + language: script + stages: [pre-push, manual] + - id: run-vis + name: run-vis + entry: project_management/run_vis.sh + language: script + stages: [pre-push, manual] + - id: run-analyzers + name: run-analyzers + entry: project_management/run_analyzers.sh + language: script + stages: [manual] + - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - id: check-yaml - - id: requirements-txt-fixer + - id: check-added-large-files + - id: check-builtin-literals + - id: check-case-conflict + - id: check-executables-have-shebangs + - id: check-shebang-scripts-are-executable + - id: check-merge-conflict + - id: check-symlinks + - id: check-vcs-permalinks + - id: debug-statements + - id: check-toml + - id: check-ast + - id: destroyed-symlinks + - id: sort-simple-yaml - repo: https://github.com/asottile/setup-cfg-fmt - rev: v3.1.0 + rev: v3.2.0 hooks: - id: setup-cfg-fmt - repo: https://github.com/asottile/pyupgrade - rev: v3.21.0 + rev: v3.21.2 hooks: - id: pyupgrade args: [ --py310-plus ] - - repo: https://github.com/astral-sh/ruff-pre-commit - # Ruff version. - rev: v0.14.3 - hooks: # Run the linter. - - id: ruff-check - args: [ --fix ] - # Run the formatter. - - id: ruff-format diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 2f01bfb..177a19c 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -1,36 +1,36 @@ -- id: ruff-check - name: ruff check - description: "Run 'ruff check' for extremely fast Python linting" - entry: ruff check --force-exclude - language: python - types_or: [ python, pyi, jupyter ] - args: [ ] - require_serial: true - additional_dependencies: [ ] - minimum_pre_commit_version: "2.9.2" -- id: ruff-format - name: ruff format - description: "Run 'ruff format' for extremely fast Python formatting" - entry: ruff format --force-exclude - language: python - types_or: [ python, pyi, jupyter ] - args: [ ] - require_serial: true - additional_dependencies: [ ] - minimum_pre_commit_version: "2.9.2" - -# Legacy alias -- id: ruff - name: ruff (legacy alias) - description: "Run 'ruff check' for extremely fast Python linting" - entry: ruff check --force-exclude - language: python - types_or: [ python, pyi, jupyter ] - args: [ ] - require_serial: true - additional_dependencies: [ ] - minimum_pre_commit_version: "2.9.2" +- id: check-added-large-files + name: check for added large files + description: prevents giant files from being committed. + entry: check-added-large-files + language: python + stages: [pre-commit, pre-push, manual] + minimum_pre_commit_version: 3.2.0 +- id: check-ast + name: check python ast + description: simply checks whether the files parse as valid python. + entry: check-ast + language: python + types: [python] +- id: check-builtin-literals + name: check builtin type constructor use + description: requires literal syntax when initializing empty or zero python builtin types. + entry: check-builtin-literals + language: python + types: [python] +- id: check-case-conflict + name: check for case conflicts + description: checks for files that would conflict in case-insensitive filesystems. + entry: check-case-conflict + language: python +- id: check-executables-have-shebangs + name: check that executables have shebangs + description: ensures that (non-binary) executables have a shebang. + entry: check-executables-have-shebangs + language: python + types: [text, executable] + stages: [pre-commit, pre-push, manual] + minimum_pre_commit_version: 3.2.0 - id: check-illegal-windows-names name: check illegal windows names entry: Illegal Windows filenames detected @@ -41,43 +41,117 @@ description: checks json files for parseable syntax. entry: check-json language: python - types: [ json ] -- id: pretty-format-json - name: pretty format json - description: sets a standard for formatting json files. - entry: pretty-format-json + types: [json] +- id: check-shebang-scripts-are-executable + name: check that scripts with shebangs are executable + description: ensures that (non-binary) files with a shebang are executable. + entry: check-shebang-scripts-are-executable + language: python + types: [text] + stages: [pre-commit, pre-push, manual] + minimum_pre_commit_version: 3.2.0 +- id: check-merge-conflict + name: check for merge conflicts + description: checks for files that contain merge conflict strings. + entry: check-merge-conflict + language: python + types: [text] +- id: check-symlinks + name: check for broken symlinks + description: checks for symlinks which do not point to anything. + entry: check-symlinks + language: python + types: [symlink] +- id: check-toml + name: check toml + description: checks toml files for parseable syntax. + entry: check-toml + language: python + types: [toml] +- id: check-vcs-permalinks + name: check vcs permalinks + description: ensures that links to vcs websites are permalinks. + entry: check-vcs-permalinks language: python - types: [ json ] + types: [text] +- id: check-xml + name: check xml + description: checks xml files for parseable syntax. + entry: check-xml + language: python + types: [xml] +- id: check-yaml + name: check yaml + description: checks yaml files for parseable syntax. + entry: check-yaml + language: python + types: [yaml] +- id: debug-statements + name: debug statements (python) + description: checks for debugger imports and py37+ `breakpoint()` calls in python source. + entry: debug-statement-hook + language: python + types: [python] - id: destroyed-symlinks name: detect destroyed symlinks description: detects symlinks which are changed to regular files with a content of a path which that symlink was pointing to. entry: destroyed-symlinks language: python - types: [ file ] - stages: [ pre-commit, pre-push, manual ] -- id: detect-aws-credentials - name: detect aws credentials - description: detects *your* aws credentials from the aws cli credentials file. - entry: detect-aws-credentials - language: python - types: [ text ] + types: [file] + stages: [pre-commit, pre-push, manual] - id: detect-private-key name: detect private key description: detects the presence of private keys. entry: detect-private-key language: python - types: [ text ] + types: [text] +- id: fix-byte-order-marker + name: fix utf-8 byte order marker + description: removes utf-8 byte order marker. + entry: fix-byte-order-marker + language: python + types: [text] +# pyupgrade https://github.com/asottile/pyupgrade + +- id: mixed-line-ending + name: mixed line ending + description: replaces or checks mixed line ending. + entry: mixed-line-ending + language: python + types: [text] + stages: [pre-commit, pre-push, manual] + +- id: no-commit-to-branch + name: "don't commit to branch" + entry: no-commit-to-branch + language: python + pass_filenames: false + always_run: true - id: sort-simple-yaml name: sort simple yaml files description: sorts simple yaml files which consist only of top-level keys, preserving comments and blocks. language: python entry: sort-simple-yaml files: '^$' -- id: trailing-whitespace - name: trim trailing whitespace - description: trims trailing whitespace. - entry: trailing-whitespace-fixer - language: python - types: [ text ] - stages: [ pre-commit, pre-push, manual ] - minimum_pre_commit_version: 3.2.0 + +# - id: run-autofix +# name: run-autofix +# entry: project_management/run_autofix.sh +# language: script +# stages: [pre-commit, manual] + +- id: run-vis + name: run-vis + entry: project_management/run_vis.sh + language: script + stages: [pre-push, manual] +- id: run-formatters + name: run-formatters + entry: project_management/run_formatters.sh + language: script + stages: [pre-push, manual] +- id: run-analyzers + name: run-analyzers + entry: project_management/run_analyzers.sh + language: script + stages: [manual] diff --git a/Notes.md b/Notes.md deleted file mode 100644 index e69de29..0000000 diff --git a/README.md b/README.md index 883d859..3a5f32e 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Sofia Billger Bergström [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/SofiaBillBerg/code_agent/main/HEAD?urlpath=%2Fdoc%2Ftree%2Fdocs%2FREADME.qmd) -> A lightweight, LLM‑driven assistant that can scaffold projects, edit +> A lightweight, LLM-driven assistant that can scaffold projects, edit > files, generate documentation, and operate as an interactive chatbot. ## Quick start @@ -27,29 +27,31 @@ code-agent You can now type queries, e.g.: +```cli > Show me a simple project scaffold > Add a new function to utils.py +``` -## Architecture +## Package structure and class diagram -``` mermaid -flowchart LR - CLI(Typer CLI) --\u003e Main(Main driver) - Main --\u003e Graph(LangGraph) - Graph --\u003e Tools - Graph --\u003e Memory(Chroma vector store) - Main --\u003e LLM(LLM wrapper) -``` +**Package diagram**: The following diagram illustrates the main packages and their relationships within the Code Agent project. +![Package diagram](docs/visualizations/svg/packages.svg) + +**Classes diagram**: The following diagram illustrates the main classes and their relationships within the Code Agent project. + +![Classes diagram](docs/visualizations/svg/classes.svg) For a deeper dive, see the following sections: -- **[CODE_AGENT](CODE_AGENT.qmd)** – Core components and internals. -- **[USAGE](USAGE.qmd)** – Detailed usage patterns. -- **[FILES](FILES.qmd)** – File‑level overview of the repo. -- **[FAQ & Troubleshooting](FAQ.qmd)** – Common questions and solutions. -- **[CONTRIBUTING](CONTRIBUTING.qmd)** – Guidelines for contributing to +- **[CODE_AGENT](docs/CODE_AGENT.qmd)** - Core components and internals. +- **[USAGE](docs/USAGE.qmd)** - Detailed usage patterns. +- **[FILES](docs/FILES.qmd)** - File-level overview of the repo. +- **[FAQ & Troubleshooting](docs/FAQ.qmd)** - Common questions and solutions. +- **[CONTRIBUTING](docs/CONTRIBUTING.qmd)** - Guidelines for contributing to the project. -- **[LICENSE](../LICENSE)** – Project licensing information. -- **[ROADMAP](ROADMAP.qmd)** – Future plans and development. -- **[CHANGELOG](CHANGELOG.qmd)** – Version history and changes. -- **[Agent Workflow](AGENT_WORKFLOW.qmd)** +- **[LICENSE](./LICENSE)** - Project licensing information. +- **[ROADMAP](docs/ROADMAP.qmd)** - Future plans and development. +- **[CHANGELOG](docs/CHANGELOG.qmd)** - Version history and changes. +- **[Agent Workflow](docs/AGENT_WORKFLOW.qmd)** +- **[Class Visualizations](docs/visualizations/svg/classes.svg)** - Visual representation of class relationships. All class diagrams are generated using [Pyreverse](https://www.logilab.org/project/pyreverse) from the [Pylint](https://pylint.pycqa.org/) project. +- **[Package Visualizations](docs/visualizations/svg/packages.svg)** - Visual representation of package relationships. All package diagrams are generated using [Pyreverse](https://www.logilab.org/project/pyreverse) from the [Pylint](https://pylint.pycqa.org/) project. diff --git a/_quarto.yml b/_quarto.yml index d4dc648..0586202 100644 --- a/_quarto.yml +++ b/_quarto.yml @@ -96,7 +96,7 @@ format: fig-cap-location: bottom tbl-cap-location: top subtitle: "Documentation for The code_agent - an interactive, locally maintained LLM-powered assistant" - description: "The code_agent is a local, LLM‑driven code assistant that can scaffold projects, edit files, generate tests, and produce documentation. It is built on top of LangChain and uses a local Ollama instance as the LLM backend." + description: "The code_agent is a local, LLM-driven code assistant that can scaffold projects, edit files, generate tests, and produce documentation. It is built on top of LangChain and uses a local Ollama instance as the LLM backend." pdf: latex-output-dir: pdf_docs @@ -114,3 +114,4 @@ fig-responsive: true resources: - docs/ - LICENSE + - docs/visualizations/svg/ diff --git a/code_agent/.github/change_2_local_workflows/llm-review.yml b/code_agent/.github/change_2_local_workflows/llm-review.yml new file mode 100644 index 0000000..5767409 --- /dev/null +++ b/code_agent/.github/change_2_local_workflows/llm-review.yml @@ -0,0 +1,32 @@ +# .github/workflows/llm-review.yml +name: LLM Code Review + +on: + push: + branches: [ main ] + +jobs: + review: + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.13' + + - name: Install dependencies + run: | + uv pip install --upgrade pip + uv pip install -r requirements.txt + # Ollama will run on the host; make sure the container image or host has it + + - name: Run coding agent + run: | + uv run python ci/run_agent.py + + - name: Sanity check + run: | + uv run python ci/check_output.py diff --git a/code_agent/README.qmd b/code_agent/README.qmd index 9b0faf0..b2b1f8f 100644 --- a/code_agent/README.qmd +++ b/code_agent/README.qmd @@ -49,4 +49,4 @@ pytest ## Configuration -Edit `code_agent/config/llm_config.json` for LLM settings. Default model: `gpt-oss:20b-cloud` +Edit `.env` at the project root for LLM settings. Default model: `gpt-oss:20b-cloud` diff --git a/code_agent/__init__.py b/code_agent/__init__.py index 093d3cd..42ed155 100644 --- a/code_agent/__init__.py +++ b/code_agent/__init__.py @@ -1,16 +1,55 @@ -"""Top‑level package for *code_agent*.""" +"""Top-level package for *code_agent*.""" from __future__ import annotations # Import agent-related functions from .agents import build_agent, create_default_tools + +# OAP-inspired capability layer (public API) +from .capabilities.audit import AuditLog, Receipt +from .capabilities.base import Capability, CapabilityBase, RiskClass +from .capabilities.envelope import InvocationRequest, InvocationResponse +from .capabilities.registry import CapabilityRegistry +from .capabilities.tool_adapter import tool_to_capability from .core import create_project_scaffold +# First import non-dependent modules # First import non-dependent modules from .exceptions import CodeAgentError, FileCreationError, InvalidToolError from .file_generator import create_from_template, py_to_ipynb, write_file from .main import create_llm, load_config +# Provider-agnostic LLM layer (public API) +from .providers.base import LLMProvider, ProviderBase +from .providers.factory import create_provider +from .providers.ollama import OllamaProvider +from .providers.openai import OpenAIProvider + # Explicitly expose the public API members -__all__ = ["build_agent", "create_default_tools", "create_llm", "write_file", "create_from_template", "py_to_ipynb", - "create_project_scaffold", "load_config", "CodeAgentError", "InvalidToolError", "FileCreationError", ] +__all__ = [ + "AuditLog", + "Capability", + "CapabilityBase", + "CapabilityRegistry", + "CodeAgentError", + "FileCreationError", + "InvalidToolError", + "InvocationRequest", + "InvocationResponse", + "LLMProvider", + "OllamaProvider", + "OpenAIProvider", + "ProviderBase", + "Receipt", + "RiskClass", + "build_agent", + "create_default_tools", + "create_from_template", + "create_llm", + "create_project_scaffold", + "create_provider", + "load_config", + "py_to_ipynb", + "tool_to_capability", + "write_file", +] diff --git a/code_agent/__main__.py b/code_agent/__main__.py index d7009f7..3ae26ab 100644 --- a/code_agent/__main__.py +++ b/code_agent/__main__.py @@ -1,5 +1,4 @@ -""" " -Main entry point for the code_agent package. +"""Main entry point for the code_agent package. This module provides the command-line interface for the code_agent package. When run as `python -m code_agent`, it starts an interactive chat session. diff --git a/code_agent/agents/__init__.py b/code_agent/agents/__init__.py index 7670853..f254d70 100644 --- a/code_agent/agents/__init__.py +++ b/code_agent/agents/__init__.py @@ -1,6 +1,4 @@ -""" -Agent implementations for the code_agent package. -""" +"""Agent implementations for the code_agent package.""" from .base_agent import build_agent, create_default_tools diff --git a/code_agent/agents/base_agent.py b/code_agent/agents/base_agent.py index f01230f..0578f48 100644 --- a/code_agent/agents/base_agent.py +++ b/code_agent/agents/base_agent.py @@ -5,38 +5,73 @@ from collections.abc import Iterable from pathlib import Path +from code_agent.graph import build_graph from langchain_core.language_models.chat_models import BaseChatModel from langchain_core.runnables import Runnable from langchain_core.tools import BaseTool -from code_agent.graph import build_graph - __all__ = ["build_agent", "create_default_tools"] def build_agent(llm: BaseChatModel, tools: Iterable[BaseTool]) -> Runnable: - """Builds a LangChain runnable with tools bound to the LLM.""" + """Builds a LangChain runnable with tools bound to the LLM. + + :param llm: The language model to use. + :param tools: The tools to bind to the LLM. + + :return: A LangChain runnable. + """ return build_graph(llm, list(tools)) def create_default_tools( - root_dir: str | None = None, llm: BaseChatModel | None = None - ) -> list[BaseTool]: - """Return a list of default tools.""" + root_dir: str | None = None, llm: BaseChatModel | None = None +) -> list[BaseTool]: + """Return a list of default tools. + + :param root_dir: The root directory to use for the tools. + :param llm: The language model to use for the tools. - from code_agent.tools import (EditFileTool, FormatCodeTool, GeneralChatTool, GenerateTestTool, LinkerTool, - NewFileTool, NotebookTool, ReadFileTool, RScriptTool, SearchExplainTool, ) + :return: A list of default tools. + """ + from code_agent.tools import ( + EditFileTool, + FormatCodeTool, + GeneralChatTool, + GenerateTestTool, + LinkerTool, + NewFileTool, + NotebookTool, + ReadFileTool, + RScriptTool, + SearchExplainTool, + ) root_path = Path(root_dir) if root_dir else Path.cwd() - standard_tools: list[BaseTool | None] = [ReadFileTool(root_dir = root_path), EditFileTool(root_dir = root_path), - (SearchExplainTool(root_dir = root_path, llm_instance = llm) if llm else None), - LinkerTool(root_dir = root_path), NewFileTool(root_dir = root_path), - (GenerateTestTool(root_dir = root_path, llm_instance = llm) if llm else None), - FormatCodeTool(root_dir = root_path), - (NotebookTool(root_dir = root_path, llm_instance = llm) if llm else None), - (GeneralChatTool(llm_instance = llm) if llm else None), RScriptTool(), ] + standard_tools: list[BaseTool | None] = [ + ReadFileTool(root_dir=root_path), + EditFileTool(root_dir=root_path), + ( + SearchExplainTool(root_dir=root_path, llm_instance=llm) + if llm + else None + ), + LinkerTool(root_dir=root_path), + NewFileTool(root_dir=root_path), + ( + GenerateTestTool(root_dir=root_path, llm_instance=llm) + if llm + else None + ), + FormatCodeTool(root_dir=root_path), + (NotebookTool(root_dir=root_path, llm_instance=llm) if llm else None), + (GeneralChatTool(llm_instance=llm) if llm else None), + RScriptTool(), + ] - tools: list[BaseTool] = [tool for tool in standard_tools if tool is not None] + tools: list[BaseTool] = [ + tool for tool in standard_tools if tool is not None + ] return tools diff --git a/code_agent/agents/persistent_agent.py b/code_agent/agents/persistent_agent.py index 7783e09..02afba1 100644 --- a/code_agent/agents/persistent_agent.py +++ b/code_agent/agents/persistent_agent.py @@ -4,12 +4,17 @@ from pathlib import Path from typing import Any +from code_agent.agents.base_agent import build_agent from langchain_core.language_models import BaseChatModel +from langchain_core.messages import ( + AIMessage, + BaseMessage, + HumanMessage, + SystemMessage, +) from langchain_core.runnables import Runnable from langchain_core.tools import BaseTool - -from code_agent.agents.base_agent import build_agent - +from typing_extensions import Self class PersistentAgent: """A persistent agent that maintains state between sessions.""" @@ -17,78 +22,125 @@ class PersistentAgent: _instance = None _state_file = Path.home() / ".code_agent" / "state.json" - def __new__(cls, *args, **kwargs): + def __new__(cls: type[Self], *args: Any, **kwargs: Any) -> Any | Self: + """Ensure only one instance of the agent exists. + + :param args: Positional arguments. + :param kwargs: Keyword arguments. + :return: The singleton instance of the agent. + """ if cls._instance is None: cls._instance = super().__new__(cls) cls._instance._initialized = False return cls._instance - def __init__(self, llm: BaseChatModel, tools: list[BaseTool]): - if self._initialized: + def __init__(self, llm: BaseChatModel, tools: list[BaseTool]) -> None: + """Initialize the agent with the given LLM and tools. + + :param llm: The language model to use. + :param tools: The tools to use. + :return: None + """ + if self._initialized: # type: ignore[has-type] return - self.agent: Runnable = build_agent(llm = llm, tools = tools) + self.agent: Runnable = build_agent(llm=llm, tools=tools) self.conversation_history: list[dict[str, str]] = [] self.settings: dict[str, Any] = {} self._initialized = True self._load_state() - def _ensure_state_dir(self): - """Ensure the state directory exists.""" - self._state_file.parent.mkdir(parents = True, exist_ok = True) + def _ensure_state_dir(self) -> None: + """Ensure the state directory exists. - def _load_state(self): - """Load agent state from disk.""" + :return: None + """ + self._state_file.parent.mkdir(parents=True, exist_ok=True) + + def _load_state(self) -> None: + """Load agent state from disk. + + :return: None + """ self._ensure_state_dir() if self._state_file.exists(): try: - with open(self._state_file) as f: + with Path(self._state_file).open(encoding="utf-8") as f: data = json.load(f) self.conversation_history = data.get( - "conversation_history", [] - ) + "conversation_history", [] + ) self.settings = data.get("settings", {}) except Exception as e: print(f"⚠️ Warning: Could not load state: {e}") - def _save_state(self): - """Save agent state to disk.""" + def _save_state(self) -> None: + """Save agent state to disk. + + :return: None + """ self._ensure_state_dir() try: - data = {"conversation_history": self.conversation_history, "settings": self.settings, } - with open(self._state_file, "w") as f: - json.dump(data, f, indent = 2) + data = { + "conversation_history": self.conversation_history, + "settings": self.settings, + } + with Path(self._state_file).open("w", encoding="utf-8") as f: + json.dump(data, f, indent=2) except Exception as e: print(f"⚠️ Warning: Could not save state: {e}") def chat(self, message: str) -> str: - """Process a message and return a response.""" - if not self.agent: - return "❌ Agent not initialized. Please check the configuration." + """Process a message and return a response. + :param message: The message to process. + :return: The response from the agent. + """ self.conversation_history.append({"role": "user", "content": message}) try: - response = self.agent.invoke( - {"input": message, "chat_history": self.conversation_history} - ) - response_content = response.get("output", str(response)) - self.conversation_history.append( - {"role": "assistant", "content": response_content} - ) + response = self.agent.invoke({ + "messages": self._history_to_messages() + }) + response_content = response["messages"][-1].content + self.conversation_history.append({ + "role": "assistant", + "content": response_content, + }) self._save_state() return response_content except Exception as e: - error_msg = f"❌ Error: {str(e)}" - self.conversation_history.append( - {"role": "error", "content": error_msg} - ) + error_msg = f"❌ Error: {e!s}" + self.conversation_history.append({ + "role": "error", + "content": error_msg, + }) self._save_state() return error_msg + def _history_to_messages(self) -> list[BaseMessage]: + """Convert stored conversation history into LangChain messages. + + :return: Chronological list of :class:`BaseMessage` objects. + """ + messages: list[BaseMessage] = [] + for entry in self.conversation_history: + role = entry.get("role") + content = entry.get("content", "") + if role == "assistant": + messages.append(AIMessage(content=content)) + elif role == "system": + messages.append(SystemMessage(content=content)) + else: + messages.append(HumanMessage(content=content)) + return messages + def reset_conversation(self) -> None: - """Reset the conversation history.""" + """Reset the conversation history. + + :return: None + """ self.conversation_history = [] self._save_state() @@ -96,15 +148,40 @@ def reset_conversation(self) -> None: agent = None -def get_persistent_agent(llm, tools): +def get_persistent_agent( + llm: BaseChatModel, tools: list[Any] +) -> Any | PersistentAgent | None: + """ + Get persistent agent. + + :param llm: Description of llm + :param tools: Description of tools + :return: Description of return value. + + Example:: + + >>> result = get_persistent_agent(llm="example_llm", tools="example_tools") + """ global agent if agent is None: - agent = PersistentAgent(llm = llm, tools = tools) + agent = PersistentAgent(llm=llm, tools=tools) return agent -def main(): - """Run the interactive chat interface.""" +def main() -> None: + """Run the interactive chat interface. + + This function provides a simple command-line interface for interacting with the agent. + It handles user input, processes it through the agent, and displays the response. + The interface also supports special commands like 'exit', 'quit', 'q', and 'clear'. + The 'clear' command resets the conversation history. + The 'help' command displays a list of available commands. + + :return: None + """ + from code_agent.agents.base_agent import create_default_tools + from code_agent.main import create_llm, load_config + print("\n" + "=" * 50) print("=== Code Agent (Persistent) ===") print("Type 'exit', 'quit', or 'q' to end the session.") @@ -112,7 +189,36 @@ def main(): print("Type 'help' for more options.") print("=" * 50 + "\n") - # This part needs to be refactored to be called from the main entry point # For now, it serves as a placeholder. + try: + cfg = load_config() + llm = create_llm(cfg) + tools = create_default_tools(llm=llm) + agent = get_persistent_agent(llm, tools) + except Exception as e: + print(f"❌ Failed to initialize agent: {e}") + return + + assert agent is not None + + while True: + try: + user_input = input("You: ").strip() + except (EOFError, KeyboardInterrupt): + print("\nGoodbye!") + break + if user_input.lower() in {"exit", "quit", "q"}: + print("Goodbye!") + break + if user_input.lower() == "clear": + agent.reset_conversation() + print("Conversation history cleared.") + continue + if user_input.lower() == "help": + print("Commands: exit, quit, q, clear, help") + continue + if not user_input: + continue + print(f"Agent: {agent.chat(user_input)}") if __name__ == "__main__": diff --git a/code_agent/capabilities/__init__.py b/code_agent/capabilities/__init__.py new file mode 100644 index 0000000..606506d --- /dev/null +++ b/code_agent/capabilities/__init__.py @@ -0,0 +1,10 @@ +"""Capability layer: protocol, base class and risk classes. + +Exposes the public names of the OAP-inspired capability contract. +""" + +from __future__ import annotations + +from .base import Capability, CapabilityBase, RiskClass + +__all__ = ["Capability", "CapabilityBase", "RiskClass"] diff --git a/code_agent/capabilities/audit.py b/code_agent/capabilities/audit.py new file mode 100644 index 0000000..cfd1d53 --- /dev/null +++ b/code_agent/capabilities/audit.py @@ -0,0 +1,112 @@ +"""Append-only, hash-chained audit log for capability invocations. + +Receipts form a tamper-evident chain: each receipt's ``receipt_hash`` is +computed over the previous receipt's hash plus the current invocation +details, so altering any entry breaks every subsequent hash in the chain. + +This is *our own* OAP-inspired implementation - not a copy of the OAP spec. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path + +from pydantic import BaseModel + +class Receipt(BaseModel): + """Immutable record of a single capability invocation. + + Attributes: + request_id: Unique identifier of the invocation request. + capability_id: Stable identifier of the invoked capability. + status: Outcome of the invocation (e.g. "ok" | "error"). + timestamp: UTC ISO-8601 timestamp of when the receipt was created. + prev_hash: Hash of the previous receipt in the chain (tamper link). + receipt_hash: Hash of this receipt's own contents. + """ + + request_id: str + capability_id: str + status: str + timestamp: str + prev_hash: str + receipt_hash: str + + +def _hash(*parts: str) -> str: + """Return the SHA-256 hex digest of the pipe-joined parts. + + :param parts: Strings to hash together. + :return: The hex digest of the concatenated parts. + """ + return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest() + + +class AuditLog: + """Append-only, hash-chained audit log of capability invocations. + + Receipts can only be added via :meth:`record`; they are never mutated or + removed. Each new receipt links to the previous one through ``prev_hash``, + providing tamper evidence for the full chain. + + Args: + path: Optional file to persist each receipt as a JSON line. + """ + + def __init__(self, path: Path | None = None) -> None: + """Initialize the audit log. + + :param path: Optional file to persist each receipt as a JSON line. + :return: None + """ + self._path = Path(path) if path else None + self._last_hash = "GENESIS" + self._receipts: list[Receipt] = [] + + def record( + self, request_id: str, capability_id: str, status: str + ) -> Receipt: + """Append a receipt for a capability invocation. + + :param request_id: Unique identifier of the invocation request. + :param capability_id: Stable identifier of the invoked capability. + :param status: Outcome of the invocation (e.g. "ok" | "error"). + + :return: The created receipt, chained to the previous one. + """ + timestamp = datetime.now(timezone.utc).isoformat() + receipt_hash = _hash( + self._last_hash, request_id, capability_id, status, timestamp + ) + receipt = Receipt( + request_id=request_id, + capability_id=capability_id, + status=status, + timestamp=timestamp, + prev_hash=self._last_hash, + receipt_hash=receipt_hash, + ) + self._last_hash = receipt_hash + self._receipts.append(receipt) + if self._path is not None: + with self._path.open("a", encoding="utf-8") as fp: + fp.write(json.dumps(receipt.model_dump()) + "\n") + return receipt + + def read_chain(self) -> list[Receipt]: + """Return the full receipt chain in append order. + + :return: A copy of the receipts recorded so far; the internal log is never exposed for mutation. + """ + return list(self._receipts) + + @property + def last_hash(self) -> str: + """Access the hash of the most recently recorded receipt. + + :return: The hash of the most recently recorded receipt. + """ + return self._last_hash diff --git a/code_agent/capabilities/base.py b/code_agent/capabilities/base.py new file mode 100644 index 0000000..2f5ff90 --- /dev/null +++ b/code_agent/capabilities/base.py @@ -0,0 +1,107 @@ +"""Core capability contract for the OAP-inspired layer. + +Defines the ``Capability`` protocol (the public interface every capability +must satisfy) and ``CapabilityBase`` (a convenient ABC that implements the +protocol's ``invoke`` dispatch and validation). ``RiskClass`` provides the +canonical risk levels used to gate high-risk capabilities. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, ClassVar, Protocol, runtime_checkable + +from pydantic import BaseModel + +@dataclass +class RiskClass: + """Canonical risk levels for capabilities. + + Used to gate invocation: high-risk capabilities require explicit + approval before execution. + """ + + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + +@runtime_checkable +class Capability(Protocol): + """Structural contract every capability must satisfy. + + Attributes: + id: Stable identifier, e.g. ``"read-file"``. + intent: Human description of what the capability does. + input_model: Pydantic model validating the invocation params. + output_model: Pydantic model describing the invocation result. + risk_class: One of :data:`RiskClass` values. + """ + + id: str + intent: str + input_model: type[BaseModel] + output_model: type[BaseModel] + risk_class: str + + def invoke(self, params: BaseModel) -> BaseModel: + """Execute the capability against validated ``params``. + + :param params: Validated invocation parameters. + :return: The capability result. + """ + ... + + +@dataclass +class CapabilityBase(ABC): + """Base class for capabilities. Subclass and implement ``_execute``. + + Subclasses must set the ``id``, ``intent``, ``input_model`` and + ``output_model`` class attributes. ``invoke`` validates that ``params`` + is an instance of ``input_model`` before delegating to ``_execute``. + """ + + id: ClassVar[str] + intent: ClassVar[str] + input_model: ClassVar[type[BaseModel]] + output_model: ClassVar[type[BaseModel]] + risk_class: ClassVar[str] = RiskClass.LOW + + def invoke(self, params: BaseModel) -> BaseModel: + """Validate ``params`` and delegate to ``_execute``. + + :param params: The invocation parameters, an instance of ``input_model``. + :return: The capability result, an instance of ``output_model``. + :raises TypeError: If ``params`` is not an instance of ``input_model``. + """ + if not isinstance(params, self.input_model): + raise TypeError(f"expected {self.input_model.__name__}") + return self._execute(params) + + @abstractmethod + def _execute(self, params: BaseModel) -> BaseModel: + """Implement the capability's actual behavior. + + :param params: Validated invocation parameters. + + :return: The capability result. + """ + ... + + def describe(self) -> dict[str, Any]: + """Return a JSON-serializable description of the capability. + + Includes the id, intent, risk class and the JSON schemas of the + input and output models. + + :return: A dict describing the capability. + """ + return { + "id": self.id, + "intent": self.intent, + "risk_class": self.risk_class, + "input_schema": self.input_model.model_json_schema(), + "output_schema": self.output_model.model_json_schema(), + } diff --git a/code_agent/capabilities/envelope.py b/code_agent/capabilities/envelope.py new file mode 100644 index 0000000..8064c6a --- /dev/null +++ b/code_agent/capabilities/envelope.py @@ -0,0 +1,53 @@ +"""Invocation envelopes for the capability layer. + +These Pydantic v2 models define the request/response contract used to +invoke capabilities. Every capability call is wrapped in an +:class:`InvocationRequest` and returns an :class:`InvocationResponse`, +giving a uniform, auditable interface across all capabilities. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Literal + +from pydantic import BaseModel, Field + +class InvocationRequest(BaseModel): + """A request to invoke a capability. + + Attributes: + request_id: Unique identifier for this invocation. + capability_id: Stable identifier of the capability to invoke. + params: Typed parameters for the capability. + caller: Optional identifier of the calling entity. + created_at: ISO-8601 timestamp of when the request was created. + """ + + request_id: str + capability_id: str + params: dict[str, Any] = Field(default_factory=dict) + caller: str | None = None + created_at: str = Field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) + + +class InvocationResponse(BaseModel): + """The result of invoking a capability. + + Attributes: + request_id: Echoes the request_id of the originating request. + capability_id: Stable identifier of the invoked capability. + status: Outcome of the invocation, either "ok" or "error". + result: Structured result payload on success. + error: Error message when status is "error". + duration_ms: Wall-clock duration of the invocation in milliseconds. + """ + + request_id: str + capability_id: str + status: Literal["ok", "error"] = "ok" + result: dict[str, Any] | None = None + error: str | None = None + duration_ms: int = 0 diff --git a/code_agent/capabilities/registry.py b/code_agent/capabilities/registry.py new file mode 100644 index 0000000..0a8e089 --- /dev/null +++ b/code_agent/capabilities/registry.py @@ -0,0 +1,166 @@ +"""Capability registry: registration, discovery and dispatch. + +The :class:`CapabilityRegistry` is the central entry point for invoking +capabilities. It validates invocation params against each capability's +``input_model``, gates high-risk capabilities, dispatches to the capability +and records every invocation as an audit :class:`Receipt`. +""" + +from __future__ import annotations + +import time +from typing import Any, Literal + +from .audit import AuditLog, Receipt +from .base import Capability, RiskClass +from .envelope import InvocationRequest, InvocationResponse + +from pydantic import BaseModel, ValidationError + +class CapabilityRegistry: + """Registry of capabilities with discovery and dispatch. + + Args: + audit_log: Optional :class:`AuditLog` used to record every dispatch. + A fresh in-memory log is created when omitted. + """ + + def __init__(self, audit_log: AuditLog | None = None) -> None: + """Initialize the registry with an empty capability map. + + :param audit_log: Optional :class:`AuditLog` used to record every dispatch. + :return: None + """ + self._capabilities: dict[str, Capability] = {} + self._audit_log = audit_log if audit_log is not None else AuditLog() + + def register(self, capability: Capability) -> None: + """Register a capability under its ``id``. + + Registering a capability whose id is already present replaces the + previous entry. + + :param capability: The capability instance to register. + :return: None + """ + self._capabilities[capability.id] = capability + + def discover(self) -> list[dict[str, Any]]: + """Return metadata for all registered capabilities. + + :return: A list of dicts, one per capability, each containing ``id``, + ``intent``, ``risk_class`` and the JSON schema of the + ``input_model``. + """ + return [ + { + "id": capability.id, + "intent": capability.intent, + "risk_class": capability.risk_class, + "input_schema": capability.input_model.model_json_schema(), + } + for capability in self._capabilities.values() + ] + + def dispatch( + self, request: InvocationRequest + ) -> tuple[InvocationResponse, Receipt]: + """Validate, gate and invoke a capability, recording an audit receipt. + + :param request: The invocation request to dispatch. + :returns: A tuple of the invocation response and the audit receipt recorded + for this dispatch. + """ + started = time.perf_counter() + capability = self._capabilities.get(request.capability_id) + + if capability is None: + return self._finish( + request, + started, + status="error", + error=f"unknown capability: {request.capability_id}", + ) + + if capability.risk_class == RiskClass.HIGH: + return self._finish( + request, + started, + status="error", + error=( + f"capability '{request.capability_id}' is high-risk " + "and requires explicit approval" + ), + ) + + try: + params = capability.input_model.model_validate(request.params) + except ValidationError as exc: + return self._finish( + request, + started, + status="error", + error=f"invalid params: {exc}", + ) + + try: + result = capability.invoke(params) + except Exception as exc: + return self._finish( + request, + started, + status="error", + error=f"invocation failed: {exc}", + ) + + return self._finish( + request, started, status="ok", result=self._to_dict(result) + ) + + def _finish( + self, + request: InvocationRequest, + started: float, + status: Literal["ok", "error"], + result: dict[str, Any] | None = None, + error: str | None = None, + ) -> tuple[InvocationResponse, Receipt]: + """Build the response, record the audit receipt and return both. + + :param request: The invocation request being dispatched. + :param started: ``time.perf_counter()`` value captured at dispatch start. + :param status: Outcome of the invocation, "ok" or "error". + :param result: Structured result payload on success. + :param error: Error message when status is "error". + + :return: A tuple of the invocation response and the audit receipt. + """ + duration_ms = int((time.perf_counter() - started) * 1000) + response = InvocationResponse( + request_id=request.request_id, + capability_id=request.capability_id, + status=status, + result=result, + error=error, + duration_ms=duration_ms, + ) + receipt = self._audit_log.record( + request_id=request.request_id, + capability_id=request.capability_id, + status=status, + ) + return response, receipt + + @staticmethod + def _to_dict(result: Any) -> dict[str, Any]: + """Normalize an invocation result to a JSON-serializable dict. + + :param result: The capability's output, typically an ``output_model`` + instance. + :return: A dict representation of the result. + """ + if isinstance(result, BaseModel): + return result.model_dump() + if isinstance(result, dict): + return result + return {"value": result} diff --git a/code_agent/capabilities/tool_adapter.py b/code_agent/capabilities/tool_adapter.py new file mode 100644 index 0000000..005eaad --- /dev/null +++ b/code_agent/capabilities/tool_adapter.py @@ -0,0 +1,124 @@ +"""Adapt LangChain ``BaseTool`` instances into :class:`Capability` objects. + +The :func:`tool_to_capability` factory wraps an existing LangChain tool so it +can be registered in a :class:`CapabilityRegistry` and dispatched through the +standard envelope flow. Tool behavior is preserved: ``invoke`` delegates to +the tool's ``_run`` with the same keyword arguments LangChain would pass. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from .base import CapabilityBase, RiskClass + +from langchain.tools import BaseTool +from pydantic import BaseModel + +# Name fragments that suggest a tool only reads, never mutates state. +_READ_ONLY_HINTS: tuple[str, ...] = ( + "read", + "search", + "lookup", + "list", + "get", + "explain", + "inspect", +) + + +class ToolResult(BaseModel): + """Uniform output model for an adapted tool invocation. + + Attributes: + output: The raw value returned by the underlying tool. + """ + + output: Any + + +def _kebab_case(name: str) -> str: + """Normalize a tool name into a kebab-case capability id. + + :param name: The tool's ``name`` attribute. + :return: The name lower-cased with underscores and spaces replaced by hyphens. + """ + return name.strip().lower().replace("_", "-").replace(" ", "-") + + +def _infer_risk_class(tool: BaseTool) -> str: + """Heuristically assign a risk class to a tool. + + Tools whose names contain read-only hints are treated as low risk; + everything else is medium risk. This is a simple heuristic - callers + may override it with an explicit ``risk_class``. + + :param tool: The tool to classify. + + :return: One of the :data:`RiskClass` values. + """ + lowered = tool.name.lower() + if any(hint in lowered for hint in _READ_ONLY_HINTS): + return RiskClass.LOW + return RiskClass.MEDIUM + + +def _make_execute(tool: BaseTool) -> Callable[[BaseModel], BaseModel]: + """Build the ``_execute`` implementation delegating to a tool. + + The returned callable mirrors ``CapabilityBase._execute``: it receives + validated parameters and returns an ``output_model`` instance. It invokes + the tool's synchronous ``_run`` unchanged, falling back to the public + ``invoke`` only for tools that do not implement ``_run``. + + :param tool: The LangChain tool to delegate to. + :return: A callable from validated params to a :class:`ToolResult`. + """ + + def execute(params: BaseModel) -> BaseModel: + """Execute the tool with the given parameters. + + :param params: Validated input parameters. + :return: A :class:`ToolResult` wrapping the tool's output. + """ + tool_args = params.model_dump() + try: + output = tool._run(**tool_args) + except NotImplementedError: + output = tool.invoke(tool_args) + return ToolResult(output=output) + + return execute + + +def tool_to_capability( + tool: BaseTool, risk_class: str | None = None +) -> CapabilityBase: + """Wrap a LangChain ``BaseTool`` into a :class:`CapabilityBase`. + + A dedicated ``CapabilityBase`` subclass is created per tool so the + derived ``id``, ``intent`` and ``input_model`` are stable class-level + attributes. The capability id is the tool's kebab-cased name, the intent + is the tool's description, and the input model is the tool's + ``args_schema``. + + :param tool: The LangChain tool to adapt. + :param risk_class: Optional explicit risk class; when omitted it is inferred from the tool name (read-only hints map to low risk). + + :return: A capability wrapping ``tool``, ready for registration in a :class:`CapabilityRegistry`. + """ + capability_cls = type( + "ToolCapability", + (CapabilityBase,), + { + "__module__": __name__, + "id": _kebab_case(tool.name), + "intent": tool.description or tool.name, + "input_model": tool.args_schema or BaseModel, + "output_model": ToolResult, + "risk_class": risk_class or _infer_risk_class(tool), + "_execute": staticmethod(_make_execute(tool)), + }, + ) + return capability_cls() diff --git a/code_agent/ci/__init__.py b/code_agent/ci/__init__.py index e69de29..e27e3ed 100644 --- a/code_agent/ci/__init__.py +++ b/code_agent/ci/__init__.py @@ -0,0 +1,6 @@ +"""CI/CD pipeline for the code_agent package. + +This module provides functionality for setting up and running CI/CD pipelines +for the code_agent package. It includes utilities for configuring the pipeline, +running tests, and deploying the package. +""" diff --git a/code_agent/ci/check_output.py b/code_agent/ci/check_output.py index 7f5484f..a6ebf8e 100644 --- a/code_agent/ci/check_output.py +++ b/code_agent/ci/check_output.py @@ -1,11 +1,10 @@ -# ci/check_output.py """Check agent output for issues.""" from __future__ import annotations +from pathlib import Path import re import sys -from pathlib import Path REVIEW_PATH = Path(".ci/llm_review.txt") @@ -13,7 +12,7 @@ print("❌ No review file found") sys.exit(1) -REVIEW = REVIEW_PATH.read_text(encoding = "utf-8") +REVIEW = REVIEW_PATH.read_text(encoding="utf-8") # Check for issues if re.search(r"❌|problem|bug|error|security", REVIEW, re.IGNORECASE): diff --git a/code_agent/ci/run_agent.py b/code_agent/ci/run_agent.py index 7bcc662..66608bd 100644 --- a/code_agent/ci/run_agent.py +++ b/code_agent/ci/run_agent.py @@ -1,25 +1,34 @@ -# ci/run_agent.py """Run agent on staged files for CI.""" from __future__ import annotations +from pathlib import Path import subprocess import sys -from pathlib import Path - -from ..agents.base_agent import build_agent, create_default_tools -from ..main import create_llm, load_config +from code_agent.agents.base_agent import build_agent, create_default_tools +from code_agent.main import create_llm, load_config def get_staged_files() -> list[str]: - """Get list of staged files from git.""" + """Get list of staged files from git. + + :return: List of staged file paths + """ result = subprocess.run( - ["git", "diff", "--name-only", "--cached", "--diff-filter=ACM"], capture_output = True, text = True, ) + ["git", "diff", "--name-only", "--cached", "--diff-filter=ACM"], + capture_output=True, + text=True, + check=True, + ) return [f.strip() for f in result.stdout.split("\n") if f.strip()] -def main(): - """Run agent review on staged files.""" +def main() -> None: + """Run agent review on staged files. + + This script is intended to be run as part of a CI pipeline. + It will review all staged files and save the review to .ci/llm_review.txt. + """ staged = get_staged_files() if not staged: @@ -32,8 +41,8 @@ def main(): cfg = load_config() llm = create_llm(cfg) root_dir = Path(cfg.get("root_dir", ".")).resolve() - tools = create_default_tools(root_dir = str(root_dir), llm = llm) - agent = build_agent(llm = llm, tools = tools) + tools = create_default_tools(root_dir=str(root_dir), llm=llm) + agent = build_agent(llm=llm, tools=tools) # Create review prompt files_list = "\n".join(f"- {f}" for f in staged) @@ -54,10 +63,14 @@ def main(): # Save review review_path = Path(".ci/llm_review.txt") - review_path.parent.mkdir(exist_ok = True) - - output = (response.get("output", str(response)) if isinstance(response, dict) else str(response)) - review_path.write_text(output, encoding = "utf-8") + review_path.parent.mkdir(exist_ok=True) + + output = ( + response.get("output", str(response)) + if isinstance(response, dict) + else str(response) + ) + review_path.write_text(output, encoding="utf-8") print(f"Review saved to {review_path}") diff --git a/code_agent/cli.py b/code_agent/cli.py index f88a873..a6b370e 100644 --- a/code_agent/cli.py +++ b/code_agent/cli.py @@ -1,101 +1,400 @@ -"""Command‑line interface for the **code_agent** package. +"""Command-line interface for the **code_agent** package. -The CLI is intentionally small – it only exposes the most common +The CLI is intentionally small - it only exposes the most common operations that a developer would want when working in a local repository: -* ``create`` – create a new file with supplied content. -* ``append`` – append to an existing file. -* ``scaffold`` – generate a minimal project structure. -* ``py2ipynb`` – convert a Python script to a Jupyter notebook. -* ``docs`` – generate Quarto documentation for the current tree. +* ``create`` - create a new file with supplied content. +* ``append`` - append to an existing file. +* ``scaffold`` - generate a minimal project structure. +* ``py2ipynb`` - convert a Python script to a Jupyter notebook. +* ``docs`` - generate Quarto documentation for the current tree. +* ``chat`` - start an interactive chat session with the agent. +* ``capabilities list`` - list the registered capabilities. +* ``capabilities invoke`` - dispatch an invocation through the registry. +* ``serve`` - start the LLM provider selected by the config. Implementation details ---------------------- -* Uses **Typer** for argument parsing – it provides a pleasant +* Uses **Typer** for argument parsing - it provides a pleasant developer experience (automatic ``--help`` generation, type checking and rich error messages). -* All file‑system interactions are delegated to +* All file-system interactions are delegated to :func:`code_agent.file_generator.write_file` and :func:`code_agent.file_generator.py_to_ipynb`. * ``scaffold`` uses :func:`code_agent.file_generator.create_project_scaffold`. * ``docs`` simply calls :func:`code_agent.docs_generator.generate_quarto_docs`. +* ``capabilities`` commands build a :class:`CapabilityRegistry` populated + with the default tools adapted via :func:`tool_to_capability`, then + discover or dispatch through it. +* ``serve`` selects a provider via :func:`create_provider` from the config + and runs a small read-eval-print loop against ``provider.complete``. * Errors are wrapped in :class:`code_agent.exceptions.CodeAgentError` to -The CLI is intentionally **stateless** – it performs the requested +The CLI is intentionally **stateless** - it performs the requested action and exits. All heavy lifting is done by the helper functions. """ -import subprocess -from pathlib import Path +from __future__ import annotations -import typer +import json +from pathlib import Path +import shutil +import subprocess +import sys +from typing import Any +import uuid from code_agent.agents.base_agent import build_agent, create_default_tools +from code_agent.capabilities.audit import Receipt +from code_agent.capabilities.envelope import ( + InvocationRequest, + InvocationResponse, +) +from code_agent.capabilities.registry import CapabilityRegistry +from code_agent.capabilities.tool_adapter import tool_to_capability from code_agent.docs_generator import generate_quarto_docs from code_agent.exceptions import CodeAgentError from code_agent.file_generator import py_to_ipynb, write_file from code_agent.main import create_llm, load_config +from code_agent.providers.factory import create_provider from code_agent.scaffold import create_project_scaffold +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.tools import BaseTool +import typer + +app = typer.Typer(name="code_agent", help="Local LLM-driven code assistant") + + +def _build_registry(root_dir: str | None = None) -> CapabilityRegistry: + """Build a registry populated with the default tool-adapted capabilities. -app = typer.Typer(name = "code_agent", help = "Local LLM‑driven code assistant") + Each default tool that can be constructed without an LLM is wrapped via + :func:`tool_to_capability` and registered under its kebab-cased id. Tools + that require an LLM (e.g. ``search-explain``, ``generate-test``) are + intentionally omitted so ``capabilities`` commands stay stateless and do + not require a running model backend. + + :param root_dir: Root directory the tools operate within; defaults to the + current working directory. + + :return: A :class:`CapabilityRegistry` with the adapted tools registered. + """ + registry = CapabilityRegistry() + for tool in create_default_tools(root_dir=root_dir): + registry.register(tool_to_capability(tool)) # type: ignore[arg-type] + return registry + + +def _echo_response(response: InvocationResponse, receipt: Receipt) -> None: + """Print an invocation response and its audit receipt. + + :param response: The invocation response to display. + :param receipt: The audit receipt recorded for the dispatch. + :return: None + """ + if response.status == "error": + typer.echo("status: error") + typer.echo(f"error: {response.error}") + else: + typer.echo("status: ok") + typer.echo(f"result: {json.dumps(response.result, default=str)}") + typer.echo(f"duration_ms: {response.duration_ms}") + typer.echo( + f"receipt: request_id={receipt.request_id} " + f"status={receipt.status} timestamp={receipt.timestamp} " + f"receipt_hash={receipt.receipt_hash}" + ) -@app.command(help = "Create a new file with the supplied content.") +capabilities_app = typer.Typer( + help="Inspect and invoke the registered capabilities." +) +app.add_typer(capabilities_app, name="capabilities") + + +@capabilities_app.command("list", help="List all registered capabilities.") +def capabilities_list() -> None: + """List every registered capability (id, intent, risk class). + + The catalog is built from the default tools adapted into capabilities. + Each row shows the capability ``id``, its ``risk_class`` and its + ``intent`` (a human description of what the capability does). + + :return: None + """ + capabilities = _build_registry().discover() + if not capabilities: + typer.echo("No capabilities registered.") + return + + rows: list[tuple[str, str, str]] = [ + (cap["id"], cap["risk_class"], cap["intent"]) for cap in capabilities + ] + id_width = max(len(row[0]) for row in rows) + risk_width = max(len(row[1]) for row in rows) + for cap_id, risk_class, intent in rows: + typer.echo( + f"{cap_id:<{id_width}} {risk_class:<{risk_width}} {intent}" + ) + + +@capabilities_app.command( + "invoke", help="Dispatch a request through the registry." +) +def capabilities_invoke( + capability_id: str = typer.Argument( + ..., help="Identifier of the capability to invoke." + ), + params: str = typer.Option( + "{}", help="JSON object of invocation parameters." + ), +) -> None: + """Invoke a capability and print the response and audit receipt. + + Builds an :class:`InvocationRequest` for ``capability_id`` with the + supplied ``params`` (a JSON object), dispatches it through the registry + and prints the resulting :class:`InvocationResponse` together with the + hash-chained audit :class:`Receipt`. A non-zero exit code is returned + when the dispatch reports an error. + + :param capability_id: Stable identifier of the capability to invoke. + :param params: JSON object of parameters for the capability. + :return: None + """ + try: + parsed_params: dict[str, Any] = json.loads(params) + except json.JSONDecodeError as exc: + raise CodeAgentError(f"Invalid --params JSON: {exc}") from exc + + request = InvocationRequest( + request_id=uuid.uuid4().hex, + capability_id=capability_id, + params=parsed_params, + caller="cli", + ) + response, receipt = _build_registry().dispatch(request) + _echo_response(response, receipt) + if response.status == "error": + raise typer.Exit(code=1) + + +def _ensure_webapp_built() -> None: + """Build the React SPA into ``webapp/dist`` if it is missing. + + ``dist/`` is gitignored, so a fresh checkout ships no built UI until the + frontend is compiled. When Node/npm are available we build it lazily so + ``code-agent serve --web`` works out of the box. A missing toolchain is + non-fatal: the API still serves, just without the static single-page app. + """ + webapp_dir = Path(__file__).resolve().parent / "ui" / "webapp" + dist_dir = webapp_dir / "dist" + if dist_dir.is_dir(): + return + if not webapp_dir.is_dir(): + typer.echo( + "Note: webapp source not found; serving the API without the web UI.", + err=True, + ) + return + npm = shutil.which("npm") + if npm is None: + typer.echo( + "Note: webapp/dist not found and npm is not on PATH; serving the " + "API without the web UI. Run `npm install && npm run build` in " + "code_agent/ui/webapp to build it.", + err=True, + ) + return + typer.echo( + "Building web UI into webapp/dist (npm install && npm run build)..." + ) + try: + subprocess.run( + [npm, "install"], + cwd=str(webapp_dir), + check=True, + capture_output=True, + text=True, + ) + subprocess.run( + [npm, "run", "build"], + cwd=str(webapp_dir), + check=True, + capture_output=True, + text=True, + ) + except ( + subprocess.CalledProcessError, + FileNotFoundError, + ) as exc: # pragma: no cover - environment dependent + detail = (getattr(exc, "stderr", "") or "").strip() or str(exc) + typer.echo( + f"Warning: failed to build web UI ({detail}); serving API without it.", + err=True, + ) + + +@app.command(help="Start the LLM provider selected by the config.") +def serve( + config_path: str | None = typer.Option( + None, + help="Optional path to a JSON configuration file (overrides .env settings).", + ), + web: bool = typer.Option( + False, + "--web", + is_flag=True, + help="Serve the web UI instead of the console loop.", + ), + web_host: str = typer.Option( + "127.0.0.1", + "--web-host", + help="Host to bind the web UI server to.", + ), + web_port: int = typer.Option( + 8000, + "--web-port", + help="Port to bind the web UI server to.", + ), +) -> None: + """Start the provider selected by the config and serve prompts. + + Loads the config, constructs the provider via :func:`create_provider` + and runs a small read-eval-print loop: each line of input is sent to + ``provider.complete`` and the completion is printed. Type ``exit``, + ``quit`` or ``q`` (or press Ctrl-C) to end the session. + + With ``--web`` the command instead starts a local HTTP server for the + web UI (``code_agent.ui.web``): ``GET /capabilities`` lists the + capability catalog and ``POST /invoke`` dispatches audited invocations. + The built React app is served from ``code_agent/ui/webapp/dist`` when + present. The server binds to ``127.0.0.1:8000``; pass ``--web-port`` to + change the port. No API keys or prompt content are ever logged. + + :param config_path: Path to the JSON configuration file. + :param web: Whether to serve the web UI instead of the console loop. + :param web_host: Host to bind the web UI server to. + :param web_port: Port to bind the web UI server to. + :return: None + """ + if web: + _ensure_webapp_built() + import uvicorn + + typer.echo(f"Web UI at http://{web_host}:{web_port}") + uvicorn.run( + "code_agent.ui.web:app", + host=web_host, + port=web_port, + log_level="info", + ) + return + + try: + cfg = load_config(config_path) + provider = create_provider(cfg) + except Exception as exc: # pragma: no cover - exercised via tests + raise CodeAgentError(str(exc)) from exc + + model = getattr(provider, "model", "unknown") + typer.echo(f"Serving provider '{provider.name}' (model: {model}).") + typer.echo("Type 'exit', 'quit' or 'q' to end the session.") + + while True: + try: + prompt = input("You: ").strip() + except (EOFError, KeyboardInterrupt): + typer.echo("\nGoodbye!") + break + if not prompt: + continue + if prompt.lower() in {"exit", "quit", "q"}: + typer.echo("Goodbye!") + break + try: + response = provider.complete([{"role": "user", "content": prompt}]) + except Exception as exc: + typer.echo(f"Error: {exc}") + continue + typer.echo(response) + + +@app.command(help="Create a new file with the supplied content.") def create( - file_path: Path = typer.Argument( - ..., exists = False, help = "Path to the file to create." - ), content: str = typer.Option(..., help = "Content to write into the file."), - overwrite: bool = typer.Option( - False, is_flag = True, help = "Allow overwriting an existing file." - ), ): + file_path: Path = typer.Argument( + ..., exists=False, help="Path to the file to create." + ), + content: str = typer.Option(..., help="Content to write into the file."), + overwrite: bool = typer.Option( + False, is_flag=True, help="Allow overwriting an existing file." + ), +) -> None: """Create ``file_path`` with ``content``. - The file is written atomically – a temporary file is written first + The file is written atomically - a temporary file is written first and then renamed to the target path. If the file already exists - and ``overwrite`` is not set, the command exits with a non‑zero + and ``overwrite`` is not set, the command exits with a non-zero status code. - """ + :param file_path: Path to the file to create. + :param content: Content to write into the file. + :param overwrite: Allow overwriting an existing file. + """ try: if file_path.exists() and not overwrite: raise CodeAgentError( - f"File '{file_path}' already exists. Use --overwrite to replace." - ) + f"File '{file_path}' already exists. Use --overwrite to replace." + ) write_file(file_path, content) typer.echo(f"File written: {file_path}") - except Exception as exc: # pragma: no cover – exercised via tests + except Exception as exc: # pragma: no cover - exercised via tests raise CodeAgentError(str(exc)) from exc -@app.command(help = "Append text to an existing file.") +@app.command(help="Append text to an existing file.") def append( - file_path: Path = typer.Argument( - ..., exists = True, help = "Path to the file to modify." - ), content: str = typer.Option(..., help = "Text to append to the file."), ): + file_path: Path = typer.Argument( + ..., exists=True, help="Path to the file to modify." + ), + content: str = typer.Option(..., help="Text to append to the file."), +) -> None: """Append ``content`` to ``file_path``. The function opens the file in append mode and writes the supplied - content. File locking is *not* required for the use‑cases + content. File locking is *not* required for the use-cases envisioned in this project. - """ + :param file_path: Path to the file to modify. + :param content: Text to append to the file. + """ try: file_path.write_text( - file_path.read_text(encoding = "utf-8") + content, encoding = "utf-8", ) + file_path.read_text(encoding="utf-8") + content, + encoding="utf-8", + ) typer.echo(f"Appended to: {file_path}") - except Exception as exc: # pragma: no cover – exercised via tests + except Exception as exc: # pragma: no cover - exercised via tests raise CodeAgentError(str(exc)) from exc -@app.command(help = "Create a minimal project scaffold.") +@app.command(help="Create a minimal project scaffold.") def scaffold( - target: Path = typer.Argument( - ..., exists = False, help = "Target directory for the scaffold." - ), project_name: str = typer.Option( - "sample_project", "--name", "-n", help = "Project name used in scaffold files.", ), - overwrite: bool = typer.Option( - False, is_flag = True, help = "Overwrite existing files in the target directory.", ), ): + target: Path = typer.Argument( + ..., exists=False, help="Target directory for the scaffold." + ), + project_name: str = typer.Option( + "sample_project", + "--name", + "-n", + help="Project name used in scaffold files.", + ), + overwrite: bool = typer.Option( + False, + is_flag=True, + help="Overwrite existing files in the target directory.", + ), +) -> None: """Generate a project skeleton. Creates a minimal Python project with the following structure: @@ -105,66 +404,92 @@ def scaffold( - .GitHub/workflows/ - GitHub Actions workflows - requirements.txt - Project dependencies - README.qmd - Project documentation - """ + + The function uses :func:`code_agent.file_generator.create_project_scaffold`. + + :param target: Target directory for the scaffold. + :param project_name: Project name used in scaffold files. + :param overwrite: Allow overwriting existing files. + :return: None + """ try: create_project_scaffold( - str(target), project_name = project_name, overwrite = overwrite - ) + str(target), project_name=project_name, overwrite=overwrite + ) typer.echo(f"Scaffold created: {target}") - except Exception as exc: # pragma: no cover – exercised via tests + except Exception as exc: # pragma: no cover - exercised via tests raise CodeAgentError(str(exc)) from exc -@app.command(help = "Convert a Python script to a Jupyter notebook.") +@app.command(help="Convert a Python script to a Jupyter notebook.") def py2ipynb( - src: Path = typer.Argument( - ..., exists = True, help = "Python script to convert." - ), dst: Path = typer.Argument( - ..., exists = False, help = "Target notebook path." - ), ): + src: Path = typer.Argument( + ..., exists=True, help="Python script to convert." + ), + dst: Path = typer.Argument(..., exists=False, help="Target notebook path."), +) -> None: """Create a minimal Jupyter notebook from a Python file. The notebook contains a single code cell with the full source code. The function uses :func:`code_agent.file_generator.py_to_ipynb`. - """ + :param src: Python script to convert. + :param dst: Target notebook path. + :return: None + """ try: py_to_ipynb(src, dst) typer.echo(f"Notebook written: {dst}") - except Exception as exc: # pragma: no cover – exercised via tests + except Exception as exc: # pragma: no cover - exercised via tests raise CodeAgentError(str(exc)) from exc -@app.command(help = "Generate and render Quarto documentation.") +@app.command(help="Generate and render Quarto documentation.") def docs( - output_dir: str = typer.Option( - "docs", help = "Directory to write docs into." - ), overwrite: bool = typer.Option( - True, help = "Overwrite existing files in the output directory." - ), ): - """Generate a minimal set of QMD files and render the Quarto site.""" - + output_dir: str = typer.Option( + "docs", help="Directory to write docs into." + ), + overwrite: bool = typer.Option( + True, help="Overwrite existing files in the output directory." + ), +) -> None: + """Generate a minimal set of QMD files and render the Quarto site. + + The function uses :func:`code_agent.file_generator.generate_quarto_docs`. + + :param output_dir: Directory to write docs into. + :param overwrite: Overwrite existing files in the output directory. + :return: None + """ try: - generate_quarto_docs(output_dir = Path(output_dir), overwrite = overwrite) + generate_quarto_docs(output_dir=Path(output_dir), overwrite=overwrite) typer.echo(f"Docs generated in: {output_dir}") typer.echo("Rendering Quarto site...") - subprocess.run(["quarto", "render"], check = True) + subprocess.run(["quarto", "render"], check=True) typer.echo("Quarto site rendered successfully.") except FileNotFoundError: typer.echo( - "Error: 'quarto' command not found. Please ensure Quarto is installed and in your PATH." - ) - except Exception as exc: # pragma: no cover – exercised via tests + "Error: 'quarto' command not found. Please ensure Quarto is installed and in your PATH." + ) + except Exception as exc: # pragma: no cover - exercised via tests raise CodeAgentError(str(exc)) from exc -@app.command(help = "Start an interactive chat session with the code agent.") +@app.command(help="Start an interactive chat session with the code agent.") def chat( - verbose: bool = typer.Option( - False, "--verbose", "-v", - help = "Show verbose streaming 'thinking' output from the agent.", ), ) -> None: - """Start an interactive chat session with the code agent.""" + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Show verbose streaming 'thinking' output from the agent.", + ), +) -> None: + """Start an interactive chat session with the code agent. + + :param verbose: Show verbose streaming 'thinking' output from the agent. + :return: None + """ try: cfg = load_config() llm = create_llm(cfg) @@ -187,8 +512,8 @@ def chat( # Handle lifecycle and simple commands separately cont, conversation_state = _handle_command( - user_input, conversation_state, tools - ) + user_input, conversation_state, tools + ) if not cont: break if conversation_state is None: @@ -201,32 +526,46 @@ def chat( try: response = agent.invoke(conversation_state) conversation_state = _display_agent_response( - response, conversation_state - ) + response, conversation_state + ) except Exception as e: - print(f"\n❌ Error processing your request: {str(e)}\n") + print(f"\n❌ Error processing your request: {e!s}\n") continue except KeyboardInterrupt: print("\n\n👋 Session ended by user. Goodbye!") break except Exception as e: - print(f"\n❌ An unexpected error occurred: {str(e)}\n") + print(f"\n❌ An unexpected error occurred: {e!s}\n") continue except Exception as e: - print(f"\n❌ Failed to start chat: {e}", file = sys.stderr) + print(f"\n❌ Failed to start chat: {e}", file=sys.stderr) sys.exit(1) -def _setup_agent_and_tools(cfg, llm): +def _setup_agent_and_tools( + cfg: dict[str, Any], llm: BaseChatModel +) -> tuple[Any, list[BaseTool], Path]: + """Setup the agent and tools for the chat session. + + :param cfg: Configuration dictionary. + :param llm: Language model instance. + :return: Tuple of (agent, tools, root_dir) + """ root_dir = Path(cfg.get("root_dir", ".")).resolve() - tools = create_default_tools(root_dir = str(root_dir), llm = llm) - agent = build_agent(llm = llm, tools = tools) + tools = create_default_tools(root_dir=str(root_dir), llm=llm) + agent = build_agent(llm=llm, tools=tools) return agent, tools, root_dir -def _show_startup_info(root_dir, tools): +def _show_startup_info(root_dir: Path, tools: list[BaseTool]) -> None: + """Show startup information for the chat session. + + :param root_dir: Root directory for the agent. + :param tools: List of available tools. + :return: None + """ print("\n" + "=" * 50) print("=== Code Agent Chat ===") print("Type 'exit', 'quit', or 'q' to end the session.") @@ -236,14 +575,23 @@ def _show_startup_info(root_dir, tools): print("=" * 50 + "\n") -def _handle_command(user_input: str, conversation_state: dict, tools: list): +def _handle_command( + user_input: str, + conversation_state: dict[Any, Any] | None, + tools: list, +) -> tuple[bool, dict[Any, Any] | None]: """Handle simple chat commands. Returns (continue_session, conversation_state or None). If a command is handled that should not continue into agent invocation (help, tools, clear), the function returns (True, None). If the session should end, returns (False, _). Otherwise, returns (True, conversation_state) to proceed. + + :param user_input: User input string. + :param conversation_state: Current conversation state. + :param tools: List of available tools. + :return: Tuple of (continue_session, conversation_state or None) """ - if user_input.lower() in ["exit", "quit", "q"]: + if user_input.lower() in {"exit", "quit", "q"}: print("\nGoodbye!") return False, conversation_state @@ -254,8 +602,8 @@ def _handle_command(user_input: str, conversation_state: dict, tools: list): print("- clear: Clear the conversation history") print("- tools: List available tools") print( - "\nYou can also type natural language requests and the agent will try to help you." - ) + "\nYou can also type natural language requests and the agent will try to help you." + ) return True, None if user_input.lower() == "clear": @@ -275,13 +623,23 @@ def _handle_command(user_input: str, conversation_state: dict, tools: list): return True, conversation_state -def _display_agent_response(response, conversation_state): +def _display_agent_response(response: Any, conversation_state) -> dict | Any: + """Display the agent's response to the user. + + :param response: Response from the agent. + :param conversation_state: Current conversation state. + :return: Updated conversation state. + """ if isinstance(response, dict) and "messages" in response: print("\n" + "=" * 50) print("🛠️ Agent response:") messages = response["messages"] for msg in reversed(messages): - if (isinstance(msg, (list, tuple)) and len(msg) > 1 and msg[0] in ["ai", "assistant"]): + if ( + isinstance(msg, (list, tuple)) + and len(msg) > 1 + and msg[0] in {"ai", "assistant"} + ): print(msg[1]) break print("=" * 50 + "\n") @@ -294,20 +652,16 @@ def _display_agent_response(response, conversation_state): return conversation_state -# Register the chat command -app.command(help = "Start an interactive chat session with the code agent")(chat) - - -def main() -> None: # pragma: no cover – thin wrapper +def main() -> None: # pragma: no cover - thin wrapper """Entry point used by ``python -m code_agent.cli``. This function initializes and runs the Typer CLI application. + + :return: None """ app() if __name__ == "__main__": # This allows the script to be run directly with `python -m code_agent.cli` - import sys - main() diff --git a/code_agent/config/llm_config.json b/code_agent/config/llm_config.json deleted file mode 100644 index 40e69dd..0000000 --- a/code_agent/config/llm_config.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "model": "gpt-oss:20b-cloud", - "temperature": 0.7, - "max_tokens": 6000, - "stream": true, - "auth_token": null, - "root_dir": ".", - "system_prompt": "You are an elite software architect and data science expert with deep knowledge of Python, R, and modern development practices.\n\nCAPABILITIES:\n- Design and implement complete, production-ready systems\n- Perform sophisticated code analysis and refactoring\n- Create comprehensive documentation and explanations\n- Solve complex algorithmic and architectural challenges\n- Optimize performance and maintainability\n- Apply advanced design patterns and best practices\n\nAPPROACH:\n- Provide thorough, well-reasoned solutions\n- Consider edge cases and potential issues\n- Write clean, maintainable, well-documented code\n- Explain complex concepts clearly and completely\n- Suggest improvements and alternatives\n- Think holistically about system design\n\nSTANDARDS:\n- Production-quality code with proper error handling\n- Comprehensive docstrings and comments\n- Type hints and validation where appropriate\n- Following language-specific conventions (PEP 8, tidyverse style)\n- Security and performance considerations\n- Scalability and maintainability focus\n\nWhen using tools, use them strategically to gather information before providing complete solutions.\nNever give minimal or toy examples - always provide professional, complete implementations.", - "verbose": true, - "log_level": "INFO", - "max_iterations": 50, - "max_execution_time": 5000 -} diff --git a/code_agent/config/settings.bash b/code_agent/config/settings.bash index 1068e40..be773e3 100644 --- a/code_agent/config/settings.bash +++ b/code_agent/config/settings.bash @@ -1,9 +1,8 @@ - LOCAL_URL="http://localhost:11434/v1/chat" MODEL="gpt-oss:20b-cloud" # Set environment variables for Ollama Cloud export OLLAMA_CLOUD_KEY="$CLOUD_API_KEY" -export OLLAMA_CLOUD=1 # flag to force the script to hit the cloud +export OLLAMA_CLOUD=1 # flag to force the script to hit the cloud export OLLAMA_API_URL="$LOCAL_URL" export OLLAMA_MODEL="$MODEL" diff --git a/code_agent/core.py b/code_agent/core.py index 742d637..2a81bb0 100644 --- a/code_agent/core.py +++ b/code_agent/core.py @@ -1,12 +1,12 @@ """Core helper functions for the *code_agent* package. This module provides a very small public API that is used by the -command‑line interface, the agent runtime and the test‑suite. All -functions are intentionally pure – they do not depend on any global -state – which makes them straightforward to unit‑test. +command-line interface, the agent runtime and the test-suite. All +functions are intentionally pure - they do not depend on any global +state - which makes them straightforward to unit-test. The helpers are thin wrappers around :mod:`code_agent.file_generator`. -They expose a slightly more user‑friendly name and a few convenience +They expose a slightly more user-friendly name and a few convenience arguments such as ``overwrite``. """ @@ -16,64 +16,72 @@ from .exceptions import CodeAgentError from .file_generator import create_from_template as _create_from_template -from .file_generator import (py_to_ipynb, write_file, ) +from .file_generator import py_to_ipynb, write_file from .scaffold import create_project_scaffold # Re-export for public API -__all__ = ["write_file", "create_file", "append_file", "create_from_template", "py_to_ipynb", "create_project_scaffold", - "CodeAgentError", ] +__all__ = [ + "CodeAgentError", + "append_file", + "create_file", + "create_from_template", + "create_project_scaffold", + "py_to_ipynb", + "write_file", +] -def create_file( - path: Path | str, content: str, *, overwrite: bool = False - ) -> Path: +def create_file(path: Path, content: str, *, overwrite: bool = False) -> Path: """Create *path* and write *content*. - Parameters - ---------- - path: - Target file path. - content: - Text to write. - overwrite: - If ``False`` (the default) an existing file will raise a - :class:`CodeAgentError`. - Returns - ------- - Path - Absolute path of the created file. + :param path: Target file path. + :param content: Text to write. + :param overwrite: If ``False`` (the default) an existing file will raise a :class:`CodeAgentError`. + :return: Absolute path of the created file. """ - path = Path(path).expanduser().resolve() if path.exists() and not overwrite: raise CodeAgentError( - f"File {path!s} already exists – use overwrite=True to replace it" - ) + f"File {path!s} already exists - use overwrite=True to replace it" + ) return write_file(path, content) -def append_file(path: Path | str, content: str) -> Path: - """Append *content* to *path*. +def append_file(root_dir: Path, content: str) -> Path: + """Append *content* to the file at *root_dir*. The function opens the file in append mode, writes the content and returns the absolute file path. - """ - path = Path(path).expanduser().resolve() + :param root_dir: Target file path. + :param content: Text to append. + :return: Absolute path of the modified file. + """ + path = Path(root_dir).expanduser().resolve() if not path.exists(): - raise CodeAgentError(f"File {path!s} does not exist – cannot append") - with path.open("a", encoding = "utf-8") as fp: + raise CodeAgentError(f"File {path!s} does not exist - cannot append") + with path.open("a", encoding="utf-8") as fp: fp.write(content) return path def create_from_template( - template_path: Path | str, dest_path: Path | str, *, replace_vars: dict | None = None, ) -> Path: + template_root_dir: Path, + dest_root_dir: Path, + *, + replace_vars: dict | None = None, +) -> Path: """Create a file by copying *template_path* to *dest_path*. Any ``{}`` placeholders in the template are replaced by ``replace_vars`` using :meth:`str.format`. - """ + :param template_path: Path to the template file. + :param dest_path: Path to the destination file. + :param replace_vars: Variables to replace in the template. + :return: Absolute path of the created file. + """ + template_path = Path(template_root_dir).expanduser().resolve() + dest_path = Path(dest_root_dir).expanduser().resolve() return _create_from_template( - template_path, dest_path, replace_vars = replace_vars - ) + template_path, dest_path, replace_vars=replace_vars + ) diff --git a/code_agent/docs_generator.py b/code_agent/docs_generator.py index 173d7fd..ae543b5 100644 --- a/code_agent/docs_generator.py +++ b/code_agent/docs_generator.py @@ -1,5 +1,4 @@ -""" -Generate Quarto (.qmd) documentation files from repository structure. +"""Generate Quarto (.qmd) documentation files from repository structure. This inspects files, extracts basic metadata and writes user-friendly .qmd pages (README.qmd, CODE_AGENT.qmd, FILES.qmd). Optionally uses an LLM to generate content. @@ -12,19 +11,16 @@ from pathlib import Path -from langchain_core.language_models.chat_models import BaseChatModel - from .file_generator import write_file +from langchain_core.language_models.chat_models import BaseChatModel def _gather_repo_info(root: Path) -> dict[str, list[str]]: """Gather information about files in the repository. - Args: - root: Root directory to scan + :param root: Root directory to scan - Returns: - Dictionary with lists of file paths by type + :return: Dictionary with lists of file paths by type """ py_files = [] data_files = [] @@ -35,31 +31,41 @@ def _gather_repo_info(root: Path) -> dict[str, list[str]]: if p.is_file(): if p.suffix == ".py": py_files.append(p.relative_to(root).as_posix()) - elif p.suffix in (".csv", ".tsv", ".json"): + elif p.suffix in {".csv", ".tsv", ".json"}: data_files.append(p.relative_to(root).as_posix()) - elif p.suffix in (".ipynb", ".qmd"): + elif p.suffix in {".ipynb", ".qmd"}: notebooks.append(p.relative_to(root).as_posix()) elif p.parts and "tests" in p.parts: tests.append(p.relative_to(root).as_posix()) - return {"py_files": sorted(set(py_files)), "data_files": sorted(set(data_files)), - "notebooks": sorted(set(notebooks)), "tests": sorted(set(tests)), } + return { + "py_files": sorted(set(py_files)), + "data_files": sorted(set(data_files)), + "notebooks": sorted(set(notebooks)), + "tests": sorted(set(tests)), + } def _render_readme_qmd(info: dict[str, list[str]]) -> str: """Generate content for README.qmd. - Args: - info: Dictionary containing file information from _gather_repo_info() + :param info: Dictionary containing file information from _gather_repo_info() - Returns: - String containing the README.qmd content + :return: String containing the README.qmd content """ - lines = ["---", 'title: "Project overview"', "format:", " markdown_docs:", " css: docs/styles/custom.css", - "---\n", "# Project overview\n", - "This project contains an automated pipeline and a small code agent used to create ", - "and edit files and documentation locally (Quarto).", "\n## Contents\n", - "* Top-level Python modules and scripts (auto-detected)", ] + lines = [ + "---", + 'title: "Project overview"', + "format:", + " markdown_docs:", + " css: docs/styles/custom.css", + "---\n", + "# Project overview\n", + "This project contains an automated pipeline and a small code agent used to create ", + "and edit files and documentation locally (Quarto).", + "\n## Contents\n", + "* Top-level Python modules and scripts (auto-detected)", + ] # Add Python files for p in info["py_files"][:50]: @@ -68,10 +74,15 @@ def _render_readme_qmd(info: dict[str, list[str]]) -> str: lines.append(f"- ... ({len(info['py_files']) - 50} more)") # Add data files section - lines.extend( - ["\n## Data files\n", *(f"- `{p}`" for p in info["data_files"][:50]), - *(["No common data files detected in `data/`"] if not info["data_files"] else []), ] - ) + lines.extend([ + "\n## Data files\n", + *(f"- `{p}`" for p in info["data_files"][:50]), + *( + ["No common data files detected in `data/`"] + if not info["data_files"] + else [] + ), + ]) # Add notebooks section lines.append("\n## Notebooks & docs\n") @@ -84,14 +95,16 @@ def _render_readme_qmd(info: dict[str, list[str]]) -> str: lines.append(f"- `{p}`") # Add how to run section - lines.extend( - ["\n## How to run the pipeline\n", - "See `RUN_MISTRAL.qmd` for detailed instructions about running the analysis pipeline.", - "\n## CodeAgent\n", - "The `code_agent` package provides commands to create files, preview edits (dry-run), ", - "convert `.py` -> `.ipynb`, and scaffold new projects. Use `python -m code_agent.cli --help` for " - "details.", ] - ) + lines.extend([ + "\n## How to run the pipeline\n", + "See `RUN_MISTRAL.qmd` for detailed instructions about running the analysis pipeline.", + "\n## CodeAgent\n", + "The `code_agent` package provides commands to create files, preview edits (dry-run), ", + ( + "convert `.py` -> `.ipynb`, and scaffold new projects. Use `python -m code_agent.cli --help` for " + "details." + ), + ]) return "\n".join(lines) @@ -99,8 +112,7 @@ def _render_readme_qmd(info: dict[str, list[str]]) -> str: def _render_code_agent_qmd() -> str: """Generate content for CODE_AGENT.qmd. - Returns: - String containing the CODE_AGENT.qmd content + :return: String containing the CODE_AGENT.qmd content """ return """--- title: "Code Agent" @@ -132,14 +144,19 @@ def _render_code_agent_qmd() -> str: def _render_files_qmd(info: dict[str, list[str]]) -> str: """Generate content for FILES.qmd. - Args: - info: Dictionary containing file information from _gather_repo_info() + :param info: Dictionary containing file information from _gather_repo_info() - Returns: - String containing the FILES.qmd content + :return: String containing the FILES.qmd content """ - lines = ["---", 'title: "Files"', "format:", " markdown_docs:", " css: docs/styles/custom.css", "---\n", - "# Project files\n", ] + lines = [ + "---", + 'title: "Files"', + "format:", + " markdown_docs:", + " css: docs/styles/custom.css", + "---\n", + "# Project files\n", + ] # Add all files for file_type in ["py_files", "data_files", "notebooks"]: @@ -150,23 +167,23 @@ def _render_files_qmd(info: dict[str, list[str]]) -> str: def generate_quarto_docs( - output_dir: Path = "docs", overwrite: bool = True, use_llm: bool = False, llm: BaseChatModel | None = None, - ) -> \ -list[str]: + output_dir: Path = Path("docs"), + overwrite: bool = False, + use_llm: bool = False, + llm: BaseChatModel | None = None, +) -> list[str]: """Generate a small set of .qmd files in `output_dir`. - Args: - output_dir: Directory to write documentation files - overwrite: Whether to overwrite existing files - use_llm: Whether to use LLM for enhanced documentation generation - llm: Optional LLM instance to use for content generation + :param output_dir: Directory to write documentation files + :param overwrite: Whether to overwrite existing files + :param use_llm: Whether to use LLM for enhanced documentation generation + :param llm: Optional LLM instance to use for content generation - Returns: - List of paths to the generated files + :return: List of paths to the generated files """ - root = Path(".") + root = Path() out = Path(output_dir) - out.mkdir(parents = True, exist_ok = True) + out.mkdir(parents=True, exist_ok=True) info = _gather_repo_info(root) written = [] @@ -174,48 +191,51 @@ def generate_quarto_docs( readme_q = out / "README.qmd" if not overwrite and readme_q.exists(): print(f"Skipping {readme_q} (already exists and overwrite=False)") - else: - if use_llm and llm: - try: - # Build a prompt for the LLM to generate a README - prompt = ("You are an expert technical writer. Create a comprehensive README.qmd " - "for this project. Include sections for: project description, installation, " - "usage, and examples. Format it in Quarto markdown with a YAML header.\n\n" - f"Project files:\n" - f"Python files: {', '.join(info['py_files'][:20])}\n" - f"Data files: {', '.join(info['data_files'][:10])}\n" - f"Notebooks: {', '.join(info['notebooks'][:10])}\n") - - # Use the provided LLM instance - content = llm.invoke(prompt) - if hasattr(content, "content"): - content = content.content - - # Ensure we have a valid string - content = str(content).strip() - - # Ensure it starts with --- for YAML front matter - if not content.startswith("---"): - content = ("---\n" - 'title: "Project Overview"\n' - "format:\n" - " markdown_docs:\n" - " css: docs/styles/custom.css\n" - "---\n\n" + content) - - write_file(readme_q, content) - written.append(str(readme_q)) - - except Exception as e: - print(f"Error generating README with LLM: {e}") - print("Falling back to template-based generation") - content = _render_readme_qmd(info) - write_file(readme_q, content) - written.append(str(readme_q)) - else: + elif use_llm and llm: + try: + # Build a prompt for the LLM to generate a README + prompt = ( + "You are an expert technical writer. Create a comprehensive README.qmd " + "for this project. Include sections for: project description, installation, " + "usage, and examples. Format it in Quarto markdown with a YAML header.\n\n" + f"Project files:\n" + f"Python files: {', '.join(info['py_files'][:20])}\n" + f"Data files: {', '.join(info['data_files'][:10])}\n" + f"Notebooks: {', '.join(info['notebooks'][:10])}\n" + ) + + # Use the provided LLM instance + content = llm.invoke(prompt) + if hasattr(content, "content"): + content = content.content + + # Ensure we have a valid string + content = str(content).strip() + + # Ensure it starts with --- for YAML front matter + if not content.startswith("---"): + content = ( + "---\n" + 'title: "Project Overview"\n' + "format:\n" + " markdown_docs:\n" + " css: docs/styles/custom.css\n" + "---\n\n" + content + ) + + write_file(readme_q, content) + written.append(str(readme_q)) + + except Exception as e: + print(f"Error generating README with LLM: {e}") + print("Falling back to template-based generation") content = _render_readme_qmd(info) write_file(readme_q, content) written.append(str(readme_q)) + else: + content = _render_readme_qmd(info) + write_file(readme_q, content) + written.append(str(readme_q)) # Generate CODE_AGENT.qmd code_agent_q = out / "CODE_AGENT.qmd" diff --git a/code_agent/exceptions.py b/code_agent/exceptions.py index 165915c..1a8bd7f 100644 --- a/code_agent/exceptions.py +++ b/code_agent/exceptions.py @@ -7,7 +7,7 @@ class CodeAgentError(RuntimeError): - """Base exception for all code‑agent related errors.""" + """Base exception for all code-agent related errors.""" class FileCreationError(CodeAgentError): diff --git a/code_agent/file_generator.py b/code_agent/file_generator.py index 0632a9d..151c209 100644 --- a/code_agent/file_generator.py +++ b/code_agent/file_generator.py @@ -1,4 +1,4 @@ -"""Low‑level file‑system helpers used by the *code_agent* package. +"""Low-level file-system helpers used by the *code_agent* package. The goal of this module is to provide **pure, synchronous** helpers that write text files and convert a simple Python script into a minimal Jupyter @@ -8,29 +8,34 @@ failure. The module deliberately avoids external dependencies. The notebook -generation falls back to a hand‑crafted JSON if :mod:`nbformat` is not +generation falls back to a hand-crafted JSON if :mod:`nbformat` is not available. """ from __future__ import annotations -import json from collections.abc import Iterable +import json from pathlib import Path from typing import Any -try: # Optional dependency – used only for the notebook path. - import nbformat # type: ignore -except Exception: # pragma: no cover – handled at runtime - nbformat = None - from .exceptions import CodeAgentError -__all__ = ["write_file", "create_from_template", "py_to_ipynb", ] +try: # Optional dependency - used only for the notebook path. + import nbformat # type: ignore +except Exception: # pragma: no cover - handled at runtime + nbformat = None # type: ignore[assignment] + +__all__ = ["create_from_template", "py_to_ipynb", "write_file"] def write_file( - target: Path | str, content: str, *, mode: str = "w", encoding: str = "utf-8", ) -> Path: + target: Path | str, + content: str, + *, + mode: str = "w", + encoding: str = "utf-8", +) -> Path: """Write *content* to *target* atomically. The function creates any missing parent directories, writes the @@ -38,80 +43,102 @@ def write_file( temporary file to ``target``. This prevents partial writes if the process is interrupted. - Parameters - ---------- - target: - Destination file path. - content: - Text to write. - mode: - File mode – defaults to ``"w"``. - encoding: - Text encoding – defaults to ``"utf-8"``. - Returns - ------- - Path - The absolute path of the written file. + :param target: Destination file path. + :param content: Text to write. + :param mode: File mode - defaults to ``"w"``. + :param encoding: Text encoding - defaults to ``"utf-8"``. + :return: The absolute path of the written file. """ - target = Path(target).expanduser().resolve() if target.is_dir(): raise CodeAgentError(f"Cannot write to a directory: {target!s}") try: - target.parent.mkdir(parents = True, exist_ok = True) + target.parent.mkdir(parents=True, exist_ok=True) tmp = target.with_suffix(".tmp") - with tmp.open(mode, encoding = encoding) as fp: + with tmp.open(mode, encoding=encoding) as fp: fp.write(content) tmp.replace(target) return target - except OSError as exc: # pragma: no cover – exercised via tests - raise CodeAgentError( - f"Failed to write file {target!s}: {exc}" - ) from exc + except OSError as exc: # pragma: no cover - exercised via tests + raise CodeAgentError(f"Failed to write file {target!s}: {exc}") from exc def create_from_template( - template_path: Path | str, dest_path: Path | str, *, replace_vars: dict | None = None, ) -> Path: + template_root_dir: Path, + dest_root_dir: Path, + *, + replace_vars: dict | None = None, +) -> Path: """Create *dest_path* by copying *template_path*. ``replace_vars`` may contain placeholder keys that will be replaced in the template text using :meth:`str.format`. The function returns the absolute :class:`Path` to the created file. - """ - template_path = Path(template_path).expanduser().resolve() - dest_path = Path(dest_path).expanduser().resolve() + :param template_path: Path to the template file. + :param dest_path: Destination file path. + :param replace_vars: Optional dict of placeholders to replace. + :return: The absolute path of the created file. + """ + template_path = Path(template_root_dir).expanduser().resolve() + dest_path = Path(dest_root_dir).expanduser().resolve() if not template_path.is_file(): raise CodeAgentError(f"Template file {template_path!s} does not exist") try: - text = template_path.read_text(encoding = "utf-8") + text = template_path.read_text(encoding="utf-8") if replace_vars: text = text.format(**replace_vars) return write_file(dest_path, text) - except Exception as exc: # pragma: no cover – exercised via tests + except Exception as exc: # pragma: no cover - exercised via tests raise CodeAgentError( - f"Failed to create {dest_path!s} from template {template_path!s}: {exc}" - ) from exc + f"Failed to create {dest_path!s} from template {template_path!s}: {exc}" + ) from exc def _generate_ipynb_from_cells( - cells: Iterable[str], ) -> ( - dict[str, list[dict[str, str | None | dict[Any, Any] | list[Any]]] | dict[str, dict[str, str]] | int,] | str): + cells: Iterable[str], +) -> ( + dict[ + str, + list[dict[str, str | dict[Any, Any] | list[Any] | None]] + | dict[str, dict[str, str]] + | int, + ] + | str +): """Return a minimal Jupyter notebook dict for the given *cells*. - The function is intentionally minimal – it creates a single + The function is intentionally minimal - it creates a single code cell per element in ``cells``. If :mod:`nbformat` is available, the notebook is created using the public API; otherwise a - hand‑crafted minimal structure is returned. - """ + hand-crafted minimal structure is returned. + :param cells: Iterable of cell contents. + :return: Minimal Jupyter notebook dict. + """ if nbformat is None: - # Hand‑crafted minimal notebook – sufficient for the tests. - return {"cells": [ - {"cell_type": "code", "execution_count": None, "metadata": {}, "outputs": [], "source": cell, } for cell - in cells], - "metadata": {"kernelspec": {"display_name": "python", "language": "python", "name": "python", }}, - "nbformat": 4, "nbformat_minor": 2, } + # Hand-crafted minimal notebook - sufficient for the tests. + return { + "cells": [ + { + "cell_type": "code", + "execution_count": None, + "metadata": {}, + "outputs": [], + "source": cell, + } + for cell in cells + ], + "metadata": { + "kernelspec": { + "display_name": "python", + "language": "python", + "name": "python", + } + }, + "nbformat": 4, + "nbformat_minor": 2, + } # When nbformat is available we can use the public API. nb = nbformat.v4.new_notebook() for cell in cells: @@ -119,34 +146,26 @@ def _generate_ipynb_from_cells( return nbformat.writes(nb) -def py_to_ipynb(py_file: Path | str, output: Path | str | None = None) -> Path: +def py_to_ipynb(py_file: Path, output: Path | None = None) -> Path: """Convert a Python script to a minimal Jupyter notebook. - The function searches the script for ``# %%`` markers – any text + The function searches the script for ``# %%`` markers - any text following a marker until the next marker (or the file end) becomes a separate cell. If no markers are found, the entire file becomes a single cell. - Parameters - ---------- - py_file: - Path to the input Python file. - output: - Destination notebook path. If omitted, ``py_file`` is + :param py_file: Path to the input Python file. + :param output: Destination notebook path. If omitted, ``py_file`` is rewritten with a ``.ipynb`` extension. - Returns - ------- - Path - Absolute path to the generated notebook. + :return: Absolute path to the generated notebook. """ - py_file = Path(py_file).expanduser().resolve() if not py_file.is_file(): raise CodeAgentError(f"Python file {py_file!s} does not exist") - content = py_file.read_text(encoding = "utf-8") + content = py_file.read_text(encoding="utf-8") cells: list[str] = [] current: list[str] = [] - for line in content.splitlines(True): + for line in content.splitlines(keepends=True): if line.lstrip().startswith("# %%"): if current: cells.append("".join(current)) @@ -155,7 +174,7 @@ def py_to_ipynb(py_file: Path | str, output: Path | str | None = None) -> Path: current.append(line) if current: cells.append("".join(current)) - if not cells: # empty file – create a single empty cell + if not cells: # empty file - create a single empty cell cells = ["\n"] nb_dict = _generate_ipynb_from_cells(cells) if output is None: @@ -167,11 +186,11 @@ def py_to_ipynb(py_file: Path | str, output: Path | str | None = None) -> Path: # nbformat returned a string when used. write_file(output, nb_dict) else: - # hand‑crafted dict. - json_text = json.dumps(nb_dict, indent = 2) + # hand-crafted dict. + json_text = json.dumps(nb_dict, indent=2) write_file(output, json_text) return output - except Exception as exc: # pragma: no cover – exercised via tests + except Exception as exc: # pragma: no cover - exercised via tests raise CodeAgentError( - f"Failed to write notebook {output!s}: {exc}" - ) from exc + f"Failed to write notebook {output!s}: {exc}" + ) from exc diff --git a/code_agent/graph.py b/code_agent/graph.py index e234271..5e5040e 100644 --- a/code_agent/graph.py +++ b/code_agent/graph.py @@ -1,5 +1,4 @@ -""" -This module defines the core agent graph using LangGraph. +"""This module defines the core agent graph using LangGraph. The graph orchestrates the flow of conversation, tool use, and memory. The function `build_graph` is designed for direct use, but a thin @@ -10,27 +9,25 @@ from __future__ import annotations -from typing import TypedDict +from typing import TypedDict, cast from langchain_core.language_models import BaseChatModel -from langchain_core.messages import (AIMessage, BaseMessage, ) +from langchain_core.messages import AIMessage, BaseMessage from langchain_core.runnables import Runnable, RunnableConfig from langchain_core.tools import BaseTool from langgraph.graph import END, StateGraph from langgraph.prebuilt import ToolNode - -# --------------------------------------------------------------------------- # State definition -# --------------------------------------------------------------------------- + class AgentState(TypedDict): """The conversational state. Attributes - ---------- - messages: - A sequence of chat messages that represents the conversation - history. + ---------- + messages: + A sequence of chat messages that represents the conversation + history. """ messages: list[BaseMessage] @@ -45,17 +42,10 @@ def call_llm(state: AgentState, model: Runnable) -> AgentState: """Invoke the LLM with the full conversation history and return the updated state. - Parameters - ---------- - state: - The current state of the graph. - model: - A tool‑aware LLM instance. - - Returns - ------- - AgentState - Updated state that contains the new LLM message. + :param state: The current state of the graph. + :param model: A tool-aware LLM instance. + + :return: The updated AgentState that contains the new LLM message. """ response = model.invoke(state["messages"]) # Preserve the conversation history @@ -67,6 +57,10 @@ def should_continue(state: AgentState) -> str: If the last LLM message contains a tool call, we route to the ``action`` node; otherwise we finish the conversation. + + :param state: The current state of the graph. + + :return: The name of the next node. """ last = state["messages"][-1] if isinstance(last, AIMessage) and last.tool_calls: @@ -74,25 +68,16 @@ def should_continue(state: AgentState) -> str: return END -# --------------------------------------------------------------------------- # Graph construction -# --------------------------------------------------------------------------- def build_graph(llm: BaseChatModel, tools: list[BaseTool]) -> Runnable: - """Build a LangGraph ``StateGraph`` for a tool‑aware agent. - - Parameters - ---------- - llm: - The underlying language model. - tools: - A list of tools that the agent can invoke. - - Returns - ------- - Runnable - The compiled graph ready for execution. + """Build a LangGraph ``StateGraph`` for a tool-aware agent. + + :param llm: The underlying language model. + :param tools: A list of tools that the agent can invoke. + + :returns: The compiled graph ready for execution. """ # Bind tools to the LLM model = llm.bind_tools(tools) @@ -108,7 +93,10 @@ def build_graph(llm: BaseChatModel, tools: list[BaseTool]) -> Runnable: # Conditional routing graph.add_conditional_edges( - "agent", should_continue, {"action": "action", END: END}, ) + "agent", + should_continue, + {"action": "action", END: END}, + ) # Return to agent after a tool call graph.add_edge("action", "agent") @@ -129,10 +117,14 @@ def graph_factory(config: RunnableConfig) -> Runnable: ``RunnableConfig`` argument. The configuration is expected to contain ``configurable`` entries ``llm`` (``BaseChatModel``) and ``tools`` (``List[BaseTool]``). + + :param config: The configuration for the graph. + :returns: The compiled graph ready for execution. """ cfg = config.get("configurable", {}) llm: BaseChatModel = cfg["llm"] - tools: list[BaseTool] = cfg["tools"] + tools = cast(list[BaseTool], cfg["tools"]) + return build_graph(llm, tools) diff --git a/code_agent/main.py b/code_agent/main.py index 975dd5e..67c95ce 100644 --- a/code_agent/main.py +++ b/code_agent/main.py @@ -1,5 +1,4 @@ -""" -Main entry point for the Code Agent CLI. +"""Main entry point for the Code Agent CLI. This script wires together the LLM, embeddings, vector store, and tool set, builds the LangGraph, and runs an interactive loop. @@ -8,122 +7,170 @@ from __future__ import annotations import argparse +from collections.abc import Callable, Sequence import json import logging -import sys -from collections.abc import Sequence from pathlib import Path +import sys from typing import Any +# Local imports +from code_agent.agents.base_agent import create_default_tools +from code_agent.graph import build_graph +from code_agent.settings import Settings, get_settings + # LangChain imports from langchain_chroma import Chroma from langchain_community.embeddings import GPT4AllEmbeddings -from langchain_core.language_models import BaseChatModel -from langchain_core.messages import (AIMessage, BaseMessage, HumanMessage, ) +from langchain_core.callbacks.manager import CallbackManagerForLLMRun +from langchain_core.language_models import BaseChatModel, LanguageModelInput +from langchain_core.messages import AIMessage, BaseMessage, HumanMessage from langchain_core.outputs import ChatGeneration, ChatResult from langchain_core.runnables import Runnable from langchain_core.tools import BaseTool # Added import for BaseTool -# Local imports -from code_agent.agents.base_agent import create_default_tools -from code_agent.graph import build_graph - log = logging.getLogger(__name__) -# --------------------------------------------------------------------------- # Configuration helpers -# --------------------------------------------------------------------------- -def load_config( - config_path: str = "code_agent/config/llm_config.json", ) -> dict[str, Any]: - """Load JSON config, tolerant to missing file. +def load_config(config_path: str | None = None) -> dict[str, Any]: + """Load configuration as a dictionary. + + When *config_path* is provided the JSON file at that location is loaded + (legacy override). When it is ``None`` the typed application settings are + returned instead, so the ``.env`` file / environment remain the single + source of truth. - Parameters - ---------- - config_path: - Path to the JSON configuration file. If the path points to a - directory, the function will look for ``llm_config.json`` inside. + :param config_path: Optional path to a JSON configuration file. If the path points to a directory, the function will look for ``llm_config.json`` inside. - Returns - ------- - Dict[str, Any] - Parsed configuration dictionary. + :returns: Parsed configuration dictionary. """ + if config_path is None: + return get_settings().model_dump() + cfg_file = Path(config_path) if cfg_file.is_dir(): - cfg_file = cfg_file / "llm_config.json" + cfg_file /= "llm_config.json" if not cfg_file.exists(): - script_dir = Path(__file__).parent - alt = script_dir / "config" / "llm_config.json" - if alt.exists(): - cfg_file = alt - else: - raise FileNotFoundError( - f"Config file not found: {config_path} or {alt}" - ) + raise FileNotFoundError(f"Config file not found: {config_path}") - with cfg_file.open("r", encoding = "utf-8") as f: + with cfg_file.open("r", encoding="utf-8") as f: return json.load(f) -# --------------------------------------------------------------------------- # LLM helpers -# --------------------------------------------------------------------------- -def create_llm(cfg: dict[str, Any]) -> BaseChatModel: +def create_llm(cfg: Settings | dict[str, Any]) -> BaseChatModel: """Create an LLM instance from config, with graceful fallback. - The function supports an Ollama‑style backend and falls back to a + The function supports an Ollama-style backend and falls back to a lightweight dummy model that returns an error message when the real - LLM cannot be initialised. + LLM cannot be initialized. + + :param cfg: Either a :class:`~code_agent.settings.Settings` instance or a plain + configuration dictionary (e.g. from :func:`load_config`). + + :returns: A :class:`~langchain_core.language_models.BaseChatModel` instance. """ + if isinstance(cfg, Settings): + cfg = cfg.model_dump() + scheme = cfg.get("ollama_scheme", "http") host = cfg.get("ollama_host", "localhost") port = cfg.get("ollama_port", 11434) model = cfg.get("ollama_model", "gpt-oss:20b-cloud") temperature = cfg.get("temperature", 0.7) + # ``ChatOllama`` is constructed lazily and never contacts the server, so an + # invalid port would otherwise slip through and fail only at request time. + # Build the URL up front and validate the port eagerly so bad configs + # route to the graceful ``_FallbackLLM`` instead of hanging on a connection. base_url = f"{scheme}://{host}:{port}" try: from langchain_ollama import ChatOllama + try: + int(port) + except (TypeError, ValueError): + raise ValueError(f"Invalid ollama_port: {port!r}") + return ChatOllama( - model = model, base_url = base_url, temperature = temperature - ) - except Exception as exc: # pragma: no cover – fallback path + model=model, base_url=base_url, temperature=temperature + ) + except Exception as exc: # pragma: no cover - fallback path class _FallbackLLM(BaseChatModel): _err: Exception _base_url: str - def __init__(self, err: Exception, base_url: str, **kwargs: Any): + def __init__( + self, err: Exception, base_url: str, **kwargs: Any + ) -> None: + """Initialise the fallback LLM. + + :param err: The exception that caused the fallback. + :param base_url: The base URL of the Ollama server. + :param kwargs: Additional keyword arguments. + :return: None + """ super().__init__(**kwargs) self._err = err self._base_url = base_url def _generate( - self, messages: list, stop: list | None = None, **kwargs: Any - ) -> ChatResult: - content = json.dumps( - {"error": "LLM unavailable", "details": (f"Failed to initialise ChatOllama. Error: {self._err}" - f". Base URL: {self._base_url}."), } - ) + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + """Generate a response from the fallback LLM. + + :param messages: The messages to generate a response from. + :param stop: The stop sequences. + :param run_manager: The run manager. + :param kwargs: Additional keyword arguments. + :return: The generated response. + """ + content = json.dumps({ + "error": "LLM unavailable", + "details": ( + f"Failed to initialise ChatOllama. Error: {self._err}" + f". Base URL: {self._base_url}." + ), + }) return ChatResult( - generations = [ChatGeneration(message = AIMessage(content = content))] - ) + generations=[ + ChatGeneration(message=AIMessage(content=content)) + ] + ) @property def _llm_type(self) -> str: + """Access the type of the LLM. + + :return: The type of the LLM. + """ return "fallback" def bind_tools( - self, tools: list[BaseTool], **kwargs: Any - ) -> Runnable[Any, BaseMessage]: + self, + tools: Sequence[ + dict[str, Any] | type | Callable[..., Any] | BaseTool + ], + **kwargs: Any, + ) -> Runnable[LanguageModelInput, AIMessage]: + """Bind tools to the LLM. + + :param tools: The tools to bind. + :param kwargs: Additional keyword arguments. + :return: The runnable LLM. + """ return self # Simply return self for fallback LLM return _FallbackLLM(exc, base_url) @@ -140,21 +187,17 @@ def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: Parses the given list of arguments and returns a `argparse.Namespace` object containing the parsed arguments. - Parameters - ---------- - argv : Sequence[str] | None - List of command-line arguments. If `None`, `sys.argv` is used. + :param argv : List of command-line arguments. If `None`, `sys.argv` is used. - Returns - ------- - argparse.Namespace - A namespace object containing the parsed arguments. + :return: A namespace object containing the parsed arguments. """ parser = argparse.ArgumentParser( - prog = "code_agent", description = "An interactive agent for code manipulation.", ) + prog="code_agent", + description="An interactive agent for code manipulation.", + ) parser.add_argument( - "--debug", action = "store_true", help = "Enable DEBUG logs." - ) + "--debug", action="store_true", help="Enable DEBUG logs." + ) return parser.parse_args(argv) @@ -164,32 +207,28 @@ def _setup_logging(debug: bool) -> None: This function sets up the logging module for the Code Agent. The logging level is set to `DEBUG` if the `debug` parameter is `True`, otherwise it is set to `INFO`. - Parameters - ---------- - debug : bool Whether to enable DEBUG logs. - Returns - ------- - None This function does not return any value. + :param debug: Whether to enable DEBUG logs. + + :return: None """ level = logging.DEBUG if debug else logging.INFO logging.basicConfig( - level = level, format = "[%(asctime)s] %(levelname)s %(name)s: %(message)s", datefmt = "%H:%M:%S", ) + level=level, + format="[%(asctime)s] %(levelname)s %(name)s: %(message)s", + datefmt="%H:%M:%S", + ) -# --------------------------------------------------------------------------- # Main entry point -# --------------------------------------------------------------------------- def main(argv: Sequence[str] | None = None) -> None: """Entry point for the code agent CLI. - Parameters - ---------- - argv: - Optional argument vector. If ``None`` the function will read from + :param argv: Optional argument vector. If ``None`` the function will read from :data:`sys.argv`. + :return: None """ print("=== Code Agent CLI ===") print("Type 'exit' or 'quit' to end the session.\n") @@ -202,27 +241,29 @@ def main(argv: Sequence[str] | None = None) -> None: cfg = load_config() llm = create_llm(cfg) root_dir = Path(cfg.get("root_dir", ".")).resolve() - tools = create_default_tools(root_dir = str(root_dir), llm = llm) + tools = create_default_tools(root_dir=str(root_dir), llm=llm) # Build the LangGraph app = build_graph(llm, tools) - # Vector store for retrieval‑augmented generation + # Vector store for retrieval-augmented generation memory_dir = root_dir / ".code_agent_memory" - memory_dir.mkdir(exist_ok = True) + memory_dir.mkdir(exist_ok=True) - embeddings = GPT4AllEmbeddings(client = None) + embeddings = GPT4AllEmbeddings(client=None) vectorstore = Chroma( - collection_name = "code_agent_conversations", embedding_function = embeddings, - persist_directory = str(memory_dir), ) + collection_name="code_agent_conversations", + embedding_function=embeddings, + persist_directory=str(memory_dir), + ) log.info(f"Agent initialized with root: {root_dir}") log.info(f"Persistent memory initialized at: {memory_dir}") print(f"Available tools: {[t.name for t in tools]}\n") except Exception as e: log.critical( - "Error loading or initializing agent: %s", e, exc_info = True - ) + "Error loading or initializing agent: %s", e, exc_info=True + ) print(f"❌ Critical Error: {e}") sys.exit(1) @@ -236,21 +277,31 @@ def main(argv: Sequence[str] | None = None) -> None: def _handle_retrieval( - vectorstore: Chroma, user_input: str, chat_history: list[BaseMessage] - ) -> None: - """Retrieve relevant documents and update chat history.""" - retrieved_docs = vectorstore.similarity_search(user_input, k = 2) + vectorstore: Chroma, user_input: str, chat_history: list[BaseMessage] +) -> None: + """Retrieve relevant documents and update chat history. + + :param vectorstore: Chroma vector store for retrieval. + :param user_input: User's input. + :param chat_history: List of messages in the chat history. + :return: None + """ + retrieved_docs = vectorstore.similarity_search(user_input, k=2) if retrieved_docs: print("\n🧠 Retrieved from memory:") for doc in retrieved_docs: chat_history.append( - HumanMessage(content = f"Past context: {doc.page_content}") - ) + HumanMessage(content=f"Past context: {doc.page_content}") + ) print(f"- {doc.page_content[:100]}...") def _process_agent_event(event: dict) -> AIMessage | None: - """Process a single event from the agent stream and print tool calls.""" + """Process a single event from the agent stream and print tool calls. + + :param event: Event from the agent stream. + :return: Final agent response if available. + """ final_response = None for node, output in event.items(): if node == "agent": @@ -258,8 +309,8 @@ def _process_agent_event(event: dict) -> AIMessage | None: if getattr(agent_response, "tool_calls", None): for tool_call in agent_response.tool_calls: print( - f"🛠️ Agent decided to use tool: **{tool_call['name']}**" - ) + f"🛠️ Agent decided to use tool: **{tool_call['name']}**" + ) print(f" With arguments: {tool_call['args']}") else: final_response = agent_response @@ -269,18 +320,38 @@ def _process_agent_event(event: dict) -> AIMessage | None: def _update_history_and_persist( - vectorstore: Chroma, user_input: str, final_response: AIMessage, chat_history: list[BaseMessage], ) -> None: - """Update chat history and persist to vector store.""" + vectorstore: Chroma, + user_input: str, + final_response: AIMessage, + chat_history: list[BaseMessage], +) -> None: + """Update chat history and persist to vector store. + + :param vectorstore: Chroma vector store for retrieval. + :param user_input: User's input. + :param final_response: Final response from the agent. + :param chat_history: List of messages in the chat history. + :return: None + """ print("\n=== Agent response ===") print(final_response.content) chat_history.append(final_response) vectorstore.add_texts( - texts = [user_input, final_response.content], - metadatas = [{"type": "user_query"}, {"type": "agent_response"}, ], ) + texts=[user_input, str(final_response.content)], + metadatas=[ + {"type": "user_query"}, + {"type": "agent_response"}, + ], + ) def _main_loop(app: Runnable, vectorstore: Chroma) -> None: - """Run an interactive chat loop.""" + """Run an interactive chat loop. + + :param app: The compiled agent graph. + :param vectorstore: Chroma vector store for retrieval. + :return: None + """ chat_history: list[BaseMessage] = [] while True: try: @@ -293,7 +364,7 @@ def _main_loop(app: Runnable, vectorstore: Chroma) -> None: log.info("User input: %s", user_input) _handle_retrieval(vectorstore, user_input, chat_history) - chat_history.append(HumanMessage(content = user_input)) + chat_history.append(HumanMessage(content=user_input)) print("\n=== Agent working... ===") final_response = None @@ -304,8 +375,8 @@ def _main_loop(app: Runnable, vectorstore: Chroma) -> None: if final_response: _update_history_and_persist( - vectorstore, user_input, final_response, chat_history - ) + vectorstore, user_input, final_response, chat_history + ) print("-" * 60) diff --git a/code_agent/providers/__init__.py b/code_agent/providers/__init__.py new file mode 100644 index 0000000..4bf29ef --- /dev/null +++ b/code_agent/providers/__init__.py @@ -0,0 +1,10 @@ +"""Provider layer: protocol and base class for LLM providers. + +Exposes the public names of the provider-agnostic LLM contract. +""" + +from __future__ import annotations + +from .base import LLMProvider, ProviderBase + +__all__ = ["LLMProvider", "ProviderBase"] diff --git a/code_agent/providers/base.py b/code_agent/providers/base.py new file mode 100644 index 0000000..ffc8174 --- /dev/null +++ b/code_agent/providers/base.py @@ -0,0 +1,84 @@ +"""Provider-agnostic LLM contract for the OAP-inspired layer. + +Defines ``LLMProvider`` (the structural protocol every concrete provider - +ollama, openai, ... - must satisfy) and ``ProviderBase`` (a convenient ABC +that implements the protocol's ``bind_capabilities`` default). No concrete +model backend is referenced here; providers are selected by config via +``providers/factory.py``. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Protocol, runtime_checkable + +@runtime_checkable +class LLMProvider(Protocol): + """Structural contract every LLM provider must satisfy. + + Attributes: + name: Stable provider identifier, e.g. ``"ollama"`` or ``"openai"``. + """ + + name: str + + def complete(self, messages: list[dict[str, Any]]) -> str: + """Generate a completion for the given chat ``messages``. + + :param messages: Chat history as a list of ``{"role": ..., "content": ...}`` message dicts. + :return: The model's text completion. + """ + ... + + def bind_capabilities(self, caps: list[Any]) -> LLMProvider: + """Return a provider bound to the given capabilities. + + Providers that need tool-binding (e.g. function calling) may return a + new provider instance configured with ``caps``; others return ``self``. + + :param caps: Capabilities available to the provider. + + :return: A provider instance bound to ``caps``. + """ + ... + + +class ProviderBase(ABC): + """Base class for LLM providers (ollama, openai, ...). + + Subclasses must set ``name`` and implement ``complete``. ``bind_capabilities`` + defaults to a no-op that returns ``self``; providers that require tool-binding + override it. + + :ivar name: Stable provider identifier, e.g. ``"ollama"`` or ``"openai"``. + :ivar model: The model name, e.g. ``"llama3"`` or ``"gpt-4o"``. + :ivar temperature: Sampling temperature, ``0.0`` to ``1.0``. + :ivar max_tokens: Maximum number of tokens to generate. + :ivar stream: Whether to stream the response. + :ivar api_key: API key for the provider. + :ivar base_url: Base URL for the provider. + :ivar capabilities: Capabilities available to the provider. + """ + + name: str = "base" + + @abstractmethod + def complete(self, messages: list[dict[str, Any]]) -> str: + """Generate a completion for the given chat ``messages``. + + :param messages: Chat history as a list of ``{"role": ..., "content": ...}`` + message dicts. + :return: The model's text completion. + """ + ... + + def bind_capabilities(self, caps: list[Any]) -> ProviderBase: + """Return a provider bound to the given capabilities. + + Providers that need tool-binding (e.g. function calling) may return a + new provider instance configured with ``caps``; others return ``self``. + + :param caps: Capabilities available to the provider. + :return: A provider instance bound to ``caps``. + """ + return self diff --git a/code_agent/providers/factory.py b/code_agent/providers/factory.py new file mode 100644 index 0000000..3e80dae --- /dev/null +++ b/code_agent/providers/factory.py @@ -0,0 +1,97 @@ +"""Provider factory for the OAP-inspired layer. + +``create_provider(config)`` is the single entry point for constructing an +``LLMProvider`` from a config mapping. It reads the ``provider`` key +(e.g. ``"ollama"`` | ``"openai"``) and dispatches to the registered provider +factory, so business logic never references a concrete model backend. + +Extending: register a new provider by adding one entry to +``_PROVIDER_FACTORIES`` mapping its name to either a ``from_config`` +classmethod or a small builder function ``(config: dict) -> LLMProvider``. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from code_agent.providers.base import LLMProvider +from code_agent.providers.ollama import OllamaProvider +from code_agent.providers.openai import OpenAIProvider + +#: Provider used when ``config`` has no ``provider`` key (backward compat). +DEFAULT_PROVIDER: str = "ollama" + +#: Callable that builds an LLM provider from a config mapping. +ProviderFactory = Callable[[dict[str, Any]], LLMProvider] + + +def _openai_from_config(config: dict[str, Any]) -> OpenAIProvider: + """Build an ``OpenAIProvider`` from a config mapping. + + ``OpenAIProvider`` has no ``from_config`` classmethod, so the factory + forwards the config keys it understands: ``model``, ``api_key``, + ``base_url`` and the ``ChatOpenAI`` options ``temperature``, + ``max_tokens`` and ``stream``. Keys absent from ``config`` are omitted so + constructor defaults (e.g. the ``OPENAI_API_KEY`` env fallback) apply. + + :param config: Configuration mapping. + :return: A configured ``OpenAIProvider`` instance. + + Prefer the OpenAI-specific keys sourced from settings, falling back to the bare keys for backward compatibility with older configs and tests. + """ + kwargs: dict[str, Any] = {} + model = config.get("openai_model") or config.get("model") + if model is not None: + kwargs["model"] = model + api_key = config.get("openai_api_key") or config.get("api_key") + if api_key is not None: + kwargs["api_key"] = api_key + base_url = config.get("openai_base_url") or config.get("base_url") + if base_url is not None: + kwargs["base_url"] = base_url + for key in ("temperature", "max_tokens", "stream"): + if key in config: + kwargs[key] = config[key] + return OpenAIProvider(**kwargs) + + +#: Registered provider name -> factory callable. +_PROVIDER_FACTORIES: dict[ + str, + Callable[[dict[str, Any] | None], OllamaProvider] + | Callable[[dict[str, Any]], OpenAIProvider], +] = { + "ollama": OllamaProvider.from_config, + "openai": _openai_from_config, +} + + +def create_provider(config: dict[str, Any]) -> OllamaProvider | OpenAIProvider: + """Create an ``LLMProvider`` selected by the config mapping. + + The ``provider`` key of ``config`` names the backend (e.g. ``"ollama"`` + or ``"openai"``). When the key is missing (or ``None``) it defaults to + ``DEFAULT_PROVIDER`` so existing configs keep working. The remaining keys + are passed to the selected provider's factory (``from_config`` classmethod + or builder function). + + :param config: Configuration mapping containing at least a ``provider`` key plus the options for that provider. + :return: A configured ``LLMProvider`` instance. + :raises ValueError: If the ``provider`` key names an unknown or unsupported provider. + """ + provider_value = config.get("provider", DEFAULT_PROVIDER) + if provider_value is None: + provider_value = DEFAULT_PROVIDER + provider_name = str(provider_value).strip().lower() + + factory = _PROVIDER_FACTORIES.get(provider_name) + if factory is None: + supported = ", ".join(sorted(_PROVIDER_FACTORIES)) + raise ValueError( + f"Unknown LLM provider '{provider_name}'. " + f"Supported providers: {supported}. " + "Set the 'provider' key in your config to one of these values." + ) + + return factory(config) diff --git a/code_agent/providers/ollama.py b/code_agent/providers/ollama.py new file mode 100644 index 0000000..9a06601 --- /dev/null +++ b/code_agent/providers/ollama.py @@ -0,0 +1,131 @@ +"""Ollama LLM provider for the OAP-inspired layer. + +Wraps ``langchain_ollama.ChatOllama`` behind the provider-agnostic +``LLMProvider`` contract so business logic never references a concrete +model backend directly. Connection details (scheme/host/port/model/ +temperature) are read from config, never hardcoded. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from code_agent.providers.base import ProviderBase +from langchain_ollama import ChatOllama + +class OllamaProvider(ProviderBase): + """Ollama-backed LLM provider. + + Attributes: + name: Stable provider identifier, ``"ollama"``. + model: Ollama model name, e.g. ``"gpt-oss:20b-cloud"``. + base_url: Ollama server base URL, e.g. ``"http://localhost:11434"``. + """ + + name: str = "ollama" + + def __init__( + self, + model: str, + base_url: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize the Ollama provider. + + Args: + model: Ollama model name to use for completions. + base_url: Ollama server base URL. When ``None`` it is derived + from the ``scheme``/``host``/``port`` keyword arguments + (defaults ``http``/``localhost``/``11434``). + **kwargs: Extra options forwarded to ``ChatOllama``, e.g. + ``temperature``, ``max_tokens``, ``stream``. + """ + scheme = kwargs.pop("scheme", "http") + host = kwargs.pop("host", "localhost") + port = kwargs.pop("port", 11434) + if base_url is None: + base_url = f"{scheme}://{host}:{port}" + + self.model = model + self.base_url = base_url + self._client = ChatOllama(model=model, base_url=base_url, **kwargs) + + def complete(self, messages: list[dict[str, Any]]) -> str: + """Generate a completion for the given chat ``messages``. + + :param messages: Chat history as a list of ``{"role": ..., "content": ...}`` message dicts. + :return The model's text completion. + :raises RuntimeError: If the Ollama backend fails to produce a response. + """ + try: + response = self._client.invoke(messages) + except Exception as exc: + raise RuntimeError(f"Ollama completion failed: {exc}") from exc + return str(response.content) + + def bind_capabilities(self, caps: list[Any]) -> OllamaProvider: + """Bind capabilities to the Ollama provider. + + :param caps: List of capabilities to bind. + :return: The provider with bound capabilities. + """ + # Ollama tool-binding is handled by the LangGraph layer; no-op here. + return self + + @classmethod + def from_config( + cls, config: dict[str, Any] | None = None + ) -> OllamaProvider: + """Build an ``OllamaProvider`` from a config mapping. + + Reads ``model``, ``temperature``, ``max_tokens``, ``stream`` and the + ``ollama_*`` connection keys from ``config`` (or the default config + file when ``config`` is ``None``). + + :param config: Configuration mapping. When ``None`` the default ``code_agent/config/llm_config.json`` is loaded. + :return: A configured ``OllamaProvider`` instance. + """ + if config is None: + config = cls._load_default_config() + + # Prefer the explicit ``ollama_model`` key; fall back to the bare + # ``model`` key for backward compatibility with older configs/tests. + model = config.get("ollama_model") or config.get( + "model", "gpt-oss:20b-cloud" + ) + scheme = config.get("ollama_scheme", "http") + host = config.get("ollama_host", "localhost") + port = config.get("ollama_port", 11434) + base_url = f"{scheme}://{host}:{port}" + + kwargs: dict[str, Any] = {} + for key in ("temperature", "max_tokens", "stream"): + if key in config: + kwargs[key] = config[key] + + return cls(model=model, base_url=base_url, **kwargs) + + @staticmethod + def _load_default_config() -> dict[str, Any]: + """Load the default configuration. + + The typed application settings (sourced from ``.env`` / the environment) + are the canonical default. A legacy ``llm_config.json`` is used only as + a fallback when the settings module is unavailable. + + :return: The default configuration mapping. + """ + try: + from code_agent.settings import get_settings + + return get_settings().model_dump() + except Exception: + path = ( + Path(__file__).resolve().parent.parent + / "config" + / "llm_config.json" + ) + with path.open("r", encoding="utf-8") as f: + return json.load(f) diff --git a/code_agent/providers/openai.py b/code_agent/providers/openai.py new file mode 100644 index 0000000..2eeb8a6 --- /dev/null +++ b/code_agent/providers/openai.py @@ -0,0 +1,91 @@ +"""OpenAI LLM provider for the OAP-inspired layer. + +Wraps ``langchain_openai.ChatOpenAI`` behind the provider-agnostic +``LLMProvider`` contract so business logic never references a concrete +model backend directly. The model name, API key and base URL are read +from constructor arguments (the API key falls back to the +``OPENAI_API_KEY`` environment variable), never hardcoded. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import os +from typing import Any + +from code_agent.providers.base import ProviderBase +from langchain_openai import ChatOpenAI +from pydantic import SecretStr + +@dataclass +class OpenAIProvider(ProviderBase): + """OpenAI-backed LLM provider. + + Attributes: + name: Stable provider identifier, ``"openai"``. + model: OpenAI model name, e.g. ``"gpt-4o"``. + api_key: OpenAI API key, or ``None`` when read from ``OPENAI_API_KEY``. + base_url: Optional base URL for the API, e.g. for proxies or + emulators such as ``"https://api.openai.com/v1"``. + """ + + name: str = "openai" + + def __init__( + self, + model: str, + api_key: SecretStr | Callable[[], str] | None = None, + base_url: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize the OpenAI provider. + + :param model: OpenAI model name to use for completions. + :param api_key: OpenAI API key. When ``None`` it is read from the ``OPENAI_API_KEY`` environment variable. + :param base_url: Optional base URL for the API, e.g. when using a proxy or service emulator. + :param **kwargs: Extra options forwarded to ``ChatOpenAI``, e.g. ``temperature``, ``max_tokens``, ``stream``. + :return: The initialized provider. + """ + if api_key is None: + api_key = SecretStr(os.environ["OPENAI_API_KEY"]) + elif isinstance(api_key, str): + api_key = SecretStr(api_key) + + self.model = model + self.api_key = api_key + self.base_url = base_url + # ChatOpenAI expects a plain string (or lazy callable), not a SecretStr. + client_api_key = ( + api_key.get_secret_value() + if isinstance(api_key, SecretStr) + else api_key + ) + self._client = ChatOpenAI( + model=model, api_key=client_api_key, base_url=base_url, **kwargs + ) + + def complete(self, messages: list[dict[str, Any]]) -> str: + """Generate a completion for the given chat ``messages``. + + :param messages: Chat history as a list of ``{"role": ..., "content": ...}`` message dicts. + :return: The model's text completion. + :raises RuntimeError: If the OpenAI backend fails to produce a response. + """ + try: + response = self._client.invoke(messages) + except Exception as exc: + raise RuntimeError(f"OpenAI completion failed: {exc}") from exc + content = response.content + if isinstance(content, list): + content = "\n".join(str(part) for part in content) + return str(content) + + def bind_capabilities(self, caps: list[Any]) -> OpenAIProvider: + """Bind capabilities to the OpenAI provider. + + :param caps: List of capabilities to bind. + :return: The provider with bound capabilities. + """ + # OpenAI tool-binding is handled by the LangGraph layer; no-op here. + return self diff --git a/code_agent/run_agent.py b/code_agent/run_agent.py index 3a38615..15f0b67 100644 --- a/code_agent/run_agent.py +++ b/code_agent/run_agent.py @@ -5,13 +5,19 @@ from __future__ import annotations +from pathlib import Path + from code_agent.scaffold import create_project_scaffold +def scaffold(root: Path, name: str = "project", overwrite: bool = False) -> str: + """Create a scaffold for a new project. -def scaffold(root = ".", name = "project", overwrite = False): - return create_project_scaffold( - root, project_name = name, overwrite = overwrite - ) + :param root: The root directory to create the scaffold in. + :param name: The name of the project. + :param overwrite: Whether to overwrite existing files. + :return: The path to the created scaffold. + """ + return create_project_scaffold(root, project_name=name, overwrite=overwrite) if __name__ == "__main__": @@ -19,13 +25,15 @@ def scaffold(root = ".", name = "project", overwrite = False): p = argparse.ArgumentParser() p.add_argument( - "--scaffold", nargs = "?", const = ".", help = "Create scaffold at path", ) - p.add_argument("--name", default = "project") - p.add_argument("--overwrite", action = "store_true") + "--scaffold", + nargs="?", + const=".", + help="Create scaffold at path", + ) + p.add_argument("--name", default="project") + p.add_argument("--overwrite", action="store_true") args = p.parse_args() if args.scaffold: - print( - scaffold(args.scaffold, name = args.name, overwrite = args.overwrite) - ) + print(scaffold(args.scaffold, name=args.name, overwrite=args.overwrite)) else: print("No-op. Use --scaffold") diff --git a/code_agent/scaffold.py b/code_agent/scaffold.py index 95df0db..c6b4c94 100644 --- a/code_agent/scaffold.py +++ b/code_agent/scaffold.py @@ -1,5 +1,4 @@ -""" -Simple project scaffold generator for starting new projects from this template. +"""Simple project scaffold generator for starting new projects from this template. Creates a minimal layout: `docs/`, `src//`, `tests/`, `.GitHub/workflows/`, `requirements.txt`, and sample files. Intentionally conservative and idempotent. """ @@ -44,27 +43,45 @@ def _create_directories(root_path: Path, project_name: str) -> None: - """Create the directory structure for the project.""" - dirs = [root_path / "docs" / "styles", root_path / "src" / project_name, root_path / "tests", - root_path / ".github" / "workflows", ] + """Create the directory structure for the project. + + :param root_path: The root directory to create the scaffold in. + :param project_name: The name of the project. + :return: None + """ + dirs = [ + root_path / "docs" / "styles", + root_path / "src" / project_name, + root_path / "tests", + root_path / ".github" / "workflows", + ] for d in dirs: try: - d.mkdir(parents = True, exist_ok = True) + d.mkdir(parents=True, exist_ok=True) except OSError as exc: raise CodeAgentError( - f"Failed to create directory {d}: {exc}" - ) from exc + f"Failed to create directory {d}: {exc}" + ) from exc def _create_files(root_path: Path, project_name: str, overwrite: bool) -> None: - """Create the files for the project.""" - files_to_create = {"README.qmd": f"# {project_name}\n\nGenerated scaffold.", - "requirements.txt": DEFAULT_REQUIREMENTS, - "docs/index.qmd": f"---\ntitle: {project_name}\nformat: html\n---\n\n# " - f"{project_name}\n\nGenerated docs " - f"index.", "tests/test_smoke.py": "def test_smoke():\n assert True\n", - f"src/{project_name}/__init__.py": "# sample package init\n", - ".github/workflows/ci.yml": WORKFLOW_CONTENT, } + """Create the files for the project. + + :param root_path: The root directory to create the scaffold in. + :param project_name: The name of the project. + :param overwrite: Whether to overwrite existing files. + :return: None + """ + files_to_create = { + "README.qmd": f"# {project_name}\n\nGenerated scaffold.", + "requirements.txt": DEFAULT_REQUIREMENTS, + "docs/index.qmd": f"---\ntitle: {project_name}\nformat: html\n---\n\n# " + f"{project_name}\n\nGenerated docs " + f"index.", + "tests/test_smoke.py": "def test_smoke():\n assert True\n", + f"src/{project_name}/__init__.py": "# sample package init\n", + ".github/workflows/ci.yml": WORKFLOW_CONTENT, + } for file, content in files_to_create.items(): path = root_path / file @@ -76,22 +93,29 @@ def _create_files(root_path: Path, project_name: str, overwrite: bool) -> None: def create_project_scaffold( - root: str, project_name: str = "project", overwrite: bool = False, ) -> str: - """ - Create a minimal project scaffold in the given directory. + root: str | Path, + project_name: str = "project", + overwrite: bool = False, +) -> str: + """Create a minimal project scaffold in the given directory. + + :param root: The root directory to create the scaffold in. + :param project_name: The name of the project. + :param overwrite: Whether to overwrite existing files. + :return: The path to the created scaffold. """ root_path = Path(root).expanduser().resolve() try: - root_path.mkdir(parents = True, exist_ok = True) + root_path.mkdir(parents=True, exist_ok=True) except OSError as exc: raise CodeAgentError( - f"Failed to create root directory {root_path}: {exc}" - ) from exc + f"Failed to create root directory {root_path}: {exc}" + ) from exc if not overwrite and any( - (root_path / p).exists() for p in ["README.qmd", "src", "tests"] - ): + (root_path / p).exists() for p in ["README.qmd", "src", "tests"] + ): raise FileExistsError(f"Project already exists at {root_path}") _create_directories(root_path, project_name) @@ -105,14 +129,14 @@ def create_project_scaffold( import argparse p = argparse.ArgumentParser() - p.add_argument("root", nargs = "?", default = ".") - p.add_argument("--name", default = "project") - p.add_argument("--overwrite", action = "store_true") + p.add_argument("root", nargs="?", default=".") + p.add_argument("--name", default="project") + p.add_argument("--overwrite", action="store_true") args = p.parse_args() try: create_project_scaffold( - args.root, project_name = args.name, overwrite = args.overwrite - ) + args.root, project_name=args.name, overwrite=args.overwrite + ) print("Scaffold created at", Path(args.root).resolve()) except (CodeAgentError, FileExistsError) as e: print(f"❌ Error creating scaffold: {e}") diff --git a/code_agent/settings.py b/code_agent/settings.py new file mode 100644 index 0000000..fc5db2c --- /dev/null +++ b/code_agent/settings.py @@ -0,0 +1,145 @@ +"""Typed application configuration loaded from environment / ``.env``. + +All runtime configuration for the agent is centralised in a single +:class:`Settings` model built on ``pydantic_settings.BaseSettings``. Values are +sourced, in increasing precedence, from: + +1. the process environment variables, and +2. a ``.env`` file at the project root (``OLLAMA_PORT``, ``OPENAI_API_KEY`` ...). + +Sensible defaults live on the model so the application runs with zero +configuration, while every value can be overridden per environment without +touching code. Secrets (e.g. ``OPENAI_API_KEY``) are never hardcoded - they are +read from the environment / ``.env`` only, satisfying the project's security +requirement that provider credentials come from config, not source. + +Field names map to upper-case environment variables by default +(``ollama_port`` -> ``OLLAMA_PORT``). The provider layer reads the same values +from the ``model_dump()`` dict using the ``ollama_*`` / ``openai_*`` key +convention, so this module is the single source of truth. +""" + +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path +from typing import Any + +from pydantic import model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +#: Project root (parent of the ``code_agent`` package), where ``.env`` lives. +PROJECT_ROOT = Path(__file__).resolve().parent.parent + +#: Default system prompt used when none is supplied via config / ``.env``. +DEFAULT_SYSTEM_PROMPT = """You are an elite software architect and data science expert with deep knowledge of Python, R, and modern development practices. + +CAPABILITIES: +- Design and implement complete, production-ready systems +- Perform sophisticated code analysis and refactoring +- Create comprehensive documentation and explanations +- Solve complex algorithmic and architectural challenges +- Optimize performance and maintainability +- Apply advanced design patterns and best practices + +APPROACH: +- Provide thorough, well-reasoned solutions +- Consider edge cases and potential issues +- Write clean, maintainable, well-documented code +- Explain complex concepts clearly and completely +- Suggest improvements and alternatives +- Think holistically about system design + +STANDARDS: +- Production-quality code with proper error handling +- Comprehensive docstrings and comments +- Type hints and validation where appropriate +- Following language-specific conventions (PEP 8, tidyverse style) +- Security and performance considerations +- Scalability and maintainability focus + +When using tools, use them strategically to gather information before providing complete solutions. +Never give minimal or toy examples - always provide professional, complete implementations.""" + + +class Settings(BaseSettings): + """Application settings sourced from the environment and ``.env``. + + Every field has a safe default so the agent runs out of the box; override + any value with an environment variable or a ``.env`` entry at the project + root. Unknown environment variables are ignored (``extra="ignore"``). + """ + + model_config = SettingsConfigDict( + env_prefix="CODE_AGENT_", + env_file=PROJECT_ROOT / ".env", + env_file_encoding="utf-8", + extra="ignore", + case_sensitive=False, + ) + + # --- Provider selection ------------------------------------------------- + provider: str = "ollama" + + # --- Ollama connection -------------------------------------------------- + ollama_scheme: str = "http" + ollama_host: str = "localhost" + ollama_port: int = 11434 + ollama_model: str = "gpt-oss:20b" + + # --- OpenAI (optional; falls back to the OPENAI_API_KEY env var) -------- + openai_api_key: str | None = None + openai_model: str = "gpt-4o" + openai_base_url: str | None = None + + # --- Sampling / generation --------------------------------------------- + temperature: float = 0.7 + max_tokens: int = 6000 + stream: bool = True + + # --- Application behavior --------------------------------------------- + auth_token: str | None = None + root_dir: str = "." + system_prompt: str = DEFAULT_SYSTEM_PROMPT + verbose: bool = True + log_level: str = "INFO" + max_iterations: int = 50 + max_execution_time: int = 5000 + + @model_validator(mode="after") + def _split_combined_ollama_host(self) -> Settings: + """Accept Ollama's combined ``host:port`` form in ``ollama_host``. + + Ollama's own ``OLLAMA_HOST`` uses ``host:port``. If ``ollama_host`` + carries a trailing port, split it off so the connection URL is built + correctly instead of duplicating the port. + + :return: The validated settings. + """ + host = self.ollama_host + if host and ":" in host: + head, _, port = host.rpartition(":") + if port.isdigit(): + self.ollama_host = head + self.ollama_port = int(port) + return self + + +@lru_cache +def get_settings() -> Settings: + """Return a cached :class:`Settings` instance. + + The result is memoised so the ``.env`` file and environment are read only + once per process. + + :return: The validated settings. + """ + return Settings() + + +def as_config_dict() -> dict[str, Any]: + """Return the settings as a plain dict for the provider/config layer. + + :return: The settings as a plain dict. + """ + return get_settings().model_dump() diff --git a/code_agent/tools/__init__.py b/code_agent/tools/__init__.py index 72c79ac..cd82a9d 100644 --- a/code_agent/tools/__init__.py +++ b/code_agent/tools/__init__.py @@ -1,4 +1,5 @@ -# tools/__init__.py +"""Tools for the code_agent package.""" + from __future__ import annotations from .edit_file_tool import EditFileTool @@ -13,6 +14,16 @@ from .read_file_tool import ReadFileTool from .search_explain_tool import SearchExplainTool -__all__ = ["EditFileTool", "SearchExplainTool", "LinkerTool", "NewFileTool", "GenerateTestTool", "FormatCodeTool", - "NotebookTool", "NaturalLanguageTool", "ReadFileTool", "GeneralChatTool", "RScriptTool", # Added RScriptTool - ] +__all__ = [ + "EditFileTool", + "FormatCodeTool", + "GeneralChatTool", + "GenerateTestTool", + "LinkerTool", + "NaturalLanguageTool", + "NewFileTool", + "NotebookTool", + "RScriptTool", + "ReadFileTool", + "SearchExplainTool", +] diff --git a/code_agent/tools/edit_file_tool.py b/code_agent/tools/edit_file_tool.py index 450d296..ddc2de9 100644 --- a/code_agent/tools/edit_file_tool.py +++ b/code_agent/tools/edit_file_tool.py @@ -1,53 +1,83 @@ -# tools/edit_file_tool.py +"""Tool for editing existing files.""" + from __future__ import annotations +from dataclasses import dataclass import logging -import shutil from pathlib import Path +import shutil from typing import Literal from langchain.tools import BaseTool -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, Field log = logging.getLogger(__name__) -class FileObject(BaseModel): - """Artifact representing a file.""" +@dataclass +class FileObject: + """Artifact representing a file. + + :param path: Path to the file. + :param contents: Contents of the file. + :param status: Status of the file. + """ path: Path contents: str status: str = "success" - model_config = ConfigDict(arbitrary_types_allowed = True) - class EditFileArgs(BaseModel): - """Arguments for editing a file.""" + """Arguments for editing a file. + + :param file_path: Path to the file to edit. + :param new_content: New content for the file. + :param mode: Mode: replace|append|patch. + """ - file_path: str = Field(..., description = "Path to the file to edit") - new_content: str = Field(..., description = "New content for the file") - mode: str = Field("replace", description = "Mode: replace|append|patch") + file_path: str = Field(..., description="Path to the file to edit") + new_content: str = Field(..., description="New content for the file") + mode: str = Field("replace", description="Mode: replace|append|patch") class EditFileTool(BaseTool): - """Tool for editing existing files.""" + """Tool for editing existing files. + + :param root_dir: Root directory for file operations. + :param args_schema: Pydantic model class for validating and parsing tool input. + :param return_direct: Whether to return the tool's output directly. + :param verbose: Whether to log tool activity. + :param handle_tool_error: Whether to handle errors raised by the tool. + :param kwargs: Additional keyword arguments. + """ name: str = "edit-file" - description: str = ("Edit an existing file by replacing/appending/patching its content. " - "Returns confirmation message and FileObject artifact.") - response_format: Literal["content_and_artifact"] = "content_and_artifact" - args_schema: type[BaseModel] = EditFileArgs + description: str = ( + "Edit an existing file by replacing/appending/patching its content. " + "Returns confirmation message and FileObject artifact." + ) + response_format: Literal["content", "content_and_artifact"] = ( + "content_and_artifact" + ) + args_schema: type[BaseModel] = EditFileArgs # pyrefly: ignore[bad-override-mutable-attribute] root: Path - model_config = ConfigDict(arbitrary_types_allowed = True) + def __init__(self, root_dir: Path, **kwargs) -> None: + """Initialize the tool with a root directory. - def __init__(self, root_dir: str | Path, **kwargs): - super().__init__(root = Path(root_dir).expanduser().resolve(), **kwargs) + :param root_dir: Root directory for file operations. + :param kwargs: Additional keyword arguments. + """ + super().__init__(root=Path(root_dir).expanduser().resolve(), **kwargs) def _backup_file(self, path: Path) -> str: - """Create a backup of the file.""" + """Create a backup of the file. + + :param path: Path to the file to backup. + :return: Status of the backup operation. + """ if not path.exists(): return "no_backup" backup_path = path.with_suffix(path.suffix + ".bak") @@ -56,64 +86,106 @@ def _backup_file(self, path: Path) -> str: log.info(f"Backup created: {backup_path}") return "backup_created" except Exception as e: - log.error( - f"Failed to create backup for {path}: {e}", exc_info = True - ) + log.exception(f"Failed to create backup for {path}: {e}") return "backup_failed" def _edit_replace(self, path: Path, content: str) -> None: - path.write_text(content, encoding = "utf-8") + """Edit a file by replacing its content. + + :param path: Path to the file to edit. + :param content: New content for the file. + """ + log.info(f"Replacing content of {path}") + path.write_text(content, encoding="utf-8") def _edit_append(self, path: Path, content: str) -> None: - original_content = path.read_text(encoding = "utf-8") - path.write_text(original_content + content, encoding = "utf-8") + """Edit a file by appending content to it. + + :param path: Path to the file to edit. + :param content: Content to append to the file. + """ + log.info(f"Appending content to {path}") + original_content = path.read_text(encoding="utf-8") + path.write_text(original_content + content, encoding="utf-8") def _edit_patch(self, path: Path, content: str) -> None: - original_content = path.read_text(encoding = "utf-8") + """Edit a file by patching content between markers. + + :param path: Path to the file to edit. + :param content: Content to insert between markers. + """ + log.info(f"Patching content of {path}") + original_content = path.read_text(encoding="utf-8") start_marker = "" end_marker = "" if start_marker in original_content and end_marker in original_content: pre, rest = original_content.split(start_marker, 1) _, post = rest.split(end_marker, 1) - new_full = (pre + start_marker + "\n" + content + "\n" + end_marker + post) + new_full = ( + pre + start_marker + "\n" + content + "\n" + end_marker + post + ) else: new_full = original_content + "\n" + content - path.write_text(new_full, encoding = "utf-8") + path.write_text(new_full, encoding="utf-8") def _run( - self, file_path: str, new_content: str, mode: str = "replace" - ) -> tuple[str, FileObject]: + self, file_path: str, new_content: str, mode: str = "replace" + ) -> tuple[str, FileObject]: + """Edit a file with the specified content using the specified mode. + + :param file_path: Path to the file to edit. + :param new_content: New content for the file. + :param mode: Mode of editing (replace, append, or patch). + :return: Tuple of status message and FileObject. + """ full_path = self.root / file_path if not full_path.exists(): - return (f"❌ File not found: {full_path}", FileObject(path = full_path, contents = "", status = "error"),) + return ( + f"❌ File not found: {full_path}", + FileObject(path=full_path, contents="", status="error"), + ) try: backup_status = self._backup_file(full_path) - edit_functions = {"replace": self._edit_replace, "append": self._edit_append, "patch": self._edit_patch, } + edit_functions = { + "replace": self._edit_replace, + "append": self._edit_append, + "patch": self._edit_patch, + } if mode not in edit_functions: - return (f"❌ Unknown mode: {mode}", FileObject(path = full_path, contents = "", status = "error"),) + return ( + f"❌ Unknown mode: {mode}", + FileObject(path=full_path, contents="", status="error"), + ) edit_functions[mode](full_path, new_content) status = f"edited_{mode}" - final_contents = full_path.read_text(encoding = "utf-8") + final_contents = full_path.read_text(encoding="utf-8") message = f"✅ Successfully {status} {full_path}" if backup_status == "backup_failed": message += " (⚠️ Backup failed!)" - return (message, FileObject( - path = full_path, contents = final_contents, status = status - ),) + return ( + message, + FileObject( + path=full_path, contents=final_contents, status=status + ), + ) except Exception as e: - log.error( - f"Error during file edit operation for {full_path}: {e}", exc_info = True, ) - return (f"❌ Error editing file: {e}", FileObject(path = full_path, contents = "", status = "error"),) + log.exception( + f"Error during file edit operation for {full_path}: {e}" + ) + return ( + f"❌ Error editing file: {e}", + FileObject(path=full_path, contents="", status="error"), + ) async def _arun( - self, file_path: str, new_content: str, mode: str = "replace" - ) -> tuple[str, FileObject]: + self, file_path: str, new_content: str, mode: str = "replace" + ) -> tuple[str, FileObject]: """Async version.""" return self._run(file_path, new_content, mode) diff --git a/code_agent/tools/format_code_tool.py b/code_agent/tools/format_code_tool.py index c1966b9..4b1f6db 100644 --- a/code_agent/tools/format_code_tool.py +++ b/code_agent/tools/format_code_tool.py @@ -1,58 +1,74 @@ -# tools/format_code_tool.py +"""Format code tool.""" + from __future__ import annotations +from pathlib import Path import shutil import subprocess -from pathlib import Path from typing import Any, Literal -from langchain.tools import BaseTool -from pydantic import BaseModel, ConfigDict, Field - from .edit_file_tool import FileObject +from langchain.tools import BaseTool +from pydantic import BaseModel, ConfigDict, Field class FormatCodeArgs(BaseModel): - file_path: str = Field(..., description = "Path to the file to format") - mode: str = Field("auto", description = "Mode: auto|python|r") + """Args for the format-code tool.""" + + file_path: str = Field(..., description="Path to the file to format") + mode: str = Field("auto", description="Mode: auto|python|r") class FormatCodeTool(BaseTool): + """Format a source file.""" + name: str = "format-code" - description: str = ("Format a source file. For python files, run black and isort if available. " - "For R files, optionally run styler if available. Returns a FileObject.") - response_format: Literal["content_and_artifact"] = "content_and_artifact" - args_schema: type[BaseModel] = FormatCodeArgs + description: str = ( + "Format a source file. For python files, run black and isort if available. " + "For R files, optionally run styler if available. Returns a FileObject." + ) + response_format: Literal["content", "content_and_artifact"] = ( + "content_and_artifact" + ) + args_schema: type[BaseModel] = FormatCodeArgs # pyrefly: ignore[bad-override-mutable-attribute] root: Path - model_config = ConfigDict(arbitrary_types_allowed = True) + model_config = ConfigDict(arbitrary_types_allowed=True) - def __init__(self, root_dir: str | Path, **kwargs): - super().__init__(root = Path(root_dir).expanduser().resolve(), **kwargs) + def __init__(self, root_dir: Path, **kwargs) -> None: + """Initialize the tool. - def _run(self, **kwargs: Any) -> tuple[str, FileObject]: + :param root_dir: The root directory of the project. + :param kwargs: Additional arguments. + :return: None """ + super().__init__(root=Path(root_dir).expanduser().resolve(), **kwargs) + + def _run(self, **kwargs: Any) -> tuple[str, FileObject]: + """Format a source file. - Parameters - ---------- - kwargs : file path - mode : auto|python|r - Returns - ------- - tuple[str, FileObject] + If the file is a Python file, run black and isort if available. + If the file is an R file, optionally run styler if available. + + :param kwargs : file path + :param mode : auto|python|r + :return: """ - file_path: str = kwargs.get("file_path") + file_path: str = kwargs.get("file_path", "") mode: str = kwargs.get("mode", "auto") p = self.root / file_path if not p.exists(): - return (f"❌ File not found: {p}", FileObject(path = p, contents = "", status = "error"),) + return ( + f"❌ File not found: {p}", + FileObject(path=p, contents="", status="error"), + ) ext = p.suffix.lower() if mode == "auto": if ext == ".py": mode = "python" - elif ext in (".r", ".R"): + elif ext in {".r", ".R"}: mode = "r" # Delegate formatting to helper methods to reduce complexity @@ -61,36 +77,56 @@ def _run(self, **kwargs: Any) -> tuple[str, FileObject]: elif mode == "r": ok, msg = self._format_r(p) else: - return (f"❌ Unknown mode: {mode}", FileObject(path = p, contents = "", status = "error"),) + return ( + f"❌ Unknown mode: {mode}", + FileObject(path=p, contents="", status="error"), + ) if not ok: - return (f"❌ Formatting failed: {msg}", FileObject(path = p, contents = "", status = "error"),) + return ( + f"❌ Formatting failed: {msg}", + FileObject(path=p, contents="", status="error"), + ) - new_contents = p.read_text(encoding = "utf-8") - return (f"✅ Formatted {p}", FileObject(path = p, contents = new_contents, status = "formatted"),) + new_contents = p.read_text(encoding="utf-8") + return ( + f"✅ Formatted {p}", + FileObject(path=p, contents=new_contents, status="formatted"), + ) def _format_python(self, p: Path) -> tuple[bool, str]: """Run python formatters (isort, black) if available. - Returns (success, message).""" + :param p: Path to the file to format. + :return: (success, message). + """ try: if shutil.which("isort"): - subprocess.run(["isort", str(p)], check = False) + subprocess.run(["isort", str(p)], check=False) if shutil.which("black"): - subprocess.run(["black", str(p)], check = False) + subprocess.run(["black", str(p)], check=False) return True, "" except Exception as e: return False, str(e) def _format_r(self, p: Path) -> tuple[bool, str]: - """Run R styler via Rscript if available.""" + """Run R styler via Rscript if available. + + :param p: Path to the file to format. + :return: (success, message). + """ try: if shutil.which("Rscript"): - rcmd = f"styler::style_file('{str(p)}')" - subprocess.run(["Rscript", "-e", rcmd], check = False) + rcmd = f"styler::style_file('{p!s}')" + subprocess.run(["Rscript", "-e", rcmd], check=False) return True, "" except Exception as e: return False, str(e) async def _arun(self, **kwargs: Any) -> tuple[str, FileObject]: + """Async wrapper for _run. + + :param kwargs: Keyword arguments for _run. + :return: Tuple of (message, FileObject). + """ return self._run(**kwargs) diff --git a/code_agent/tools/general_chat_tool.py b/code_agent/tools/general_chat_tool.py index 73e07bc..a2f219f 100644 --- a/code_agent/tools/general_chat_tool.py +++ b/code_agent/tools/general_chat_tool.py @@ -1,4 +1,5 @@ -# tools/general_chat_tool.py +"""General chat tool.""" + from __future__ import annotations from typing import Any @@ -8,30 +9,42 @@ from langchain_core.messages import HumanMessage from pydantic import BaseModel, Field - class GeneralChatArgs(BaseModel): """Arguments for a general chat query.""" query: str = Field( - ..., description = "The user's question or message for a general chat response.", ) + ..., + description="The user's question or message for a general chat response.", + ) class GeneralChatTool(BaseTool): """A tool for general conversation and questions.""" name: str = "general-chat" - description: str = ("Use this tool as a last resort if no other tool is appropriate for the user's query. " - "It is for general conversation, questions, and answering 'how-to' style inquiries.") - args_schema: type[BaseModel] = GeneralChatArgs + description: str = ( + "Use this tool as a last resort if no other tool is appropriate for the user's query. " + "It is for general conversation, questions, and answering 'how-to' style inquiries." + ) + args_schema: type[BaseModel] = GeneralChatArgs # pyrefly: ignore[bad-override-mutable-attribute] llm: BaseChatModel - def __init__(self, llm_instance: BaseChatModel, **kwargs): - super().__init__(llm = llm_instance, **kwargs) + def __init__(self, llm_instance: BaseChatModel, **kwargs) -> None: + """Initialize the tool with the LLM instance. + + :param llm_instance: The LLM instance to use for generating responses. + :param kwargs: Additional keyword arguments. + :return: None + """ + super().__init__(llm=llm_instance, **kwargs) def _run(self, query: str) -> str: - """Sends the query directly to the LLM for a conversational response.""" + """Send the query directly to the LLM for a conversational response. + :param query: The user's query or message. + :return: The LLM's response. + """ prompt = f"""You are a helpful and knowledgeable AI assistant. A user has asked a question that does not fit any of the specialized tools. Provide a direct, helpful, and conversational answer to their query. @@ -40,7 +53,7 @@ def _run(self, query: str) -> str: Your response:""" try: - response = self.llm.invoke([HumanMessage(content = prompt)]) + response = self.llm.invoke([HumanMessage(content=prompt)]) if hasattr(response, "content"): return str(response.content) return str(response) @@ -48,7 +61,11 @@ def _run(self, query: str) -> str: return f"❌ Error during general chat: {e}" async def _arun(self, **kwargs: Any) -> str: - """Async version.""" + """Async version. + + :param kwargs: Keyword arguments. + :return: The LLM's response. + """ # Simplified for now query = kwargs.get("query", "") return self._run(query) diff --git a/code_agent/tools/generate_test_tool.py b/code_agent/tools/generate_test_tool.py index fcef1c3..6af6d5f 100644 --- a/code_agent/tools/generate_test_tool.py +++ b/code_agent/tools/generate_test_tool.py @@ -1,72 +1,106 @@ -# tools/generate_test_tool.py +"""Tool to generate tests.""" + from __future__ import annotations from pathlib import Path from typing import Literal -from langchain.tools import BaseTool -from pydantic import BaseModel, ConfigDict, Field - from .edit_file_tool import FileObject +from langchain.tools import BaseTool +from pydantic import BaseModel, ConfigDict, Field class GenerateTestArgs(BaseModel): + """Arguments for the generate-test tool.""" + file_path: str = Field( - ..., description = "Path to the module/file to generate tests for" - ) + ..., description="Path to the module/file to generate tests for" + ) tests_dir: str = Field( - "tests", description = "Directory to place generated tests" - ) + "tests", description="Directory to place generated tests" + ) class GenerateTestTool(BaseTool): + """Tool for generating basic pytest test files.""" + name: str = "generate-test" - description: str = ("Generate a basic pytest test file for a given Python module. " - "Creates a tests/ directory and a test_.py scaffold.") - response_format: Literal["content_and_artifact"] = "content_and_artifact" - args_schema: type[BaseModel] = GenerateTestArgs + description: str = ( + "Generate a basic pytest test file for a given Python module. " + "Creates a tests/ directory and a test_.py scaffold." + ) + response_format: Literal["content", "content_and_artifact"] = ( + "content_and_artifact" + ) + args_schema: type[BaseModel] = GenerateTestArgs # pyrefly: ignore[bad-override-mutable-attribute] root: Path - model_config = ConfigDict(arbitrary_types_allowed = True) + model_config = ConfigDict(arbitrary_types_allowed=True) - def __init__(self, root_dir: str | Path, **kwargs): - super().__init__(root = Path(root_dir).expanduser().resolve(), **kwargs) + def __init__(self, root_dir: Path, **kwargs) -> None: + """Initialize the tool with the root directory. - def _run( - self, file_path: str, tests_dir: str = "tests" - ) -> tuple[str, FileObject]: - """Generates a basic pytest test file for a given Python module.""" + :param root_dir: The root directory of the project. + :param kwargs: Additional arguments to pass to the parent class. + :return: None + """ + super().__init__(root=Path(root_dir).expanduser().resolve(), **kwargs) + def _run( + self, file_path: str, tests_dir: str = "tests" + ) -> tuple[str, FileObject]: + """Generate a basic pytest test file for a given Python module. + + :param file_path: Path to the module/file to generate tests for. + :param tests_dir: Directory to place generated tests. + :return: Tuple of (message, FileObject) + """ src = self.root / file_path if not src.exists(): - return (f"❌ Source file not found: {src}", FileObject(path = src, contents = "", status = "error"),) + return ( + f"❌ Source file not found: {src}", + FileObject(path=src, contents="", status="error"), + ) if src.suffix != ".py": raise ValueError(f"File {file_path} is not a Python file.") module_name = Path(file_path).stem test_file = self.root / tests_dir / f"test_{module_name}.py" - test_file.parent.mkdir(parents = True, exist_ok = True) + test_file.parent.mkdir(parents=True, exist_ok=True) - scaffold = f""" + scaffold = f''' import pytest -from {module_name} import * + +import {module_name} -def test_placeholder(): - # Replace this placeholder with meaningful tests for `{module_name}` - assert True -""" +def test_{module_name}_smoke(): + """Smoke test for ``{module_name}``. + + The generated module imports successfully. Replace this with meaningful + tests for the module's public API. + """ + assert {module_name} is not None +''' if test_file.exists(): - return (f"❌ Test file already exists: {test_file}", FileObject( - path = test_file, contents = test_file.read_text(encoding = "utf-8"), status = "exists", ),) + return ( + f"❌ Test file already exists: {test_file}", + FileObject( + path=test_file, + contents=test_file.read_text(encoding="utf-8"), + status="exists", + ), + ) - test_file.write_text(scaffold, encoding = "utf-8") - return (f"✅ Generated test scaffold: {test_file}", - FileObject(path = test_file, contents = scaffold, status = "created"),) + test_file.write_text(scaffold, encoding="utf-8") + return ( + f"✅ Generated test scaffold: {test_file}", + FileObject(path=test_file, contents=scaffold, status="created"), + ) async def _arun( - self, file_path: str, tests_dir: str = "tests" - ) -> tuple[str, FileObject]: + self, file_path: str, tests_dir: str = "tests" + ) -> tuple[str, FileObject]: """Async version.""" return self._run(file_path, tests_dir) diff --git a/code_agent/tools/linker_tool.py b/code_agent/tools/linker_tool.py index e2ae7ef..d660242 100644 --- a/code_agent/tools/linker_tool.py +++ b/code_agent/tools/linker_tool.py @@ -1,57 +1,81 @@ -# tools/linker_tool.py +"""Tool to read files.""" + from __future__ import annotations from pathlib import Path from typing import Any, Literal -from langchain.tools import BaseTool -from pydantic import BaseModel, ConfigDict, Field - from .edit_file_tool import FileObject -# --------------------------------- -# 1️⃣ Arguments schema +from langchain.tools import BaseTool +from pydantic import BaseModel, ConfigDict, Field +# Arguments schema class LinkerArgs(BaseModel): """Arguments for reading file contents.""" - file_path: str = Field(..., description = "Path to file to read") - - -# --------------------------------- -# 2️⃣ Tool definition + file_path: str = Field(..., description="Path to file to read") +# Tool definition class LinkerTool(BaseTool): """Tool for reading file contents.""" name: str = "linker" - description: str = ("Read and return the full contents of a file. " - "Returns file contents as string and FileObject artifact.") - response_format: Literal["content_and_artifact"] = "content_and_artifact" - args_schema: type[BaseModel] = LinkerArgs + description: str = ( + "Read and return the full contents of a file. " + "Returns file contents as string and FileObject artifact." + ) + response_format: Literal["content", "content_and_artifact"] = ( + "content_and_artifact" + ) + args_schema: type[BaseModel] = LinkerArgs # pyrefly: ignore[bad-override-mutable-attribute] root: Path - model_config = ConfigDict(arbitrary_types_allowed = True) + model_config = ConfigDict(arbitrary_types_allowed=True) + + def __init__(self, root_dir: Path, **kwargs: dict[str, Any]) -> None: + """Initialize the tool. - def __init__(self, root_dir: str | Path, **kwargs): - super().__init__(root = Path(root_dir).expanduser().resolve(), **kwargs) + :param root_dir: The root directory to use for file operations. + :param kwargs: Additional arguments to pass to the parent class. + :return: None + """ + super().__init__(root=Path(root_dir).expanduser().resolve(), **kwargs) # type: ignore[call-arg, arg-type] def _run(self, **kwargs: Any) -> tuple[str, FileObject]: + """Run the tool. + + :param kwargs: The arguments to pass to the tool. + :return: The result of the tool. + """ file_path_str: str = kwargs.get("file_path", "") file_path = self.root / file_path_str if not file_path.exists(): - return (f"❌ File not found: {file_path}", FileObject(path = file_path, contents = "", status = "error"),) + return ( + f"❌ File not found: {file_path}", + FileObject(path=file_path, contents="", status="error"), + ) try: - contents = file_path.read_text(encoding = "utf-8") - return (contents, FileObject(path = file_path, contents = contents, status = "read"),) + contents = file_path.read_text(encoding="utf-8") + return ( + contents, + FileObject(path=file_path, contents=contents, status="read"), + ) except Exception as e: - return (f"❌ Error reading file: {e}", FileObject(path = file_path, contents = "", status = "error"),) + return ( + f"❌ Error reading file: {e}", + FileObject(path=file_path, contents="", status="error"), + ) async def _arun(self, **kwargs: Any) -> tuple[str, FileObject]: - """Async version.""" + """Async version. + + :param kwargs: The arguments to pass to the tool. + :return: The result of the tool. + """ return self._run(**kwargs) diff --git a/code_agent/tools/new_file_tool.py b/code_agent/tools/new_file_tool.py index 63c8108..a491219 100644 --- a/code_agent/tools/new_file_tool.py +++ b/code_agent/tools/new_file_tool.py @@ -1,16 +1,17 @@ -# tools/new_file_tool.py +"""Tool to generate new files.""" + from __future__ import annotations import logging -import shutil from pathlib import Path +import shutil from typing import Literal +from .edit_file_tool import FileObject + from langchain.tools import BaseTool from pydantic import BaseModel, ConfigDict, Field -from .edit_file_tool import FileObject - log = logging.getLogger(__name__) @@ -18,38 +19,57 @@ class NewFileArgs(BaseModel): """Arguments for creating a new file.""" file_path: str = Field( - ..., description = "The full path, including the filename, where the new file should be created.", ) + ..., + description="The full path, including the filename, where the new file should be created.", + ) content: str = Field( - ..., description = "The content to be written into the new file." - ) + ..., description="The content to be written into the new file." + ) class NewFileTool(BaseTool): """Tool for creating new files.""" name: str = "new-file" - description: str = ("Use this tool to create a new file with specified content. " - "Provide a 'file_path' and the 'content' for the file. " - "Example: {'tool': 'new-file', 'arguments': {'file_path': 'src/new_module.py', 'content': '# " - "New Python module\\n'}}") - response_format: Literal["content_and_artifact"] = "content_and_artifact" - args_schema: type[BaseModel] = NewFileArgs + description: str = ( + "Use this tool to create a new file with specified content. " + "Provide a 'file_path' and the 'content' for the file. " + "Example: {'tool': 'new-file', 'arguments': {'file_path': 'src/new_module.py', 'content': '# " + "New Python module\\n'}}" + ) + response_format: Literal["content", "content_and_artifact"] = ( + "content_and_artifact" + ) + args_schema: type[BaseModel] = NewFileArgs # pyrefly: ignore[bad-override-mutable-attribute] root: Path - model_config = ConfigDict(arbitrary_types_allowed = True) + model_config = ConfigDict(arbitrary_types_allowed=True) - def __init__(self, root_dir: str | Path, **kwargs): - super().__init__(root = Path(root_dir).expanduser().resolve(), **kwargs) + def __init__(self, root_dir: Path, **kwargs) -> None: + """Initialize the NewFileTool with the root directory. - def _run( - self, file_path: str, content: str, overwrite: bool = False - ) -> tuple[str, FileObject]: - """Creates a new file at the specified path with the given content.""" + :param root_dir: The root directory for file operations. + :param kwargs: Additional keyword arguments. + :return: None + """ + super().__init__(root=Path(root_dir).expanduser().resolve(), **kwargs) + def _run( + self, file_path: str, content: str, overwrite: bool = False + ) -> tuple[str, FileObject]: + """Create a new file at the specified path with the given content. + + :param file_path: The path where the new file should be created. + :param content: The content to be written into the new file. + :param overwrite: Whether to overwrite the file if it already exists. + :return: A tuple containing a message and a FileObject. + """ if not file_path: - return ("❌ Error: 'file_path' cannot be empty.", - FileObject(path = Path(), contents = "", status = "error"),) + return ( + "❌ Error: 'file_path' cannot be empty.", + FileObject(path=Path(), contents="", status="error"), + ) full_path = self.root / file_path @@ -57,28 +77,27 @@ def _run( backup_status = "no_backup" if full_path.exists(): if not overwrite: - return (f"❌ File already exists: {full_path}. Use 'overwrite=True' to replace it.", FileObject( - path = full_path, contents = "", status = "error" - ),) + return ( + f"❌ File already exists: {full_path}. Use 'overwrite=True' to replace it.", + FileObject(path=full_path, contents="", status="error"), + ) else: # Create a backup before overwriting backup_path = full_path.with_suffix( - full_path.suffix + ".bak" - ) + full_path.suffix + ".bak" + ) try: shutil.copy(full_path, backup_path) backup_status = "backup_created" - log.info( - f"Backup created for overwrite: {backup_path}" - ) + log.info(f"Backup created for overwrite: {backup_path}") except Exception as e: backup_status = "backup_failed" - log.error( - f"Failed to create backup for {full_path} during overwrite: {e}", - exc_info = True, ) # Continue with the creation, but report backup failure + log.exception( + f"Failed to create backup for {full_path} during overwrite: {e}" + ) # Continue with the creation, but report backup failure - full_path.parent.mkdir(parents = True, exist_ok = True) - full_path.write_text(content, encoding = "utf-8") + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_text(content, encoding="utf-8") message = f"✅ Successfully created {full_path}" if backup_status == "backup_failed": @@ -86,13 +105,19 @@ def _run( elif backup_status == "backup_created": message += " (Original backed up)" - return (message, FileObject(path = full_path, contents = content, status = "created"),) + return ( + message, + FileObject(path=full_path, contents=content, status="created"), + ) except Exception as e: - log.error(f"Error creating file {full_path}: {e}", exc_info = True) - return (f"❌ Error creating file: {e}", FileObject(path = full_path, contents = "", status = "error"),) + log.exception(f"Error creating file {full_path}: {e}") + return ( + f"❌ Error creating file: {e}", + FileObject(path=full_path, contents="", status="error"), + ) async def _arun( - self, file_path: str, content: str, overwrite: bool = False - ) -> tuple[str, FileObject]: + self, file_path: str, content: str, overwrite: bool = False + ) -> tuple[str, FileObject]: """Async version.""" return self._run(file_path, content, overwrite) diff --git a/code_agent/tools/nlp_tool.py b/code_agent/tools/nlp_tool.py index b84fc3c..aa9242d 100644 --- a/code_agent/tools/nlp_tool.py +++ b/code_agent/tools/nlp_tool.py @@ -8,6 +8,8 @@ from langchain_core.messages import AIMessage from langchain_core.tools import BaseTool +logger = logging.getLogger(__name__) + class NaturalLanguageTool(BaseTool): """Tool for processing natural language queries and delegating to appropriate tools.""" @@ -19,10 +21,15 @@ class NaturalLanguageTool(BaseTool): """ llm: BaseChatModel | None = None - tools: list = [] # This will be set later by the agent + tools: list = [] # This will be set later by the agent # ruff: ignore[mutable-class-default] def _run(self, query: str, **kwargs: Any) -> str: - """Process a natural language query and delegate to the appropriate tool.""" + """Process a natural language query and delegate to the appropriate tool. + + :param query: The natural language query to process. + :param kwargs: Additional arguments. + :return: The result of the tool. + """ if not self.llm: return json.dumps({"error": "Language model not initialized"}) @@ -38,17 +45,19 @@ def _run(self, query: str, **kwargs: Any) -> str: arg_details = [] for arg_name, arg_info in properties.items(): - is_required = ("required" if arg_name in required_args else "optional") + is_required = ( + "required" if arg_name in required_args else "optional" + ) arg_desc = arg_info.get("description", "No description") arg_details.append( - f" - `{arg_name}` ({is_required}): {arg_desc}" - ) + f" - `{arg_name}` ({is_required}): {arg_desc}" + ) tool_manifest.append( - f" - Tool: `{t.name}`\n" - f" Description: {t.description}\n" - f" Arguments:\n" + "\n".join(arg_details) - ) + f" - Tool: `{t.name}`\n" + f" Description: {t.description}\n" + f" Arguments:\n" + "\n".join(arg_details) + ) tool_manifest_str = "\n".join(tool_manifest) @@ -74,35 +83,40 @@ def _run(self, query: str, **kwargs: Any) -> str: Valid JSON Response:""" + content = "" try: response: AIMessage = self.llm.invoke(prompt) - content = (response.content if hasattr(response, "content") else str(response)) - logging.debug(f"Raw LLM response for tool selection: {content}") + raw = ( + response.content + if hasattr(response, "content") + else str(response) + ) + if isinstance(raw, list): + raw = "\n".join(str(part) for part in raw) + content = str(raw) + logger.debug(f"Raw LLM response for tool selection: {content}") # Clean the response content content = content.strip() - if content.startswith("```json"): - content = content[7:] - if content.endswith("```"): - content = content[:-3] + content = content.removeprefix("```json") + content = content.removesuffix("```") content = content.strip() tool_call = json.loads(content) if not isinstance(tool_call, dict) or "tool" not in tool_call: - return json.dumps( - {"error": "LLM failed to select a valid tool."} - ) + return json.dumps({ + "error": "LLM failed to select a valid tool." + }) return json.dumps(tool_call) except json.JSONDecodeError as e: - logging.error(f"JSONDecodeError: {e}. LLM response was: {content}") - return json.dumps( - {"error": "Invalid JSON format from LLM.", "raw_response": content, } - ) + logger.error(f"JSONDecodeError: {e}. LLM response was: {content}") + return json.dumps({ + "error": "Invalid JSON format from LLM.", + "raw_response": content, + }) except Exception as e: - logging.error(f"Error in NaturalLanguageTool: {e}") - return json.dumps( - {"error": f"An unexpected error occurred: {str(e)}"} - ) + logger.error(f"Error in NaturalLanguageTool: {e}") + return json.dumps({"error": f"An unexpected error occurred: {e!s}"}) diff --git a/code_agent/tools/notebook_tool.py b/code_agent/tools/notebook_tool.py index c6e95dd..71bfcd2 100644 --- a/code_agent/tools/notebook_tool.py +++ b/code_agent/tools/notebook_tool.py @@ -1,48 +1,61 @@ -# tools/notebook_tool.py +"""Tool to generate notebooks.""" + from __future__ import annotations from pathlib import Path -from typing import Any, Literal - -import nbformat -from langchain.tools import BaseTool -from pydantic import BaseModel, ConfigDict, Field +from typing import Any, Literal, cast from .edit_file_tool import FileObject +from langchain.tools import BaseTool +import nbformat +from nbformat import NotebookNode +from pydantic import BaseModel, ConfigDict, Field class NotebookArgs(BaseModel): + """Arguments for the notebook tool.""" + file_path: str = Field( - ..., description = "Path to the notebook to create or edit" - ) - content: str = Field("", description = "Markdown or code content to insert") - mode: str = Field("create", description = "Mode: create|append|replace") + ..., description="Path to the notebook to create or edit" + ) + content: str = Field("", description="Markdown or code content to insert") + mode: str = Field("create", description="Mode: create|append|replace") class NotebookTool(BaseTool): + """Tool for creating and editing Jupyter notebooks.""" + name: str = "notebook" - description: str = ("Create or edit Jupyter notebooks (.ipynb). Mode create: create a minimal notebook; " - "append: add a markdown cell with content; replace: replace entire notebook with given " - "content.") - response_format: Literal["content_and_artifact"] = "content_and_artifact" - args_schema: type[BaseModel] = NotebookArgs + description: str = ( + "Create or edit Jupyter notebooks (.ipynb). Mode create: create a minimal notebook; " + "append: add a markdown cell with content; replace: replace entire notebook with given " + "content." + ) + response_format: Literal["content", "content_and_artifact"] = ( + "content_and_artifact" + ) + args_schema: type[BaseModel] = NotebookArgs # pyrefly: ignore[bad-override-mutable-attribute] root: Path - model_config = ConfigDict(arbitrary_types_allowed = True) + model_config = ConfigDict(arbitrary_types_allowed=True) - def __init__(self, root_dir: str | Path, **kwargs): - """ - Initializes the NotebookTool with the given root directory. - Args: - root_dir (str | Path): The root directory for the notebook tool. - **kwargs: Additional keyword arguments. - Returns: None + def __init__(self, root_dir: Path, **kwargs: Any) -> None: + """Initializes the NotebookTool with the given root directory. + + :param root_dir (str | Path): The root directory for the notebook tool. + :param **kwargs: Additional keyword arguments. + :return: None """ - super().__init__(root = Path(root_dir).expanduser().resolve(), **kwargs) + super().__init__(root=Path(root_dir).expanduser().resolve(), **kwargs) def _run(self, **kwargs: Any) -> tuple[str, FileObject]: - file_path: str = kwargs.get("file_path") + """Creates or edits a Jupyter notebook. + + :param **kwargs: Keyword arguments containing the file path, content, and mode. + :return: A tuple containing the result message and a FileObject. + """ + file_path: str = kwargs.get("file_path", "") content: str = kwargs.get("content", "") mode: str = kwargs.get("mode", "create") @@ -51,38 +64,59 @@ def _run(self, **kwargs: Any) -> tuple[str, FileObject]: if mode == "create": nb = nbformat.v4.new_notebook() nb.cells.append(nbformat.v4.new_markdown_cell(content)) - nb_path.parent.mkdir(parents = True, exist_ok = True) + nb_path.parent.mkdir(parents=True, exist_ok=True) nbformat.write(nb, str(nb_path)) - return (f"✅ Created notebook {nb_path}", FileObject( - path = nb_path, contents = content, status = "created" - ),) + return ( + f"✅ Created notebook {nb_path}", + FileObject( + path=nb_path, contents=content, status="created" + ), + ) elif mode == "append": if not nb_path.exists(): - return (f"❌ Notebook not found: {nb_path}", - FileObject(path = nb_path, contents = "", status = "error"),) - nb = nbformat.read(str(nb_path), as_version = 4) + return ( + f"❌ Notebook not found: {nb_path}", + FileObject(path=nb_path, contents="", status="error"), + ) + nb = cast( + NotebookNode, nbformat.read(str(nb_path), as_version=4) + ) + nb.cells.append(nbformat.v4.new_markdown_cell(content)) nbformat.write(nb, str(nb_path)) - return (f"✅ Appended notebook {nb_path}", FileObject( - path = nb_path, contents = content, status = "appended" - ),) + return ( + f"✅ Appended notebook {nb_path}", + FileObject( + path=nb_path, contents=content, status="appended" + ), + ) elif mode == "replace": # Interpret content as raw notebook JSON or as a markdown cell try: - nb_obj = nbformat.reads(content, as_version = 4) + nb_obj = nbformat.reads(content, as_version=4) nbformat.write(nb_obj, str(nb_path)) except Exception: - nb = nbformat.v4.new_notebook() + nb: NotebookNode = nbformat.v4.new_notebook() nb.cells.append(nbformat.v4.new_markdown_cell(content)) - nb_path.parent.mkdir(parents = True, exist_ok = True) + nb_path.parent.mkdir(parents=True, exist_ok=True) nbformat.write(nb, str(nb_path)) - return (f"✅ Replaced notebook {nb_path}", FileObject( - path = nb_path, contents = content, status = "replaced" - ),) + return ( + f"✅ Replaced notebook {nb_path}", + FileObject( + path=nb_path, contents=content, status="replaced" + ), + ) else: - return (f"❌ Unknown mode: {mode}", FileObject(path = nb_path, contents = "", status = "error"),) + return ( + f"❌ Unknown mode: {mode}", + FileObject(path=nb_path, contents="", status="error"), + ) except Exception as e: - return (f"❌ Notebook operation failed: {e}", FileObject(path = nb_path, contents = "", status = "error"),) + return ( + f"❌ Notebook operation failed: {e}", + FileObject(path=nb_path, contents="", status="error"), + ) async def _arun(self, **kwargs: Any) -> tuple[str, FileObject]: + """Use the tool asynchronously.""" return self._run(**kwargs) diff --git a/code_agent/tools/r_tool.py b/code_agent/tools/r_tool.py index ca6b3be..e6eea99 100644 --- a/code_agent/tools/r_tool.py +++ b/code_agent/tools/r_tool.py @@ -1,43 +1,47 @@ -# tools/r_tool.py +"""R script execution tool.""" + from __future__ import annotations +from pathlib import Path import subprocess import tempfile -from pathlib import Path from typing import Any from langchain.tools import BaseTool from pydantic import BaseModel, Field - class RScriptArgs(BaseModel): """Arguments for executing an R script.""" - code: str = Field(..., description = "The R code to be executed.") + code: str = Field(..., description="The R code to be executed.") class RScriptTool(BaseTool): """A tool for executing R code.""" name: str = "r-script" - description: str = ("Use this tool to execute R code. " - "Provide the R code as a string. The tool will return the standard output and standard error.") - args_schema: type[BaseModel] = RScriptArgs + description: str = ( + "Use this tool to execute R code. " + "Provide the R code as a string. The tool will return the standard output and standard error." + ) + args_schema: type[BaseModel] = RScriptArgs # pyrefly: ignore[bad-override-mutable-attribute] def _run(self, code: str) -> str: """Executes the given R code and returns the output.""" - with tempfile.NamedTemporaryFile( - mode = "w", suffix = ".R", delete = False - ) as temp_file: + encoding="utf-8", mode="w", suffix=".R", delete=False + ) as temp_file: temp_file.write(code) temp_file_path = temp_file.name try: result = subprocess.run( - ["Rscript", temp_file_path], capture_output = True, text = True, check = False, - # Do not raise exception on non-zero exit code - ) + ["Rscript", temp_file_path], + capture_output=True, + text=True, + check=False, + # Do not raise exception on non-zero exit code + ) output = "" if result.stdout: diff --git a/code_agent/tools/read_file_tool.py b/code_agent/tools/read_file_tool.py index ff4eec1..b18b9d5 100644 --- a/code_agent/tools/read_file_tool.py +++ b/code_agent/tools/read_file_tool.py @@ -1,4 +1,5 @@ -# tools/read_file_tool.py +"""Tool to read files.""" + from __future__ import annotations from pathlib import Path @@ -7,30 +8,41 @@ from langchain.tools import BaseTool from pydantic import BaseModel, Field - class ReadFileArgs(BaseModel): """Arguments for reading a file.""" file_path: str = Field( - ..., description = "The full path of the file to read." - ) + ..., description="The full path of the file to read." + ) class ReadFileTool(BaseTool): """Tool for reading the content of a file.""" name: str = "read-file" - description: str = ("Use this tool to read the entire content of a file. " - "Provide a 'file_path' to the file you want to inspect.") - args_schema: type[BaseModel] = ReadFileArgs + description: str = ( + "Use this tool to read the entire content of a file. " + "Provide a 'file_path' to the file you want to inspect." + ) + args_schema: type[BaseModel] = ReadFileArgs # pyrefly: ignore[bad-override-mutable-attribute] root: Path - def __init__(self, root_dir: str | Path, **kwargs): - super().__init__(root = Path(root_dir).expanduser().resolve(), **kwargs) + def __init__(self, root_dir: Path, **kwargs: Any) -> None: + """Initialize the ReadFileTool with the given root directory. + + :param root_dir: The root directory to search in. + :param kwargs: Additional keyword arguments. + :return: None + """ + super().__init__(root=Path(root_dir).expanduser().resolve(), **kwargs) def _run(self, file_path: str) -> str: - """Reads the content of the specified file.""" + """Reads the content of the specified file. + + :param file_path: The path to the file to read. + :return: The content of the file or an error message. + """ if not file_path: return "❌ Error: 'file_path' cannot be empty." @@ -43,7 +55,7 @@ def _run(self, file_path: str) -> str: return f"❌ Error: Path exists but is not a file: {full_path}" try: - content = full_path.read_text(encoding = "utf-8") + content = full_path.read_text(encoding="utf-8") return f"Content of {file_path}:\n\n---\n{content}\n---" except Exception as e: return f"❌ Error reading file: {e}" diff --git a/code_agent/tools/search_explain_tool.py b/code_agent/tools/search_explain_tool.py index 170df4e..b3c7ce1 100644 --- a/code_agent/tools/search_explain_tool.py +++ b/code_agent/tools/search_explain_tool.py @@ -1,70 +1,102 @@ -# tools/search_explain_tool.py +"""Search and explain tool for the code_agent package.""" + from __future__ import annotations import json -import re from pathlib import Path +import re from typing import Any, Literal +from .edit_file_tool import FileObject + from langchain.tools import BaseTool from langchain_core.language_models import BaseChatModel from langchain_core.messages import HumanMessage from pydantic import BaseModel, ConfigDict, Field -from .edit_file_tool import FileObject - - class SearchExplainArgs(BaseModel): - """Arguments schema for searching and explaining code.""" + """Arguments schema for searching and explaining code. - search_query: str = Field(..., description = "Term or regexp to search") - max_results: int = Field( - 10, description = "Maximum number of hits to return" - ) + The search query can be a string or a regular expression. + """ + + search_query: str = Field(..., description="Term or regexp to search") + max_results: int = Field(10, description="Maximum number of hits to return") class SearchExplainTool(BaseTool): - """Tool for searching code and generating comprehensive explanations.""" + """Tool for searching code and generating comprehensive explanations. + + This tool searches for a specific string or pattern in local text files and + summarizes the snippets (and their file names). It returns the summary as + a string and a FileObject containing the first hit's path. + """ name: str = "search-explain" - description: str = ("Search for a specific string or pattern in local text files and " - "summarise the snippets (and their file names). Return the summary as " - "a string and a FileObject containing the first hit's path.") - response_format: Literal["content_and_artifact"] = "content_and_artifact" - args_schema: type[BaseModel] = SearchExplainArgs + description: str = ( + "Search for a specific string or pattern in local text files and " + "summarize the snippets (and their file names). Return the summary as " + "a string and a FileObject containing the first hit's path." + ) + response_format: Literal["content", "content_and_artifact"] = ( + "content_and_artifact" + ) + args_schema: type[BaseModel] = SearchExplainArgs # pyrefly: ignore[bad-override-mutable-attribute] llm: BaseChatModel root: Path - model_config = ConfigDict(arbitrary_types_allowed = True) + model_config = ConfigDict(arbitrary_types_allowed=True) def __init__( - self, root_dir: str | Path, llm_instance: BaseChatModel, max_hits: int = 10, **kwargs, ): + self, + root_dir: Path, + llm_instance: BaseChatModel, + max_hits: int = 10, + **kwargs: Any, + ) -> None: + """Initialize the SearchExplainTool with the given root directory. + + :param root_dir: The root directory to search in. + :param llm_instance: The language model instance to use for summarization. + :param max_hits: The maximum number of hits to return. + :param kwargs: Additional keyword arguments. + :return: None + """ super().__init__( - llm = llm_instance, root = Path(root_dir).expanduser().resolve(), **kwargs, ) + root=Path(root_dir).expanduser().resolve(), + llm=llm_instance, + **kwargs, + ) def _read_ipynb_preview(self, path: Path) -> str: + """Read the preview of an IPython notebook file. + + :param path: The path to the IPython notebook file. + :return: The preview of the IPython notebook file. + """ try: - nb = json.loads(path.read_text(encoding = "utf-8")) + nb = json.loads(path.read_text(encoding="utf-8")) cells = nb.get("cells", []) texts = [] for c in cells: - if c.get("cell_type") == "markdown": - texts.append("".join(c.get("source", []))) - elif c.get("cell_type") == "code": + if ( + c.get("cell_type") == "markdown" + or c.get("cell_type") == "code" + ): texts.append("".join(c.get("source", []))) return "\n".join(texts)[:1000] except Exception: return "" def _run(self, **kwargs: Any) -> tuple[str, FileObject]: - """ - Parameters - ---------- - kwargs : search query - Returns - ------- - tuple[str, FileObject] + """Run the tool to search for a specific string or pattern in local text files and summarize the snippets (and their file names). + + Return the summary as a string and a FileObject containing the first hit's path. + + + :param kwargs : search query + :return tuple[str, FileObject] """ search_query: str = kwargs.get("search_query", "") max_results: int = kwargs.get("max_results", 10) @@ -78,33 +110,29 @@ def _run(self, **kwargs: Any) -> tuple[str, FileObject]: hits = self._gather_hits(search_query, pattern, max_results) if not hits: - return ("❌ No matches found.", FileObject(path = Path(), contents = "", status = "No hits"),) + return ( + "❌ No matches found.", + FileObject(path=Path(), contents="", status="No hits"), + ) summary, first_hit = self._summarize_hits(hits) file_obj = FileObject( - path = Path(str(first_hit["file_path"])).resolve(), contents = "", status = "Analysed", ) + path=Path(str(first_hit["file_path"])).resolve(), + contents="", + status="Analyzed", + ) return summary, file_obj def _gather_hits( - self, search_query: str, pattern: re.Pattern | None, max_results: int - ): - """ - Gather all matching files and their snippets from the root directory. - - Parameters - ---------- - search_query : str - The search query to look for in files - pattern : re.Pattern | None - A compiled regex pattern to search for - max_results : int - The maximum number of hits to return - - Returns - ------- - list[dict] - A list of dictionaries, each containing the file path and snippet + self, search_query: str, pattern: re.Pattern | None, max_results: int + ) -> list[dict[str, str]]: + """Gather all matching files and their snippets from the root directory. + + :param search_query : The search query to look for in files + :param pattern : A compiled regex pattern to search for + :param max_results : The maximum number of hits to return + :return: A list of dictionaries, each containing the file path and snippet """ hits = [] for path in self.root.rglob("*"): @@ -131,11 +159,25 @@ def _gather_hits( return hits def _is_candidate_path(self, path: Path) -> bool: - """Return True if the path should be considered for searching.""" - # skip virtualenvs and large folders + """Return True if the path should be considered for searching. + + Skip virtualenvs, large folders, and non-files. + + :param path: The path to check. + :return: True if the path should be considered for searching. + """ if any( - part in (".venv", "venv", "node_modules", "packrat", "archive", "output",) for part in path.parts - ): + part + in { + ".venv", + "venv", + "node_modules", + "packrat", + "archive", + "output", + } + for part in path.parts + ): return False if not path.is_file(): return False @@ -147,26 +189,38 @@ def _is_candidate_path(self, path: Path) -> bool: return True def _read_file_content(self, path: Path) -> str | None: - """Read file content with safe fallback for notebooks and read errors.""" + """Read file content with safe fallback for notebooks and read errors. + + :param path: The path to the file to read. + :return: The file content as a string, or None if there was an error. + """ try: if path.suffix == ".ipynb": return self._read_ipynb_preview(path) - return path.read_text(encoding = "utf-8") + return path.read_text(encoding="utf-8") except Exception: return None - def _summarize_hits(self, hits: list[dict]): + def _summarize_hits(self, hits: list[dict]) -> tuple[str, dict[Any, Any]]: + """Summarize the hits using the LLM. + + :param hits: A list of dictionaries, each containing the file path and snippet + :return: A summary of the hits + """ snippets = "\n\n".join( - f"File: {hit['file_path']}\nSnippet:\n{hit['snippet']}" for hit in hits - ) + f"File: {hit['file_path']}\nSnippet:\n{hit['snippet']}" + for hit in hits + ) summary_prompt = ( - "You are an expert code analyst. Provide a comprehensive analysis of the following code snippets. " - "Include: 1) Overall purpose and functionality, 2) Key design patterns and architectural decisions, " - "3) Potential issues or improvements, 4) Dependencies and relationships between files, " - "5) Best practices being followed or violated. Be thorough and detailed.\n\n" + snippets) - - response = self.llm.invoke([HumanMessage(content = summary_prompt)]) + "You are an expert code analyst. Provide a comprehensive analysis of the following code snippets. " + "Include: 1) Overall purpose and functionality, 2) Key design patterns and architectural decisions, " + "3) Potential issues or improvements, 4) Dependencies and relationships between files, " + "5) Best practices being followed or violated. Be thorough and detailed.\n\n" + + snippets + ) + + response = self.llm.invoke([HumanMessage(content=summary_prompt)]) if hasattr(response, "content"): summary: str = str(response.content) else: @@ -175,4 +229,9 @@ def _summarize_hits(self, hits: list[dict]): return summary, hits[0] async def _arun(self, **kwargs: Any) -> tuple[str, FileObject]: + """Run the tool asynchronously. + + :param kwargs: The arguments to pass to the tool. + :return: The result of the tool. + """ return self._run(**kwargs) diff --git a/code_agent/ui/__init__.py b/code_agent/ui/__init__.py new file mode 100644 index 0000000..a80182b --- /dev/null +++ b/code_agent/ui/__init__.py @@ -0,0 +1,25 @@ +"""UI layer: rich terminal interface for the capability layer. + +Exposes the public names of the rich CLI UI - the rendering helpers and the +interactive ``run_cli_ui`` session driver. +""" + +from __future__ import annotations + +from .cli_ui import ( + render_catalog, + render_progress, + render_receipt, + render_request, + render_response, + run_cli_ui, +) + +__all__ = [ + "render_catalog", + "render_progress", + "render_receipt", + "render_request", + "render_response", + "run_cli_ui", +] diff --git a/code_agent/ui/cli_ui.py b/code_agent/ui/cli_ui.py new file mode 100644 index 0000000..84e655a --- /dev/null +++ b/code_agent/ui/cli_ui.py @@ -0,0 +1,353 @@ +"""Rich terminal UI for the capability layer. + +Renders the capability catalog, drives an interactive invocation session +against a :class:`CapabilityRegistry`, and displays the resulting +:class:`InvocationResponse` and hash-chained audit :class:`Receipt` using +the ``rich`` library. + +Every rendering function accepts an injectable +:class:`rich.console.Console` so tests can capture output; the interactive +loop accepts an injectable read-line callable for the same reason. Errors +returned by the registry are rendered inline - raw stack traces are never +leaked to the user. +""" + +from __future__ import annotations + +from collections.abc import Callable +import json +from typing import Any +import uuid + +from code_agent.capabilities.audit import Receipt +from code_agent.capabilities.envelope import ( + InvocationRequest, + InvocationResponse, +) +from code_agent.capabilities.registry import CapabilityRegistry +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +def _risk_style(risk_class: str) -> str: + """Return a rich style token for a risk class. + + :param risk_class: One of the :data:`RiskClass` values. + + :return: A rich style name: green for low, yellow for medium, red for high, + white for anything else. + """ + return { + "low": "green", + "medium": "yellow", + "high": "red", + }.get(risk_class.lower(), "white") + + +def _coerce_param(raw: str, prop: dict[str, Any]) -> Any: + """Coerce raw user input to the JSON-schema declared type. + + Unparseable values fall back to the raw string rather than aborting the + session, keeping interactive input robust. + + :param raw: The raw string entered by the user. + :param prop: The JSON schema property describing the parameter. + + :return: The coerced value, or ``raw`` when coercion fails. + """ + json_type = prop.get("type", "string") + try: + if json_type == "integer": + return int(raw) + if json_type == "number": + return float(raw) + if json_type == "boolean": + lowered = raw.lower() + if lowered in {"true", "1", "yes"}: + return True + if lowered in {"false", "0", "no"}: + return False + raise ValueError(f"not a boolean: {raw}") + if json_type in {"array", "object"}: + return json.loads(raw) + except (ValueError, json.JSONDecodeError): + # Coercion is best-effort; keep the raw value instead of failing. + pass + return raw + + +def _prompt_params( + capability_meta: dict[str, Any], + read_line: Callable[[str], str], + console: Console, +) -> dict[str, Any]: + """Collect typed parameters for a capability from the user. + + Each property of the capability's input JSON schema is prompted for. + Optional parameters may be skipped by pressing enter; required ones that + are skipped are simply omitted from the request (the registry's own + validation reports any missing field). + + :param capability_meta: One entry from :meth:`CapabilityRegistry.discover`. + :param read_line: Callable that reads a line of user input. + :param console: Output console. + + :return: A dict of parameter name -> value suitable for an :class:`InvocationRequest`. + """ + schema = capability_meta.get("input_schema", {}) + properties: dict[str, dict[str, Any]] = schema.get("properties", {}) + required = set(schema.get("required", [])) + params: dict[str, Any] = {} + + if not properties: + console.print("[dim]This capability takes no parameters.[/dim]") + return params + + console.print("Supply parameters ([dim]enter to skip optional[/dim]):") + for param_name, prop in properties.items(): + label = f"{param_name} ({prop.get('type', 'any')})" + if param_name in required: + label += " [red]*[/red]" + raw = read_line(f" {label}: ").strip() + if not raw: + if param_name in required: + console.print( + f"[yellow]Skipped required parameter '{param_name}'.[/yellow]" + ) + continue + params[param_name] = _coerce_param(raw, prop) + return params + + +def _select_capability( + catalog: list[dict[str, Any]], + raw: str, + console: Console, +) -> dict[str, Any] | None: + """Resolve a user selection (1-based index or capability id) to an entry. + + :param catalog: Entries from :meth:`CapabilityRegistry.discover`. + :param raw: The raw user input. + :param console: Output console. + :return: The selected catalog entry, or ``None`` when nothing matches. + """ + text = raw.strip().lower() + if text.isdigit(): + index = int(text) - 1 + if 0 <= index < len(catalog): + return catalog[index] + console.print(f"[yellow]No capability at index {int(text)}.[/yellow]") + return None + for entry in catalog: + if entry["id"].lower() == text: + return entry + console.print(f"[yellow]Unknown capability '{raw.strip()}'.[/yellow]") + return None + + +def render_catalog( + registry: CapabilityRegistry, console: Console | None = None +) -> None: + """Render the capability catalog as a rich table. + + Each row shows the selection index, the capability ``id``, its ``intent`` + and its ``risk_class`` (color-coded by severity). + + :param registry: The registry whose capabilities are listed. + :param console: Output console; defaults to a new :class:`Console`. + :return: None + """ + console = console or Console() + table = Table(title="Capability Catalog", header_style="bold magenta") + table.add_column("#", justify="right", style="dim") + table.add_column("id", style="cyan") + table.add_column("intent") + table.add_column("risk class", justify="center") + for index, capability in enumerate(registry.discover(), start=1): + table.add_row( + str(index), + capability["id"], + capability["intent"], + Text( + capability["risk_class"], + style=_risk_style(capability["risk_class"]), + ), + ) + console.print(table) + + +def render_request( + request: InvocationRequest, console: Console | None = None +) -> None: + """Render an invocation request as a rich panel. + + :param request: The request to display. + :param console: Output console; defaults to a new :class:`Console`. + :return: None + """ + console = console or Console() + lines = [ + f"request_id: {request.request_id}", + f"capability: {request.capability_id}", + f"caller: {request.caller or 'anonymous'}", + f"params: {json.dumps(request.params, indent=2, default=str)}", + ] + console.print( + Panel( + "\n".join(lines), + title="Invocation request", + border_style="blue", + ) + ) + + +def render_progress( + request: InvocationRequest, console: Console | None = None +) -> None: + """Render a tool-call progress indicator for a pending dispatch. + + Shown immediately before :meth:`CapabilityRegistry.dispatch` runs, so + the user sees which capability and request id are being executed. + + :param request: The request being dispatched. + :param console: Output console; defaults to a new :class:`Console`. + :return: None + """ + console = console or Console() + console.print( + f"[dim]→ dispatching '{request.capability_id}' " + f"(request {request.request_id}) ...[/dim]" + ) + + +def render_response( + response: InvocationResponse, console: Console | None = None +) -> None: + """Render an invocation response as a rich panel. + + Successful responses show the structured result with a green border; + error responses show a short inline message with a red border. Raw + tracebacks are never printed. + + :param response: The response to display. + :param console: Output console; defaults to a new :class:`Console`. + :return: None + """ + console = console or Console() + if response.status == "error": + message = Text(response.error or "unknown error", style="red") + console.print( + Panel( + message, + title=f"Invocation failed ({response.capability_id})", + border_style="red", + ) + ) + return + result = json.dumps(response.result, indent=2, default=str) + console.print( + Panel( + result, + title=( + f"Invocation succeeded ({response.capability_id}) " + f"in {response.duration_ms} ms" + ), + border_style="green", + ) + ) + + +def render_receipt(receipt: Receipt, console: Console | None = None) -> None: + """Render an audit receipt (hash-chained) as a rich panel. + + Displays the receipt's linkage fields - ``prev_hash`` and + ``receipt_hash`` - so the tamper-evident chain is visible to the user. + + :param receipt: The receipt to display. + :param console: Output console; defaults to a new :class:`Console`. + :return: None + """ + console = console or Console() + lines = [ + f"request_id: {receipt.request_id}", + f"capability: {receipt.capability_id}", + f"status: {receipt.status}", + f"timestamp: {receipt.timestamp}", + f"prev_hash: {receipt.prev_hash}", + f"receipt_hash: {receipt.receipt_hash}", + ] + console.print( + Panel( + "\n".join(lines), + title="Audit receipt", + subtitle="hash-chained", + border_style="cyan", + ) + ) + + +def run_cli_ui( + registry: CapabilityRegistry, + console: Console | None = None, + input_fn: Callable[[str], str] | None = None, +) -> None: + """Run the interactive capability CLI session. + + Renders the capability catalog, then repeatedly prompts for a capability + (by index or id), collects its parameters and dispatches an + :class:`InvocationRequest`. Every dispatch renders the request, a + progress line, the resulting response and the audit receipt. + + :param registry: The registry to render and dispatch against. + :param console: Output console; defaults to a new :class:`Console`. + :param input_fn: Read-a-line callable; defaults to the builtin ``input``. + :return: None + """ + console = console or Console() + read_line = input_fn or input + catalog = registry.discover() + + render_catalog(registry, console) + + while True: + try: + raw = read_line( + "Select a capability (#, id, or 'q' to quit): " + ).strip() + except (EOFError, KeyboardInterrupt): + console.print("\n[dim]Goodbye.[/dim]") + return + if not raw or raw.lower() in {"q", "quit", "exit"}: + console.print("[dim]Goodbye.[/dim]") + return + + capability = _select_capability(catalog, raw, console) + if capability is None: + continue + + params = _prompt_params(capability, read_line, console) + request = InvocationRequest( + request_id=uuid.uuid4().hex, + capability_id=capability["id"], + params=params, + caller="cli-ui", + ) + + try: + render_request(request, console) + render_progress(request, console) + response, receipt = registry.dispatch(request) + except Exception as exc: + console.print( + Panel( + Text(f"dispatch error: {exc}", style="red"), + title="Invocation failed", + border_style="red", + ) + ) + continue + + render_response(response, console) + render_receipt(receipt, console) + console.print() diff --git a/code_agent/ui/web.py b/code_agent/ui/web.py new file mode 100644 index 0000000..5e51ebd --- /dev/null +++ b/code_agent/ui/web.py @@ -0,0 +1,130 @@ +"""FastAPI web UI for the capability layer. + +Exposes the registered capabilities over HTTP so a browser (or any HTTP +client) can list and invoke them: + +* ``GET /capabilities`` - lists the capability catalog. The catalog comes + from the same registry builder the CLI's ``capabilities list`` command + uses, so the web view always matches the CLI view. +* ``POST /invoke`` - dispatches an :class:`InvocationRequest` through the + :class:`CapabilityRegistry` and returns the :class:`InvocationResponse` + plus the hash-chained audit :class:`Receipt` as JSON. + +The built React single-page app (``webapp/dist``) is mounted statically at +the root when present, so ``code-agent serve --web`` can host the whole UI +from a single process. + +Security notes: + +* Every invocation is validated by the capability's own ``input_model``; + invalid params are rejected with HTTP 400 before any tool runs. +* Requests, params and results are never logged, so secrets and API keys + cannot leak through the web server. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any +import uuid + +from code_agent.capabilities.envelope import InvocationRequest +from code_agent.capabilities.registry import CapabilityRegistry +from fastapi import FastAPI, HTTPException +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field + +# Directory of the built React app (created by ``npm run build`` in webapp/). +_DIST_DIR = Path(__file__).resolve().parent / "webapp" / "dist" + +_REGISTRY: CapabilityRegistry | None = None + + +def get_registry() -> CapabilityRegistry: + """Return the shared capability registry, building it lazily on first use. + + The registry is built once per process so the audit receipt chain stays + continuous across requests. It reuses the CLI's ``_build_registry`` + helper, guaranteeing ``GET /capabilities`` matches ``capabilities list``. + + :return: A :class:`CapabilityRegistry` populated with the default tool-adapted capabilities. + """ + global _REGISTRY + if _REGISTRY is None: + # Imported lazily to avoid a circular import: ``cli.py`` imports + # this module inside ``serve --web``, by which time ``cli.py`` has + # finished loading and ``_build_registry`` is defined. + from code_agent.cli import _build_registry + + _REGISTRY = _build_registry() + return _REGISTRY + + +@dataclass +class InvokeBody(BaseModel): + """JSON request body for ``POST /invoke``. + + Attributes: + capability_id: Stable identifier of the capability to invoke. + params: JSON object of parameters for the capability. + """ + + capability_id: str + params: dict[str, Any] = Field(default_factory=dict) + + +app = FastAPI( + title="Code Agent Web UI", + description=( + "Web interface for the code_agent capability layer: list " + "capabilities and invoke them through the audited registry." + ), + version="0.1.0", +) + + +@app.get("/capabilities") +def list_capabilities() -> list[dict[str, Any]]: + """Return metadata for every registered capability. + + :return: A JSON list of capability metadata dicts (id, intent, risk_class, + input_schema) from the shared registry. + """ + return get_registry().discover() + + +@app.post("/invoke") +def invoke_capability(body: InvokeBody) -> dict[str, Any]: + """Dispatch an invocation and return the response plus audit receipt. + + Args: + body: Parsed request body (capability_id and params). + + :return: A JSON object with ``response`` (the :class:`InvocationResponse`) + and ``receipt`` (the audit :class:`Receipt`) fields. + + :raises HTTPException: 400 when the capability is unknown, high-risk, or the + params fail validation. + """ + request = InvocationRequest( + request_id=uuid.uuid4().hex, + capability_id=body.capability_id, + params=body.params, + caller="web", + ) + response, receipt = get_registry().dispatch(request) + if response.status == "error": + raise HTTPException(status_code=400, detail=response.error) + return { + "response": response.model_dump(), + "receipt": receipt.model_dump(), + } + + +# Serve the built React app at the root when it exists. API routes registered +# above take precedence, so the SPA only handles paths that are not API calls. +if _DIST_DIR.is_dir(): + app.mount( + "/", StaticFiles(directory=str(_DIST_DIR), html=True), name="webapp" + ) diff --git a/code_agent/ui/webapp/index.html b/code_agent/ui/webapp/index.html new file mode 100644 index 0000000..f555586 --- /dev/null +++ b/code_agent/ui/webapp/index.html @@ -0,0 +1,12 @@ + + + + + + Code Agent Web UI + + +
+ + + diff --git a/code_agent/ui/webapp/package-lock.json b/code_agent/ui/webapp/package-lock.json new file mode 100644 index 0000000..aa02e84 --- /dev/null +++ b/code_agent/ui/webapp/package-lock.json @@ -0,0 +1,893 @@ +{ + "name": "code-agent-webapp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "code-agent-webapp", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^6.0.0", + "vite": "^8.2.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/code_agent/ui/webapp/package.json b/code_agent/ui/webapp/package.json new file mode 100644 index 0000000..6a4406e --- /dev/null +++ b/code_agent/ui/webapp/package.json @@ -0,0 +1,22 @@ +{ + "name": "code-agent-webapp", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^6.0.0", + "vite": "^8.2.1" + }, + "allowScripts": { + "esbuild@0.21.5": true + } +} diff --git a/code_agent/ui/webapp/src/App.jsx b/code_agent/ui/webapp/src/App.jsx new file mode 100644 index 0000000..7981b1b --- /dev/null +++ b/code_agent/ui/webapp/src/App.jsx @@ -0,0 +1,142 @@ +import React, {useEffect, useState} from "react"; + +/** + * Code Agent Web UI main view. + * + * Lists the registered capabilities, lets the user pick one, enter JSON + * params, and invoke it. The server returns the invocation response plus + * the audited receipt, both rendered here. + */ +export default function App() { + const [capabilities, setCapabilities] = useState([]); + const [loadError, setLoadError] = useState(null); + const [selectedId, setSelectedId] = useState(""); + const [paramsText, setParamsText] = useState("{}"); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [invoking, setInvoking] = useState(false); + + useEffect(() => { + fetch("/capabilities") + .then((res) => { + if (!res.ok) { + throw new Error(`GET /capabilities failed with status ${res.status}`); + } + return res.json(); + }) + .then((data) => { + setCapabilities(data); + if (data.length > 0) { + setSelectedId(data[0].id); + } + }) + .catch((err) => setLoadError(String(err.message || err))); + }, []); + + const selected = capabilities.find((c) => c.id === selectedId); + + const invoke = async () => { + setInvoking(true); + setError(null); + setResult(null); + let params; + try { + params = JSON.parse(paramsText || "{}"); + } catch (err) { + setError(`Params are not valid JSON: ${err.message}`); + setInvoking(false); + return; + } + try { + const res = await fetch("/invoke", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({capability_id: selectedId, params}), + }); + const body = await res.json(); + if (!res.ok) { + throw new Error( + (body && body.detail) || `POST /invoke failed with status ${res.status}` + ); + } + setResult(body); + } catch (err) { + setError(String(err.message || err)); + } finally { + setInvoking(false); + } + }; + + return ( +
+

Code Agent Web UI

+ + {loadError &&

Failed to load capabilities: {loadError}

} + +
+

Capabilities

+ {capabilities.length === 0 && !loadError ? ( +

Loading capabilities...

+ ) : ( +
    + {capabilities.map((c) => ( +
  • + +
  • + ))} +
+ )} +
+ + {selected && ( +
+

Invoke: {selected.id}

+

+ Risk class: {selected.risk_class ?? "n/a"} . Input schema:{" "} + {selected.input_schema ? JSON.stringify(selected.input_schema) : "{}"} +

+ +
+