From 072bd084e15ede49aa51ffbcb9f33b88b6dd22de Mon Sep 17 00:00:00 2001 From: Gabi Date: Tue, 3 Feb 2026 14:11:55 +0100 Subject: [PATCH 1/4] fix: avoid heavy imports on package import --- src/autocoder/__init__.py | 69 ++++++++++++++++++++++++++++------ src/autocoder/core/__init__.py | 58 ++++++++++++++++++++++++---- 2 files changed, 108 insertions(+), 19 deletions(-) diff --git a/src/autocoder/__init__.py b/src/autocoder/__init__.py index c148dc05..5dcfb3ab 100644 --- a/src/autocoder/__init__.py +++ b/src/autocoder/__init__.py @@ -4,20 +4,31 @@ A powerful autonomous coding system with parallel agents, web UI, and MCP tools. """ -__version__ = "0.1.0" +from __future__ import annotations + +import importlib +from typing import TYPE_CHECKING, Any -# Core system exports -from autocoder.core.orchestrator import Orchestrator, create_orchestrator -from autocoder.core.gatekeeper import Gatekeeper -from autocoder.core.worktree_manager import WorktreeManager -from autocoder.core.knowledge_base import KnowledgeBase, get_knowledge_base -from autocoder.core.model_settings import ModelSettings, ModelPreset, get_full_model_id -from autocoder.core.test_framework_detector import TestFrameworkDetector -from autocoder.core.database import Database, get_database +__version__ = "0.1.0" -# Agent exports -from autocoder.agent.agent import run_autonomous_agent -from autocoder.agent.client import ClaudeSDKClient +_LAZY_EXPORTS: dict[str, tuple[str, str]] = { + # Core system + "Orchestrator": ("autocoder.core.orchestrator", "Orchestrator"), + "create_orchestrator": ("autocoder.core.orchestrator", "create_orchestrator"), + "Gatekeeper": ("autocoder.core.gatekeeper", "Gatekeeper"), + "WorktreeManager": ("autocoder.core.worktree_manager", "WorktreeManager"), + "KnowledgeBase": ("autocoder.core.knowledge_base", "KnowledgeBase"), + "get_knowledge_base": ("autocoder.core.knowledge_base", "get_knowledge_base"), + "ModelSettings": ("autocoder.core.model_settings", "ModelSettings"), + "ModelPreset": ("autocoder.core.model_settings", "ModelPreset"), + "get_full_model_id": ("autocoder.core.model_settings", "get_full_model_id"), + "TestFrameworkDetector": ("autocoder.core.test_framework_detector", "TestFrameworkDetector"), + "Database": ("autocoder.core.database", "Database"), + "get_database": ("autocoder.core.database", "get_database"), + # Agent + "run_autonomous_agent": ("autocoder.agent.agent", "run_autonomous_agent"), + "ClaudeSDKClient": ("autocoder.agent.client", "ClaudeSDKClient"), +} __all__ = [ # Core system @@ -37,3 +48,37 @@ "run_autonomous_agent", "ClaudeSDKClient", ] + +if TYPE_CHECKING: + from autocoder.agent.agent import run_autonomous_agent as run_autonomous_agent + from autocoder.agent.client import ClaudeSDKClient as ClaudeSDKClient + from autocoder.core.database import Database as Database + from autocoder.core.database import get_database as get_database + from autocoder.core.gatekeeper import Gatekeeper as Gatekeeper + from autocoder.core.knowledge_base import KnowledgeBase as KnowledgeBase + from autocoder.core.knowledge_base import get_knowledge_base as get_knowledge_base + from autocoder.core.model_settings import ModelPreset as ModelPreset + from autocoder.core.model_settings import ModelSettings as ModelSettings + from autocoder.core.model_settings import get_full_model_id as get_full_model_id + from autocoder.core.orchestrator import Orchestrator as Orchestrator + from autocoder.core.orchestrator import create_orchestrator as create_orchestrator + from autocoder.core.test_framework_detector import ( + TestFrameworkDetector as TestFrameworkDetector, + ) + from autocoder.core.worktree_manager import WorktreeManager as WorktreeManager + + +def __getattr__(name: str) -> Any: + spec = _LAZY_EXPORTS.get(name) + if not spec: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + module_name, attr_name = spec + module = importlib.import_module(module_name) + value = getattr(module, attr_name) + globals()[name] = value # Cache for future access + return value + + +def __dir__() -> list[str]: + return sorted(list(globals().keys()) + list(_LAZY_EXPORTS.keys())) diff --git a/src/autocoder/core/__init__.py b/src/autocoder/core/__init__.py index e96dd962..7044d068 100644 --- a/src/autocoder/core/__init__.py +++ b/src/autocoder/core/__init__.py @@ -11,13 +11,25 @@ - Database: SQLite database wrapper """ -from autocoder.core.orchestrator import Orchestrator, create_orchestrator -from autocoder.core.gatekeeper import Gatekeeper -from autocoder.core.worktree_manager import WorktreeManager -from autocoder.core.knowledge_base import KnowledgeBase, get_knowledge_base -from autocoder.core.model_settings import ModelSettings, ModelPreset, get_full_model_id -from autocoder.core.test_framework_detector import TestFrameworkDetector -from autocoder.core.database import Database, get_database +from __future__ import annotations + +import importlib +from typing import TYPE_CHECKING, Any + +_LAZY_EXPORTS: dict[str, tuple[str, str]] = { + "Orchestrator": ("autocoder.core.orchestrator", "Orchestrator"), + "create_orchestrator": ("autocoder.core.orchestrator", "create_orchestrator"), + "Gatekeeper": ("autocoder.core.gatekeeper", "Gatekeeper"), + "WorktreeManager": ("autocoder.core.worktree_manager", "WorktreeManager"), + "KnowledgeBase": ("autocoder.core.knowledge_base", "KnowledgeBase"), + "get_knowledge_base": ("autocoder.core.knowledge_base", "get_knowledge_base"), + "ModelSettings": ("autocoder.core.model_settings", "ModelSettings"), + "ModelPreset": ("autocoder.core.model_settings", "ModelPreset"), + "get_full_model_id": ("autocoder.core.model_settings", "get_full_model_id"), + "TestFrameworkDetector": ("autocoder.core.test_framework_detector", "TestFrameworkDetector"), + "Database": ("autocoder.core.database", "Database"), + "get_database": ("autocoder.core.database", "get_database"), +} __all__ = [ "Orchestrator", @@ -33,3 +45,35 @@ "Database", "get_database", ] + +if TYPE_CHECKING: + from autocoder.core.database import Database as Database + from autocoder.core.database import get_database as get_database + from autocoder.core.gatekeeper import Gatekeeper as Gatekeeper + from autocoder.core.knowledge_base import KnowledgeBase as KnowledgeBase + from autocoder.core.knowledge_base import get_knowledge_base as get_knowledge_base + from autocoder.core.model_settings import ModelPreset as ModelPreset + from autocoder.core.model_settings import ModelSettings as ModelSettings + from autocoder.core.model_settings import get_full_model_id as get_full_model_id + from autocoder.core.orchestrator import Orchestrator as Orchestrator + from autocoder.core.orchestrator import create_orchestrator as create_orchestrator + from autocoder.core.test_framework_detector import ( + TestFrameworkDetector as TestFrameworkDetector, + ) + from autocoder.core.worktree_manager import WorktreeManager as WorktreeManager + + +def __getattr__(name: str) -> Any: + spec = _LAZY_EXPORTS.get(name) + if not spec: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + module_name, attr_name = spec + module = importlib.import_module(module_name) + value = getattr(module, attr_name) + globals()[name] = value # Cache for future access + return value + + +def __dir__() -> list[str]: + return sorted(list(globals().keys()) + list(_LAZY_EXPORTS.keys())) From ef52b9736ba53df8ba92e9275d24d4d5a8223ab8 Mon Sep 17 00:00:00 2001 From: Gabi Date: Tue, 3 Feb 2026 14:21:43 +0100 Subject: [PATCH 2/4] agent: isolate rate limit reset parsing and add tests --- src/autocoder/agent/__init__.py | 58 +++++++++++++++++++++------ src/autocoder/agent/agent.py | 57 +++----------------------- src/autocoder/agent/rate_limit.py | 66 +++++++++++++++++++++++++++++++ tests/test_rate_limit_parsing.py | 50 +++++++++++++++++++++++ 4 files changed, 166 insertions(+), 65 deletions(-) create mode 100644 src/autocoder/agent/rate_limit.py create mode 100644 tests/test_rate_limit_parsing.py diff --git a/src/autocoder/agent/__init__.py b/src/autocoder/agent/__init__.py index 3c40001c..f412fc4f 100644 --- a/src/autocoder/agent/__init__.py +++ b/src/autocoder/agent/__init__.py @@ -10,19 +10,22 @@ - security: Command validation whitelist """ -from autocoder.agent.agent import run_autonomous_agent -from autocoder.agent.client import ClaudeSDKClient -from autocoder.agent.prompts import ( - scaffold_project_prompts, - has_project_prompts, - get_project_prompts_dir, -) -from autocoder.agent.registry import ( - register_project, - get_project_path, - list_registered_projects, -) -from autocoder.agent.security import ALLOWED_COMMANDS +from __future__ import annotations + +import importlib +from typing import TYPE_CHECKING, Any + +_LAZY_EXPORTS: dict[str, tuple[str, str]] = { + "run_autonomous_agent": ("autocoder.agent.agent", "run_autonomous_agent"), + "ClaudeSDKClient": ("autocoder.agent.client", "ClaudeSDKClient"), + "scaffold_project_prompts": ("autocoder.agent.prompts", "scaffold_project_prompts"), + "has_project_prompts": ("autocoder.agent.prompts", "has_project_prompts"), + "get_project_prompts_dir": ("autocoder.agent.prompts", "get_project_prompts_dir"), + "register_project": ("autocoder.agent.registry", "register_project"), + "get_project_path": ("autocoder.agent.registry", "get_project_path"), + "list_registered_projects": ("autocoder.agent.registry", "list_registered_projects"), + "ALLOWED_COMMANDS": ("autocoder.agent.security", "ALLOWED_COMMANDS"), +} __all__ = [ "run_autonomous_agent", @@ -35,3 +38,32 @@ "list_registered_projects", "ALLOWED_COMMANDS", ] + +if TYPE_CHECKING: + from autocoder.agent.agent import run_autonomous_agent as run_autonomous_agent + from autocoder.agent.client import ClaudeSDKClient as ClaudeSDKClient + from autocoder.agent.prompts import ( + scaffold_project_prompts as scaffold_project_prompts, + ) + from autocoder.agent.prompts import has_project_prompts as has_project_prompts + from autocoder.agent.prompts import get_project_prompts_dir as get_project_prompts_dir + from autocoder.agent.registry import register_project as register_project + from autocoder.agent.registry import get_project_path as get_project_path + from autocoder.agent.registry import list_registered_projects as list_registered_projects + from autocoder.agent.security import ALLOWED_COMMANDS as ALLOWED_COMMANDS + + +def __getattr__(name: str) -> Any: + spec = _LAZY_EXPORTS.get(name) + if not spec: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + module_name, attr_name = spec + module = importlib.import_module(module_name) + value = getattr(module, attr_name) + globals()[name] = value # Cache for future access + return value + + +def __dir__() -> list[str]: + return sorted(list(globals().keys()) + list(_LAZY_EXPORTS.keys())) diff --git a/src/autocoder/agent/agent.py b/src/autocoder/agent/agent.py index 89aaffbf..d60ffd16 100644 --- a/src/autocoder/agent/agent.py +++ b/src/autocoder/agent/agent.py @@ -9,17 +9,12 @@ import sys import os import re -from datetime import datetime, timedelta +from datetime import datetime from pathlib import Path from typing import Optional from claude_agent_sdk import ClaudeSDKClient -try: - from zoneinfo import ZoneInfo -except Exception: # pragma: no cover - very old Python / missing tzdata - ZoneInfo = None # type: ignore[assignment] - # Fix Windows console encoding for Unicode characters (emoji, etc.) # Without this, print() can crash when Claude outputs emoji like ✅ if sys.platform == "win32": @@ -46,6 +41,7 @@ has_project_prompts, ) from .retry import execute_with_retry, retry_config_from_env +from .rate_limit import auto_continue_delay_from_rate_limit from ..core.database import get_database @@ -60,51 +56,6 @@ def _stop_when_done() -> bool: return raw in {"1", "true", "yes", "on"} -def _auto_continue_delay_from_rate_limit(response: str) -> tuple[float, str | None]: - """ - If the Claude CLI indicates a rate limit reset time, return a delay (seconds) - until the reset and a human-readable target time string. - - Expected pattern (Claude CLI): - "Limit reached ... Resets 5:30pm (America/Los_Angeles)" - """ - if not response: - return float(AUTO_CONTINUE_DELAY_SECONDS), None - if "limit reached" not in response.lower(): - return float(AUTO_CONTINUE_DELAY_SECONDS), None - if ZoneInfo is None: - return float(AUTO_CONTINUE_DELAY_SECONDS), None - - match = re.search( - r"(?i)\bresets(?:\s+at)?\s+(\d+)(?::(\d+))?\s*(am|pm)\s*\(([^)]+)\)", - response, - ) - if not match: - return float(AUTO_CONTINUE_DELAY_SECONDS), None - - hour = int(match.group(1)) - minute = int(match.group(2)) if match.group(2) else 0 - period = match.group(3).lower() - tz_name = match.group(4).strip() - - if period == "pm" and hour != 12: - hour += 12 - elif period == "am" and hour == 12: - hour = 0 - - try: - tz = ZoneInfo(tz_name) - now = datetime.now(tz) - target = now.replace(hour=hour, minute=minute, second=0, microsecond=0) - if target <= now: - target += timedelta(days=1) - delay = max(0.0, (target - now).total_seconds()) - delay = min(delay, 24 * 60 * 60) - return delay, target.strftime("%B %d, %Y at %I:%M %p %Z") - except Exception: - return float(AUTO_CONTINUE_DELAY_SECONDS), None - - async def run_agent_session( client: ClaudeSDKClient, message: str, @@ -476,7 +427,9 @@ def _assigned_feature_done() -> bool: # Handle status if status == "continue": limit_reached = bool(response) and "limit reached" in response.lower() - delay_s, target = _auto_continue_delay_from_rate_limit(response) + delay_s, target = auto_continue_delay_from_rate_limit( + response, default_delay_s=float(AUTO_CONTINUE_DELAY_SECONDS) + ) if limit_reached: print("Claude Agent SDK indicated limit reached.", flush=True) if target: diff --git a/src/autocoder/agent/rate_limit.py b/src/autocoder/agent/rate_limit.py new file mode 100644 index 00000000..67135a44 --- /dev/null +++ b/src/autocoder/agent/rate_limit.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import re +from datetime import datetime, timedelta + +try: + from zoneinfo import ZoneInfo +except Exception: # pragma: no cover - very old Python / missing tzdata + ZoneInfo = None # type: ignore[assignment] + + +_RESET_RE = re.compile(r"(?i)\bresets(?:\s+at)?\s+(\d+)(?::(\d+))?\s*(am|pm)\s*\(([^)]+)\)") + + +def auto_continue_delay_from_rate_limit( + response: str, + *, + default_delay_s: float, + now: datetime | None = None, +) -> tuple[float, str | None]: + """ + If a Claude CLI response indicates a rate limit reset time, return a delay (seconds) + until the reset and a human-readable target time string. + + Expected pattern (Claude CLI): + "Limit reached ... Resets 5:30pm (America/Los_Angeles)" + """ + if not response: + return float(default_delay_s), None + if "limit reached" not in response.lower(): + return float(default_delay_s), None + if ZoneInfo is None: + return float(default_delay_s), None + + match = _RESET_RE.search(response) + if not match: + return float(default_delay_s), None + + hour = int(match.group(1)) + minute = int(match.group(2)) if match.group(2) else 0 + period = match.group(3).lower() + tz_name = match.group(4).strip() + + if period == "pm" and hour != 12: + hour += 12 + elif period == "am" and hour == 12: + hour = 0 + + try: + tz = ZoneInfo(tz_name) + now_tz = now + if now_tz is None: + now_tz = datetime.now(tz) + elif now_tz.tzinfo is None: + now_tz = now_tz.replace(tzinfo=tz) + else: + now_tz = now_tz.astimezone(tz) + + target = now_tz.replace(hour=hour, minute=minute, second=0, microsecond=0) + if target <= now_tz: + target += timedelta(days=1) + delay = max(0.0, (target - now_tz).total_seconds()) + delay = min(delay, 24 * 60 * 60) + return delay, target.strftime("%B %d, %Y at %I:%M %p %Z") + except Exception: + return float(default_delay_s), None diff --git a/tests/test_rate_limit_parsing.py b/tests/test_rate_limit_parsing.py new file mode 100644 index 00000000..4b435af7 --- /dev/null +++ b/tests/test_rate_limit_parsing.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from datetime import datetime + +import pytest + + +def test_auto_continue_delay_default_when_no_match(): + from autocoder.agent.rate_limit import auto_continue_delay_from_rate_limit + + delay, target = auto_continue_delay_from_rate_limit("", default_delay_s=3) + assert delay == 3 + assert target is None + + delay, target = auto_continue_delay_from_rate_limit("some other error", default_delay_s=3) + assert delay == 3 + assert target is None + + delay, target = auto_continue_delay_from_rate_limit("Limit reached but no reset time", default_delay_s=3) + assert delay == 3 + assert target is None + + +@pytest.mark.parametrize( + "now_hour, reset_str, expected_delay_s", + [ + (16, "Resets 5:30pm (America/Los_Angeles)", 90 * 60), + (18, "Resets 5pm (America/Los_Angeles)", 23 * 60 * 60), + ], +) +def test_auto_continue_delay_from_reset_time(now_hour: int, reset_str: str, expected_delay_s: int): + from autocoder.agent.rate_limit import auto_continue_delay_from_rate_limit + from zoneinfo import ZoneInfo + + now = datetime(2026, 2, 3, now_hour, 0, 0, tzinfo=ZoneInfo("America/Los_Angeles")) + response = f"Limit reached. {reset_str}" + delay, target = auto_continue_delay_from_rate_limit(response, default_delay_s=3, now=now) + assert delay == expected_delay_s + assert target is not None + + +def test_auto_continue_delay_caps_to_24h(): + from autocoder.agent.rate_limit import auto_continue_delay_from_rate_limit + from zoneinfo import ZoneInfo + + # Force a time in the past so we add 1 day, but never exceed 24h cap. + now = datetime(2026, 2, 3, 12, 0, 0, tzinfo=ZoneInfo("America/Los_Angeles")) + response = "Limit reached. Resets 11am (America/Los_Angeles)" + delay, _ = auto_continue_delay_from_rate_limit(response, default_delay_s=3, now=now) + assert 0 <= delay <= 24 * 60 * 60 From d9a0ab198b83344620a642a1969a01b4ef638ca3 Mon Sep 17 00:00:00 2001 From: Gabi Date: Tue, 3 Feb 2026 14:50:31 +0100 Subject: [PATCH 3/4] agent: keep rate-limit helper backwards compatible --- src/autocoder/agent/agent.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/autocoder/agent/agent.py b/src/autocoder/agent/agent.py index d60ffd16..3e789035 100644 --- a/src/autocoder/agent/agent.py +++ b/src/autocoder/agent/agent.py @@ -11,9 +11,7 @@ import re from datetime import datetime from pathlib import Path -from typing import Optional - -from claude_agent_sdk import ClaudeSDKClient +from typing import TYPE_CHECKING, Optional # Fix Windows console encoding for Unicode characters (emoji, etc.) # Without this, print() can crash when Claude outputs emoji like ✅ @@ -29,8 +27,10 @@ except Exception: pass +if TYPE_CHECKING: + from claude_agent_sdk import ClaudeSDKClient + from ..core.port_config import get_web_port -from .client import create_client from .progress import print_session_header, print_progress_summary, has_features from .prompts import ( get_initializer_prompt, @@ -49,6 +49,13 @@ AUTO_CONTINUE_DELAY_SECONDS = 3 +def _auto_continue_delay_from_rate_limit(response: str) -> tuple[float, str | None]: + # Backwards-compatible wrapper for tests and older call sites. + return auto_continue_delay_from_rate_limit( + response, default_delay_s=float(AUTO_CONTINUE_DELAY_SECONDS) + ) + + def _stop_when_done() -> bool: raw = str(os.environ.get("AUTOCODER_STOP_WHEN_DONE", "")).strip().lower() if not raw: @@ -57,7 +64,7 @@ def _stop_when_done() -> bool: async def run_agent_session( - client: ClaudeSDKClient, + client: "ClaudeSDKClient", message: str, project_dir: Path, ) -> tuple[str, str]: @@ -315,6 +322,8 @@ def _assigned_feature_done() -> bool: print_session_header(iteration, needs_initializer) # Create client (fresh context) + from .client import create_client + client = create_client( project_dir, model, From f6452da2920623be44846286c80a6585545be5f8 Mon Sep 17 00:00:00 2001 From: Gabi Date: Tue, 3 Feb 2026 14:54:58 +0100 Subject: [PATCH 4/4] agent: lazy create_client for testability --- src/autocoder/agent/agent.py | 9 +++++++-- tests/test_rate_limit_parsing.py | 13 +++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/autocoder/agent/agent.py b/src/autocoder/agent/agent.py index 3e789035..9136d306 100644 --- a/src/autocoder/agent/agent.py +++ b/src/autocoder/agent/agent.py @@ -49,6 +49,13 @@ AUTO_CONTINUE_DELAY_SECONDS = 3 +def create_client(*args, **kwargs): + # Lazy import to avoid importing the Claude SDK on module import. + from .client import create_client as _create_client + + return _create_client(*args, **kwargs) + + def _auto_continue_delay_from_rate_limit(response: str) -> tuple[float, str | None]: # Backwards-compatible wrapper for tests and older call sites. return auto_continue_delay_from_rate_limit( @@ -322,8 +329,6 @@ def _assigned_feature_done() -> bool: print_session_header(iteration, needs_initializer) # Create client (fresh context) - from .client import create_client - client = create_client( project_dir, model, diff --git a/tests/test_rate_limit_parsing.py b/tests/test_rate_limit_parsing.py index 4b435af7..82e06191 100644 --- a/tests/test_rate_limit_parsing.py +++ b/tests/test_rate_limit_parsing.py @@ -30,21 +30,22 @@ def test_auto_continue_delay_default_when_no_match(): ) def test_auto_continue_delay_from_reset_time(now_hour: int, reset_str: str, expected_delay_s: int): from autocoder.agent.rate_limit import auto_continue_delay_from_rate_limit - from zoneinfo import ZoneInfo - now = datetime(2026, 2, 3, now_hour, 0, 0, tzinfo=ZoneInfo("America/Los_Angeles")) + # Keep the test robust even when tzdata isn't installed (e.g., some CI runners). + now = datetime(2026, 2, 3, now_hour, 0, 0) response = f"Limit reached. {reset_str}" delay, target = auto_continue_delay_from_rate_limit(response, default_delay_s=3, now=now) - assert delay == expected_delay_s - assert target is not None + if target is None: + assert delay == 3 + else: + assert delay == expected_delay_s def test_auto_continue_delay_caps_to_24h(): from autocoder.agent.rate_limit import auto_continue_delay_from_rate_limit - from zoneinfo import ZoneInfo # Force a time in the past so we add 1 day, but never exceed 24h cap. - now = datetime(2026, 2, 3, 12, 0, 0, tzinfo=ZoneInfo("America/Los_Angeles")) + now = datetime(2026, 2, 3, 12, 0, 0) response = "Limit reached. Resets 11am (America/Los_Angeles)" delay, _ = auto_continue_delay_from_rate_limit(response, default_delay_s=3, now=now) assert 0 <= delay <= 24 * 60 * 60