Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,40 @@ name: CI

on:
push:
branches: ["main", "master"]
branches: ["main"]
pull_request:
branches: ["main", "master"]
branches: ["main"]

permissions:
contents: read

jobs:
test-lint-typecheck:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7

- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
Comment on lines +18 to 22
python-version: "3.11"
cache: "pip"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install ruff black mypy
pip install -r requirements-dev.txt

- name: Ruff (lint + auto-fix)
run: |
ruff check . --fix
ruff check .
- name: Ruff
run: ruff check .

- name: Black (format check)
run: black --check .

- name: MyPy (type check, non-blocking for now)
run: mypy . || true
- name: MyPy
run: mypy .

- name: Pytest
run: pytest -v --tb=short
27 changes: 17 additions & 10 deletions agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,31 @@

import random
import time
from typing import Annotated, TypedDict, Iterator
from typing import Annotated, TypedDict

from dotenv import load_dotenv
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage, BaseMessage
from langgraph.graph import StateGraph, END
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langgraph.graph import END, StateGraph
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode

from config import config
from context import build_system_prompt
from tools import ALL_TOOLS
from cost_tracker import track_call, init_tracker, register_exit_hook
from cost_tracker import init_tracker, register_exit_hook, track_call
from exceptions import ConfigError, RetryExhaustedError
from logger import get_logger
from metrics import record_request
from rate_limiter import acquire_or_raise
from task import (
TaskType, TaskStatus,
create_task, complete_task, fail_task, get_duration,
TaskStatus,
TaskType,
complete_task,
create_task,
fail_task,
get_duration,
)
from tools import ALL_TOOLS

load_dotenv()
log = get_logger("agent")
Expand Down Expand Up @@ -221,7 +225,7 @@ def chat_stream(self, user_input, history=None):
log.debug("Tool call detected in stream, switching to graph")
response = self.chat(user_input, history)
if collected_text and response.startswith(collected_text):
yield response[len(collected_text):]
yield response[len(collected_text) :]
else:
yield response
success = True
Expand Down Expand Up @@ -264,7 +268,8 @@ def _extract_text(messages):
if isinstance(msg, AIMessage) and msg.content:
if isinstance(msg.content, list):
parts = [
p["text"] for p in msg.content
p["text"]
for p in msg.content
if isinstance(p, dict) and p.get("type") == "text"
]
if parts:
Expand All @@ -289,7 +294,9 @@ def get_task_summary(self):
if not self.task_history:
return "No tasks have run yet."

completed = sum(1 for t in self.task_history if t.status == TaskStatus.COMPLETED)
completed = sum(
1 for t in self.task_history if t.status == TaskStatus.COMPLETED
)
failed = sum(1 for t in self.task_history if t.status == TaskStatus.FAILED)
total = len(self.task_history)

Expand Down
146 changes: 119 additions & 27 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import os
from dataclasses import dataclass, field

from dotenv import load_dotenv

load_dotenv()
Expand Down Expand Up @@ -34,75 +35,163 @@ def _env_float(name: str, default: float) -> float:
@dataclass(frozen=True)
class ModelConfig:
"""LLM model settings."""
name: str = field(default_factory=lambda: os.getenv("DEVMIND_MODEL", "claude-sonnet-4-6"))
max_tokens: int = field(default_factory=lambda: _env_int("DEVMIND_MAX_TOKENS", 4096))

name: str = field(
default_factory=lambda: os.getenv("DEVMIND_MODEL", "claude-sonnet-4-6")
)
max_tokens: int = field(
default_factory=lambda: _env_int("DEVMIND_MAX_TOKENS", 4096)
)
streaming: bool = True


@dataclass(frozen=True)
class RetryConfig:
"""Retry logic settings with exponential backoff + jitter."""

max_retries: int = field(default_factory=lambda: _env_int("DEVMIND_MAX_RETRIES", 4))
base_delay: float = field(default_factory=lambda: _env_float("DEVMIND_RETRY_BASE_DELAY", 1.0))
max_delay: float = field(default_factory=lambda: _env_float("DEVMIND_RETRY_MAX_DELAY", 30.0))
base_delay: float = field(
default_factory=lambda: _env_float("DEVMIND_RETRY_BASE_DELAY", 1.0)
)
max_delay: float = field(
default_factory=lambda: _env_float("DEVMIND_RETRY_MAX_DELAY", 30.0)
)
backoff_factor: float = 2.0
jitter: float = 0.25
retryable_codes: tuple = (
"529", "overloaded", "rate_limit",
"timeout", "connection", "502", "503", "504",
"ECONNRESET", "Temporary failure",
"529",
"overloaded",
"rate_limit",
"timeout",
"connection",
"502",
"503",
"504",
"ECONNRESET",
"Temporary failure",
)


@dataclass(frozen=True)
class RateLimitConfig:
"""Client-side rate limiting (token bucket)."""

enabled: bool = field(default_factory=lambda: _env_bool("DEVMIND_RATE_LIMIT", True))
requests_per_minute: int = field(default_factory=lambda: _env_int("DEVMIND_RPM", 50))
requests_per_minute: int = field(
default_factory=lambda: _env_int("DEVMIND_RPM", 50)
)
burst: int = field(default_factory=lambda: _env_int("DEVMIND_BURST", 10))


@dataclass(frozen=True)
class ToolConfig:
"""Tool execution settings."""
bash_timeout: int = field(default_factory=lambda: _env_int("DEVMIND_BASH_TIMEOUT", 30))

