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
27 changes: 27 additions & 0 deletions apps/backend/agents/planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
from pathlib import Path

from analysis.prevention_scanner import PreventionScanner
from core.autonomy_level import AutonomyLevel, resolve_autonomy_settings
from core.providers import create_engine_provider
from core.providers.base import SessionConfig
from core.providers.config import ProviderConfig, get_provider_config
from core.providers.exceptions import ProviderError
from implementation_plan import ImplementationPlan
from phase_config import get_phase_model, get_phase_thinking_budget
from phase_event import ExecutionPhase, emit_phase
Expand Down Expand Up @@ -60,6 +62,28 @@
logger = logging.getLogger(__name__)


def _assert_planner_autonomy_allows(provider_name: str) -> None:
"""Block non-Claude planner sessions when autonomy is fully off (P3·T2).

``AUTO_CODE_AUTONOMY=off`` promises "no autonomous non-Claude execution";
the planner writes implementation_plan.json, so the session must be
refused before it exists rather than degraded afterwards.

Raises:
ProviderError: When the resolved level is ``off`` and the effective
provider is not Claude.
"""
if provider_name == "claude":
return
autonomy = resolve_autonomy_settings()
if autonomy.level is AutonomyLevel.OFF:
raise ProviderError(
f"AUTO_CODE_AUTONOMY=off blocks the non-Claude planner "
f"(provider {provider_name!r}). Set AUTO_CODE_AUTONOMY to claude, "
f"safe, or bold — or switch the planner provider to claude."
)


