diff --git a/askcc/cli.py b/askcc/cli.py index 7d2e145..dfc9c0a 100644 --- a/askcc/cli.py +++ b/askcc/cli.py @@ -6,7 +6,7 @@ from pathlib import Path from string import Template -from . import __version__ +from . import __version__, settings from .definitions import AgentAction, AgentConfig, SupportedLanguage from .functions import ( CheckResult, @@ -26,7 +26,7 @@ write_prompt_content, ) from .runners import DEFAULT_RUNNER, RUNNER_REGISTRY, get_runner -from .settings import configure_logging +from .settings import VALID_EFFORT_LEVELS, configure_logging logger = logging.getLogger(__name__) @@ -105,6 +105,35 @@ def main() -> None: # noqa: PLR0912, PLR0915, C901 default=DEFAULT_RUNNER, help=f"Runner to execute the task (default: {DEFAULT_RUNNER}).", ) + parser.add_argument( + "--effort", + choices=VALID_EFFORT_LEVELS, + default=settings.ASKCC_CLAUDE_EFFORT_LEVEL, + help=f"Claude thinking effort level (default: {settings.ASKCC_CLAUDE_EFFORT_LEVEL}). " + "Env: ASKCC_CLAUDE_EFFORT_LEVEL.", + ) + parser.add_argument( + "--max-thinking-tokens", + type=int, + default=settings.ASKCC_CLAUDE_MAX_THINKING_TOKENS, + help=f"Thinking token budget (default: {settings.ASKCC_CLAUDE_MAX_THINKING_TOKENS}). " + "Env: ASKCC_CLAUDE_MAX_THINKING_TOKENS.", + ) + parser.add_argument( + "--disable-thinking", + action="store_true", + default=settings.ASKCC_CLAUDE_DISABLE_THINKING, + help=f"Force-disable extended thinking (default: {settings.ASKCC_CLAUDE_DISABLE_THINKING}). " + "Env: ASKCC_CLAUDE_DISABLE_THINKING.", + ) + parser.add_argument( + "--disable-adaptive-thinking", + action=argparse.BooleanOptionalAction, + default=settings.ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING, + help=f"Disable adaptive reasoning (Opus 4.6, Sonnet 4.6) " + f"(default: {settings.ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING}). " + "Env: ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING.", + ) subparsers = parser.add_subparsers(dest="command", required=True) @@ -222,7 +251,16 @@ def main() -> None: # noqa: PLR0912, PLR0915, C901 prompt += f"\nOutput all comments in {args.language}." runner = get_runner(args.runner) try: - return_code, usage = runner.run(prompt, config=config, issue_url=issue_url, cwd=cwd) + return_code, usage = runner.run( + prompt, + config=config, + issue_url=issue_url, + cwd=cwd, + effort_level=args.effort, + max_thinking_tokens=args.max_thinking_tokens, + disable_thinking=args.disable_thinking, + disable_adaptive_thinking=args.disable_adaptive_thinking, + ) finally: # Clean up /tmp files created by _build_prompt (not user templates) for f in prompt_tempfiles: diff --git a/askcc/runners.py b/askcc/runners.py index f3c875b..b6c3af9 100644 --- a/askcc/runners.py +++ b/askcc/runners.py @@ -7,6 +7,8 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING +from .settings import CLAUDE_ENV_DISABLE_ADAPTIVE_THINKING, CLAUDE_ENV_DISABLE_THINKING, CLAUDE_ENV_MAX_THINKING_TOKENS + if TYPE_CHECKING: from pathlib import Path @@ -28,6 +30,10 @@ def run( *, issue_url: str, cwd: Path, + effort_level: str | None = None, + max_thinking_tokens: int | None = None, + disable_thinking: bool = False, + disable_adaptive_thinking: bool = False, ) -> tuple[int, dict | None]: """Execute a prompt and return (exit_code, usage_dict_or_none).""" @@ -42,6 +48,10 @@ def run( *, issue_url: str, cwd: Path, + effort_level: str | None = None, + max_thinking_tokens: int | None = None, + disable_thinking: bool = False, + disable_adaptive_thinking: bool = False, ) -> tuple[int, dict | None]: agent_definition = {config.action_name: {"description": config.description, "prompt": config.system_prompt}} @@ -57,10 +67,20 @@ def run( json.dumps(agent_definition), ] + if effort_level: + cmd.extend(["--effort", effort_level]) + # Remove CLAUDECODE env var so the child claude process doesn't think it's nested inside Claude Code env = os.environ.copy() env.pop("CLAUDECODE", None) + if max_thinking_tokens is not None: + env[CLAUDE_ENV_MAX_THINKING_TOKENS] = str(max_thinking_tokens) + if disable_thinking: + env[CLAUDE_ENV_DISABLE_THINKING] = "1" + if disable_adaptive_thinking: + env[CLAUDE_ENV_DISABLE_ADAPTIVE_THINKING] = "1" + logger.info("[%s] Requesting '%s' from Claude Code ...", issue_url, config.action_name) logger.info("[%s] Working directory: %s", issue_url, cwd) logger.debug("[%s] Command: %s", issue_url, " ".join("" if arg is prompt else arg for arg in cmd)) diff --git a/askcc/settings.py b/askcc/settings.py index b38acf0..2629f7c 100644 --- a/askcc/settings.py +++ b/askcc/settings.py @@ -1,9 +1,12 @@ +import enum import logging import os import sys from logging.handlers import RotatingFileHandler from pathlib import Path +logger = logging.getLogger(__name__) + DEFAULT_LOG_LEVEL = "INFO" LOG_LEVEL = os.getenv("LOG_LEVEL", DEFAULT_LOG_LEVEL).upper() @@ -28,6 +31,57 @@ # Project field transition REVIEW_STATUS_OPTIONS: tuple[str, ...] = ("in-internal-review", "in-review") +# -- Claude thinking/reasoning controls -- +# NOTE: VALID_EFFORT_LEVELS lives here (not definitions.py) to avoid a circular import; +# definitions.py already imports from settings.py. + + +class VALID_EFFORT_LEVELS(enum.StrEnum): # noqa: N801 + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + MAX = "max" + + +DEFAULT_EFFORT_LEVEL = VALID_EFFORT_LEVELS.MAX + + +def _resolve_effort_level() -> VALID_EFFORT_LEVELS: + """Resolve ASKCC_CLAUDE_EFFORT_LEVEL, warning on invalid values.""" + raw = os.getenv("ASKCC_CLAUDE_EFFORT_LEVEL") or None + if raw is None: + return DEFAULT_EFFORT_LEVEL + try: + return VALID_EFFORT_LEVELS(raw) + except ValueError: + logger.warning( + "Invalid ASKCC_CLAUDE_EFFORT_LEVEL=%r (valid: %s). Ignoring.", + raw, + ", ".join(VALID_EFFORT_LEVELS), + ) + return DEFAULT_EFFORT_LEVEL + + +ASKCC_CLAUDE_EFFORT_LEVEL: VALID_EFFORT_LEVELS = _resolve_effort_level() + +DEFAULT_MAX_THINKING_TOKENS = 21000 # ~5% of Max5 plan daily token budget (~422K tokens/day) + +_raw_max_thinking = os.getenv("ASKCC_CLAUDE_MAX_THINKING_TOKENS", "") +ASKCC_CLAUDE_MAX_THINKING_TOKENS: int = ( + int(_raw_max_thinking) if _raw_max_thinking.isdigit() else DEFAULT_MAX_THINKING_TOKENS +) + +ASKCC_CLAUDE_DISABLE_THINKING: bool = os.getenv("ASKCC_CLAUDE_DISABLE_THINKING", "").lower() in ("1", "true") + +ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING: bool = os.getenv( + "ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING", "true" +).lower() not in ("0", "false") + +# Claude Code subprocess env var names +CLAUDE_ENV_MAX_THINKING_TOKENS = "MAX_THINKING_TOKENS" +CLAUDE_ENV_DISABLE_THINKING = "CLAUDE_CODE_DISABLE_THINKING" +CLAUDE_ENV_DISABLE_ADAPTIVE_THINKING = "CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING" + ASKCC_HOME: Path = Path(os.getenv("ASKCC_HOME") or str(Path.home() / ".askcc")).expanduser().resolve() TEMPLATES_DIR: Path = ASKCC_HOME / "templates" LOG_DIR: Path = ASKCC_HOME / "logs" diff --git a/pyproject.toml b/pyproject.toml index e053219..3c586bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "askcc" -version = "0.2.3" +version = "0.2.4" description = "A one-shot cc cli executor" authors = [{ name = "mknt", email = "shane.cousins@gmail.com" }] readme = "README.md" diff --git a/tests/test_askcc.py b/tests/test_askcc.py index f0acb61..2834299 100644 --- a/tests/test_askcc.py +++ b/tests/test_askcc.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import subprocess from pathlib import Path from string import Template @@ -43,6 +44,14 @@ write_prompt_content, ) from askcc.runners import ClaudeRunner, get_runner +from askcc.settings import ( + CLAUDE_ENV_DISABLE_ADAPTIVE_THINKING, + CLAUDE_ENV_DISABLE_THINKING, + CLAUDE_ENV_MAX_THINKING_TOKENS, + DEFAULT_EFFORT_LEVEL, + DEFAULT_MAX_THINKING_TOKENS, + _resolve_effort_level, +) def _mock_runner(return_code: int = 0, usage: dict | None = None) -> MagicMock: @@ -1543,3 +1552,247 @@ def test_reviewpr_no_transition_on_success(self): mock_review.assert_not_called() mock_planning.assert_not_called() + + +class TestThinkingSettings: + """Tests for ASKCC_CLAUDE_* thinking/reasoning env var settings.""" + + def test_effort_level_from_env(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ASKCC_CLAUDE_EFFORT_LEVEL", "high") + # Re-evaluate the module-level expression + result = os.getenv("ASKCC_CLAUDE_EFFORT_LEVEL") or None + assert result == "high" + + def test_effort_level_empty_string_uses_default(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ASKCC_CLAUDE_EFFORT_LEVEL", "") + result = _resolve_effort_level() + assert result == DEFAULT_EFFORT_LEVEL + + def test_effort_level_unset_uses_default(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("ASKCC_CLAUDE_EFFORT_LEVEL", raising=False) + result = _resolve_effort_level() + assert result == DEFAULT_EFFORT_LEVEL + + def test_invalid_effort_level_logged_and_ignored( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ): + monkeypatch.setenv("ASKCC_CLAUDE_EFFORT_LEVEL", "turbo") + with caplog.at_level("WARNING", logger="askcc.settings"): + result = _resolve_effort_level() + assert result == DEFAULT_EFFORT_LEVEL + assert "Invalid ASKCC_CLAUDE_EFFORT_LEVEL" in caplog.text + + def test_max_thinking_tokens_from_env(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ASKCC_CLAUDE_MAX_THINKING_TOKENS", "50000") + raw = os.getenv("ASKCC_CLAUDE_MAX_THINKING_TOKENS", "") + assert raw.isdigit() + assert int(raw) == 50000 + + def test_max_thinking_tokens_default(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("ASKCC_CLAUDE_MAX_THINKING_TOKENS", raising=False) + raw = os.getenv("ASKCC_CLAUDE_MAX_THINKING_TOKENS", "") + assert not raw.isdigit() + assert DEFAULT_MAX_THINKING_TOKENS == 21000 + + def test_disable_thinking_truthy(self, monkeypatch: pytest.MonkeyPatch): + for val in ("1", "true", "True", "TRUE"): + monkeypatch.setenv("ASKCC_CLAUDE_DISABLE_THINKING", val) + assert os.getenv("ASKCC_CLAUDE_DISABLE_THINKING", "").lower() in ("1", "true") + + def test_disable_thinking_falsy(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ASKCC_CLAUDE_DISABLE_THINKING", "0") + assert os.getenv("ASKCC_CLAUDE_DISABLE_THINKING", "").lower() not in ("1", "true") + + def test_disable_adaptive_thinking_default_true(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING", raising=False) + result = os.getenv("ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING", "true").lower() not in ("0", "false") + assert result is True + + def test_disable_adaptive_thinking_explicit_false(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING", "false") + result = os.getenv("ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING", "true").lower() not in ("0", "false") + assert result is False + + +class TestThinkingCLIFlags: + """Tests for --effort, --max-thinking-tokens, --disable-thinking, --disable-adaptive-thinking CLI flags.""" + + ISSUE_URL = "https://github.com/monkut/askcc-cli/issues/1" + + def _run_main_with_args(self, extra_args: list[str]) -> MagicMock: + mock_runner = _mock_runner() + with ( + patch("askcc.cli.bootstrap_templates"), + patch("askcc.cli.validate_issue_labels", return_value=[]), + patch("askcc.cli.fetch_github_issue", return_value="issue body"), + patch("askcc.cli.get_runner", return_value=mock_runner), + patch("sys.argv", ["askcc", *extra_args, "plan", "-g", self.ISSUE_URL]), + pytest.raises(SystemExit), + ): + main() + return mock_runner + + def test_effort_flag_passed_to_runner(self): + mock_runner = self._run_main_with_args(["--effort", "high"]) + call_kwargs = mock_runner.run.call_args + assert call_kwargs.kwargs["effort_level"] == "high" + + def test_effort_flag_invalid_rejected_by_argparse(self): + with ( + pytest.raises(SystemExit, match="2"), + patch("sys.argv", ["askcc", "--effort", "turbo", "plan", "-g", self.ISSUE_URL]), + ): + main() + + def test_max_thinking_tokens_flag_passed_to_runner(self): + mock_runner = self._run_main_with_args(["--max-thinking-tokens", "50000"]) + call_kwargs = mock_runner.run.call_args + assert call_kwargs.kwargs["max_thinking_tokens"] == 50000 + + def test_disable_thinking_flag_passed_to_runner(self): + mock_runner = self._run_main_with_args(["--disable-thinking"]) + call_kwargs = mock_runner.run.call_args + assert call_kwargs.kwargs["disable_thinking"] is True + + def test_disable_adaptive_thinking_flag_passed_to_runner(self): + mock_runner = self._run_main_with_args(["--disable-adaptive-thinking"]) + call_kwargs = mock_runner.run.call_args + assert call_kwargs.kwargs["disable_adaptive_thinking"] is True + + def test_no_disable_adaptive_thinking_flag(self): + mock_runner = self._run_main_with_args(["--no-disable-adaptive-thinking"]) + call_kwargs = mock_runner.run.call_args + assert call_kwargs.kwargs["disable_adaptive_thinking"] is False + + def test_effort_env_default_used(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("askcc.cli.settings.ASKCC_CLAUDE_EFFORT_LEVEL", "low") + mock_runner = self._run_main_with_args([]) + call_kwargs = mock_runner.run.call_args + assert call_kwargs.kwargs["effort_level"] == "low" + + def test_cli_flag_overrides_env_default(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("askcc.cli.settings.ASKCC_CLAUDE_EFFORT_LEVEL", "low") + mock_runner = self._run_main_with_args(["--effort", "max"]) + call_kwargs = mock_runner.run.call_args + assert call_kwargs.kwargs["effort_level"] == "max" + + +class TestClaudeRunnerThinkingOptions: + """Tests that ClaudeRunner passes thinking options to the subprocess.""" + + ISSUE_URL = "https://github.com/test/repo/issues/1" + + @pytest.fixture + def agent_config(self) -> AgentConfig: + return AgentConfig( + action_name="test-agent", + description="A test agent", + system_prompt="You are a test agent.", + user_prompt_template="$issue_content", + system_prompt_file="TEST_SYSTEM_PROMPT.md", + user_prompt_file="TEST_USER_PROMPT.md", + ) + + @pytest.fixture + def runner(self) -> ClaudeRunner: + return ClaudeRunner() + + def test_effort_level_appended_to_cmd(self, runner: ClaudeRunner, agent_config: AgentConfig): + mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="{}", stderr="") + with patch("askcc.runners.subprocess.run", return_value=mock_result) as mock_run: + runner.run( + "prompt", + config=agent_config, + issue_url=self.ISSUE_URL, + cwd=Path.cwd(), + effort_level="high", + ) + cmd = mock_run.call_args[0][0] + assert "--effort" in cmd + idx = cmd.index("--effort") + assert cmd[idx + 1] == "high" + + def test_max_thinking_tokens_in_env(self, runner: ClaudeRunner, agent_config: AgentConfig): + mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="{}", stderr="") + with patch("askcc.runners.subprocess.run", return_value=mock_result) as mock_run: + runner.run( + "prompt", + config=agent_config, + issue_url=self.ISSUE_URL, + cwd=Path.cwd(), + max_thinking_tokens=50000, + ) + env = mock_run.call_args[1]["env"] + assert env[CLAUDE_ENV_MAX_THINKING_TOKENS] == "50000" + + def test_disable_thinking_in_env(self, runner: ClaudeRunner, agent_config: AgentConfig): + mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="{}", stderr="") + with patch("askcc.runners.subprocess.run", return_value=mock_result) as mock_run: + runner.run( + "prompt", + config=agent_config, + issue_url=self.ISSUE_URL, + cwd=Path.cwd(), + disable_thinking=True, + ) + env = mock_run.call_args[1]["env"] + assert env[CLAUDE_ENV_DISABLE_THINKING] == "1" + + def test_disable_adaptive_thinking_in_env(self, runner: ClaudeRunner, agent_config: AgentConfig): + mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="{}", stderr="") + with patch("askcc.runners.subprocess.run", return_value=mock_result) as mock_run: + runner.run( + "prompt", + config=agent_config, + issue_url=self.ISSUE_URL, + cwd=Path.cwd(), + disable_adaptive_thinking=True, + ) + env = mock_run.call_args[1]["env"] + assert env[CLAUDE_ENV_DISABLE_ADAPTIVE_THINKING] == "1" + + def test_no_thinking_env_when_defaults( + self, runner: ClaudeRunner, agent_config: AgentConfig, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.delenv(CLAUDE_ENV_DISABLE_THINKING, raising=False) + monkeypatch.delenv(CLAUDE_ENV_DISABLE_ADAPTIVE_THINKING, raising=False) + monkeypatch.delenv(CLAUDE_ENV_MAX_THINKING_TOKENS, raising=False) + mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="{}", stderr="") + with patch("askcc.runners.subprocess.run", return_value=mock_result) as mock_run: + runner.run( + "prompt", + config=agent_config, + issue_url=self.ISSUE_URL, + cwd=Path.cwd(), + ) + cmd = mock_run.call_args[0][0] + env = mock_run.call_args[1]["env"] + assert "--effort" not in cmd + assert CLAUDE_ENV_DISABLE_THINKING not in env + assert CLAUDE_ENV_DISABLE_ADAPTIVE_THINKING not in env + + def test_effort_none_not_appended(self, runner: ClaudeRunner, agent_config: AgentConfig): + mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="{}", stderr="") + with patch("askcc.runners.subprocess.run", return_value=mock_result) as mock_run: + runner.run( + "prompt", + config=agent_config, + issue_url=self.ISSUE_URL, + cwd=Path.cwd(), + effort_level=None, + ) + cmd = mock_run.call_args[0][0] + assert "--effort" not in cmd + + def test_disable_thinking_false_not_set(self, runner: ClaudeRunner, agent_config: AgentConfig): + mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="{}", stderr="") + with patch("askcc.runners.subprocess.run", return_value=mock_result) as mock_run: + runner.run( + "prompt", + config=agent_config, + issue_url=self.ISSUE_URL, + cwd=Path.cwd(), + disable_thinking=False, + ) + env = mock_run.call_args[1]["env"] + assert CLAUDE_ENV_DISABLE_THINKING not in env diff --git a/uv.lock b/uv.lock index f8dbb71..dc6c6f4 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = "==3.14.*" [[package]] name = "askcc" -version = "0.2.3" +version = "0.2.4" source = { editable = "." } [package.dev-dependencies]