bash_timeout: int = field(
default_factory=lambda: _env_int("DEVMIND_BASH_TIMEOUT", 30)
)
file_read_max_lines: int = 200
file_write_max_bytes: int = 5 * 1024 * 1024
file_read_max_bytes: int = 2 * 1024 * 1024
grep_max_results: int = 20
grep_max_files: int = 5000
blocked_commands: tuple = (
"rm -rf /", "rm -rf /*", "rm -rf ~", "rm -rf ~/",
"mkfs", "dd if=", "dd of=/dev/",
":(){", "fork bomb", "chmod -R 777 /",
"wget ", "curl ",
"shutdown", "reboot", "halt", "poweroff",
"> /dev/sda", "mv / ",
"rm -rf /",
"rm -rf /*",
"rm -rf ~",
"rm -rf ~/",
"mkfs",
"dd if=",
"dd of=/dev/",
":(){",
"fork bomb",
"chmod -R 777 /",
"wget ",
"curl ",
"shutdown",
"reboot",
"halt",
"poweroff",
"> /dev/sda",
"mv / ",
)
text_extensions: frozenset = frozenset(
{
".py",
".js",
".ts",
".jsx",
".tsx",
".java",
".c",
".cpp",
".h",
".cs",
".go",
".rs",
".rb",
".php",
".swift",
".kt",
".scala",
".html",
".css",
".scss",
".less",
".xml",
".json",
".yaml",
".yml",
".toml",
".ini",
".cfg",
".conf",
".env",
".sh",
".bash",
".zsh",
".md",
".txt",
".rst",
".csv",
".sql",
".r",
".R",
".lua",
".dockerfile",
".makefile",
".gitignore",
".editorconfig",
}
)
skip_dirs: frozenset = frozenset(
{
".git",
"__pycache__",
"node_modules",
".venv",
"venv",
".mypy_cache",
".pytest_cache",
"dist",
"build",
".tox",
}
)
text_extensions: frozenset = frozenset({
".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".c", ".cpp", ".h",
".cs", ".go", ".rs", ".rb", ".php", ".swift", ".kt", ".scala",
".html", ".css", ".scss", ".less", ".xml", ".json", ".yaml", ".yml",
".toml", ".ini", ".cfg", ".conf", ".env", ".sh", ".bash", ".zsh",
".md", ".txt", ".rst", ".csv", ".sql", ".r", ".R", ".lua",
".dockerfile", ".makefile", ".gitignore", ".editorconfig",
})
skip_dirs: frozenset = frozenset({
".git", "__pycache__", "node_modules", ".venv", "venv",
".mypy_cache", ".pytest_cache", "dist", "build", ".tox",
})


@dataclass(frozen=True)
class HistoryConfig:
"""Conversation history settings."""

max_messages: int = 20
summary_threshold: int = 16


@dataclass(frozen=True)
class PersistenceConfig:
"""Conversation save/load settings."""

enabled: bool = field(default_factory=lambda: _env_bool("DEVMIND_PERSIST", True))
sessions_dir: str = field(
default_factory=lambda: os.getenv(
Expand All @@ -116,13 +205,15 @@ class PersistenceConfig:
@dataclass(frozen=True)
class MetricsConfig:
"""Performance metrics tracking."""

enabled: bool = field(default_factory=lambda: _env_bool("DEVMIND_METRICS", True))
persist_on_exit: bool = True


@dataclass(frozen=True)
class PluginConfig:
"""Plugin/extension system settings."""

enabled: bool = field(default_factory=lambda: _env_bool("DEVMIND_PLUGINS", True))
plugins_dir: str = field(
default_factory=lambda: os.getenv(
Expand All @@ -135,6 +226,7 @@ class PluginConfig:
@dataclass(frozen=True)
class AppConfig:
"""Master application configuration."""

model: ModelConfig = field(default_factory=ModelConfig)
retry: RetryConfig = field(default_factory=RetryConfig)
rate_limit: RateLimitConfig = field(default_factory=RateLimitConfig)
Expand Down
10 changes: 6 additions & 4 deletions context.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Optional

from logger import log

Expand All @@ -37,7 +36,7 @@ def _run_git(args: list[str]) -> str:
return ""


def get_git_status() -> Optional[str]:
def get_git_status() -> str | None:
"""
Return a summary of the current git repository state.
(Inspired by context.ts: getGitStatus)
Expand Down Expand Up @@ -74,7 +73,7 @@ def get_git_status() -> Optional[str]:
# CLAUDE.md — Project-specific instructions
# (Inspired by context.ts: getClaudeMds pattern)
# ============================================================
def get_claude_md() -> Optional[str]:
def get_claude_md() -> str | None:
"""
Read CLAUDE.md if it exists.
This file contains project-specific instructions for Claude.
Expand Down Expand Up @@ -138,7 +137,10 @@ def build_system_prompt() -> str:
"- bash_tool: Run terminal commands (git, python, etc.)",
"- file_read_tool: Read file contents",
"- file_write_tool: Create new files or fully overwrite existing ones",
"- file_edit_tool: Find and replace specific text in an existing file (bug fixes, code updates)",
(
"- file_edit_tool: Find and replace specific text in an existing file "
"(bug fixes, code updates)"
),
"- grep_tool: Search for patterns across the codebase",
"- list_files_tool: List files in a directory",
"",
Expand Down
Loading