def create_planner_session(
project_dir: Path,
spec_dir: Path,
Expand Down Expand Up @@ -108,6 +132,9 @@ def create_planner_session(
)

provider = create_engine_provider(config)
# Gate on the effective provider (after runner routing), not the raw
# config value, so a route to a different engine is still enforced.
_assert_planner_autonomy_allows(provider.name)

# For Claude provider, pass provider-specific kwargs
if provider.name == "claude":
Expand Down
3 changes: 2 additions & 1 deletion docs/strategy/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
| # | Задача | Файлы | Критерий приёмки |
|---|---|---|---|
| T1 | ✅ **Уже реализовано.** Coder уважает уровень автономности | `agents/coder.py:1282–1296` + `agents/runtime/direct_api_autonomy.py:124` | `AUTO_CODE_AUTONOMY=claude` блокирует автономный direct-API coder; `safe` — через gate; `bold` — мимо gate (хардкод `=True` остался только в смоук-тесте `cli/provider_smoke_commands.py`) |
| T2 | Гейт для planner на не-Claude провайдерах | `agents/planner.py` | При `off` не-Claude planner блокируется |
| T2 | Гейт для planner на не-Claude провайдерах | `agents/planner.py` (`_assert_planner_autonomy_allows` в `create_planner_session`) | При `off` не-Claude planner блокируется `ProviderError` до создания сессии; Claude работает на любом уровне |
| T3 | Фабрика принимает `autonomy_settings` и логирует уровень | `agents/runtime/adapters/__init__.py` (`create_runtime_session`) | Уровень автономности виден в логе сессии |
| T4 | Один тумблер в UI + вывод уровня в JSON | `cli/runtime_commands.py`, настройки фронта | UI показывает и задаёт один селектор автономности |
| T5 | Доки: вести с `AUTO_CODE_AUTONOMY`, 30+ переменных — в приложение | `guides/QUICK-START.md`, `docs/` | Quickstart: «поставь `safe` — готово» |
Expand Down Expand Up @@ -145,6 +145,7 @@
- ✅ **P1·T4** — QA-экран отчёта доверия в task overview (reader + IPC + `VerificationReportPanel`, i18n en/fr).
- ✅ **P3·T4** — тумблер автономности в настройках → инжектит `AUTO_CODE_AUTONOMY` в окружение сборки (явный env приоритетнее).
- ✅ **P5·T2** — агрегация стоимости подключена: `save_token_stats()` пишет запись в `cost_report.json` спеки (роль+фаза+$) и обновляет проектный `.auto-claude/model_usage_summary.json` (`cost_by_phase` добавлен; попутно починены сериализация summary и `+00:00Z`-таймстампы записей).
- ✅ **P3·T2** — гейт планнера: `AUTO_CODE_AUTONOMY=off` блокирует не-Claude planner (`ProviderError` до создания сессии, после runner-роутинга); Claude-путь не затронут.

Отчёт доверия теперь несёт: **вердикт · тесты · дифф · out-of-scope · confidence · uncertainty** — и виден в UI.

Expand Down
90 changes: 90 additions & 0 deletions tests/test_planner_autonomy_gate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Tests for the planner autonomy gate (P3.T2).

``AUTO_CODE_AUTONOMY=off`` must block non-Claude planner sessions before the
session exists; Claude keeps working at every level, and claude/safe/bold
keep non-Claude planners available.
"""

import sys
from pathlib import Path

import pytest

sys.path.insert(0, str(Path(__file__).parent.parent / "apps" / "backend"))

import agents.planner as planner_mod # noqa: E402
from agents.planner import ( # noqa: E402
_assert_planner_autonomy_allows,
create_planner_session,
)
from core.autonomy_level import AUTONOMY_LEVEL_ENV # noqa: E402
from core.providers.exceptions import ProviderError # noqa: E402


class TestAutonomyGateHelper:
def test_off_blocks_non_claude(self, monkeypatch):
monkeypatch.setenv(AUTONOMY_LEVEL_ENV, "off")
with pytest.raises(ProviderError, match="AUTO_CODE_AUTONOMY=off"):
_assert_planner_autonomy_allows("openai")

def test_off_keeps_claude_available(self, monkeypatch):
monkeypatch.setenv(AUTONOMY_LEVEL_ENV, "off")
_assert_planner_autonomy_allows("claude") # must not raise

@pytest.mark.parametrize("level", ["claude", "safe", "bold"])
def test_other_levels_allow_non_claude(self, monkeypatch, level):
monkeypatch.setenv(AUTONOMY_LEVEL_ENV, level)
_assert_planner_autonomy_allows("openai") # must not raise

def test_default_level_allows_non_claude(self, monkeypatch):
monkeypatch.delenv(AUTONOMY_LEVEL_ENV, raising=False)
_assert_planner_autonomy_allows("openai") # default is claude, not off

def test_error_names_the_provider(self, monkeypatch):
monkeypatch.setenv(AUTONOMY_LEVEL_ENV, "off")
with pytest.raises(ProviderError, match="'ollama'"):
_assert_planner_autonomy_allows("ollama")


class _FakeProvider:
"""Provider stub: session creation must never be reached when gated."""

def __init__(self, name: str):
self.name = name
self.create_session_calls = 0

def create_session(self, *args, **kwargs):
self.create_session_calls += 1
return object()


class TestCreatePlannerSessionGate:
def test_off_blocks_before_session_creation(self, monkeypatch, tmp_path):
monkeypatch.setenv(AUTONOMY_LEVEL_ENV, "off")
fake = _FakeProvider("openai")
monkeypatch.setattr(planner_mod, "create_engine_provider", lambda config: fake)

with pytest.raises(ProviderError, match="AUTO_CODE_AUTONOMY=off"):
create_planner_session(tmp_path, tmp_path / "spec")

assert fake.create_session_calls == 0

def test_safe_level_creates_non_claude_session(self, monkeypatch, tmp_path):
monkeypatch.setenv(AUTONOMY_LEVEL_ENV, "safe")
fake = _FakeProvider("openai")
monkeypatch.setattr(planner_mod, "create_engine_provider", lambda config: fake)

session = create_planner_session(tmp_path, tmp_path / "spec")

assert session is not None
assert fake.create_session_calls == 1

def test_off_still_creates_claude_session(self, monkeypatch, tmp_path):
monkeypatch.setenv(AUTONOMY_LEVEL_ENV, "off")
fake = _FakeProvider("claude")
monkeypatch.setattr(planner_mod, "create_engine_provider", lambda config: fake)

session = create_planner_session(tmp_path, tmp_path / "spec")

assert session is not None
assert fake.create_session_calls == 1
Loading