"""Composition root: how nisse-review fits together, and the checks it makes before it reviews anything.""" from dataclasses import dataclass from nisse_core.gitea import GiteaClient from nisse_core.knowledge.handlers import Knowledge from nisse_core.knowledge.memory import Memory from nisse_core.knowledge.vault import Vault from nisse_core.loop.driver import Driver from nisse_core.loop.expert import ExpertDesk from nisse_core.models import calls from nisse_core.models.catalog import ModelCatalog from nisse_core.models.compact import Compactor from nisse_core.models.context import BudgetedClient from nisse_core.models.interruptible import Interruptible from nisse_core.models.llm import LiteLLMClient from nisse_core.oplog import OpLog from nisse_core.state.cancel import Cancel from nisse_review.access import AccessRefused, check, reviewed from nisse_review.clones import Clones from nisse_review.config import ReviewConfig from nisse_review.review import Reviewer from nisse_review.scheduler import Scheduler from nisse_review.state import StateStore AGENT = "nisse-review" MEMORY_FOLDER = "memory/nisse-review" # L10: its own, in the vault class StartupError(RuntimeError): pass @dataclass class Built: scheduler: Scheduler account: str repositories: list[str] store: StateStore gitea: GiteaClient model: Interruptible def stop(self) -> None: """From the signal handler: no more rounds, the run in progress ends at its next turn, and a model call in flight is broken off.""" self.scheduler.stop() self.model.interrupt() def close(self) -> None: self.gitea.close() self.store.close() def check_access( gitea: GiteaClient, patterns: frozenset[str] | None, oplog: OpLog ) -> tuple[str, list[str]]: """Who it is and what it reviews (R1, R3): read only. An account that can push to, or administer, a repository it reviews is refused: its hard wall is that it can't change what it reviews. The scheduler checks again every round (``access``).""" account = gitea.whoami() try: repos = check(gitea, reviewed(gitea, patterns, oplog), oplog) except AccessRefused as exc: raise StartupError(str(exc)) from exc return account, repos def llm_client(config: ReviewConfig, oplog: OpLog | None = None) -> BudgetedClient: """The one route to the models, every request fitted to its window (L6), against what LiteLLM says the configured models are (L9).""" catalog = ModelCatalog.discover(config.llm_base_url, config.llm_token) pinned = config.roles.pinned() del pinned["classifier"] # not one of nisse-review's roles catalog.validate(pinned) catalog.require_limits(pinned) def limits_for(model: str) -> tuple[int, int | None]: facts = catalog.facts(model) assert facts.max_input_tokens is not None # require_limits checked return facts.max_input_tokens, facts.max_output_tokens tuning = config.tuning.context return BudgetedClient( LiteLLMClient( config.llm_base_url, config.llm_token, max_tokens=config.max_output_tokens, timeout_s=config.request_timeout_s, ), limits_for, max_output_tokens=config.max_output_tokens, keep_recent_turns=tuning.keep_recent_turns, safety_margin=tuning.safety_margin, enforces_tool_choice=lambda m: catalog.facts(m).supports_tool_choice is True, oplog=oplog, # a cut that fitting has to make is logged call_recorder=calls.recorder_for(config.state_db, config.call_record, oplog), ) def build( config: ReviewConfig, *, gitea: GiteaClient | None = None, client: object | None = None, oplog: OpLog | None = None, ) -> Built: oplog = oplog or OpLog(AGENT) gitea = gitea or GiteaClient(config.gitea_url, config.gitea_token) account, repos = check_access(gitea, config.repositories, oplog) # Every model call, the reviewer's and ask_expert's, can be broken off. model = Interruptible(client if client is not None else llm_client(config, oplog)) # type: ignore[arg-type] config.state_db.parent.mkdir(parents=True, exist_ok=True) store = StateStore(config.state_db, oplog=oplog) vault = ( Vault( config.vault.root, config.vault.folders, agent=AGENT, ignore=config.vault.ignore, ) if config.vault else None ) memory = ( Memory(vault, MEMORY_FOLDER) if vault and config.vault and config.vault.folders.get(MEMORY_FOLDER) == "rw" else None ) tuning = config.tuning cancel = Cancel() reviewer = Reviewer( store, gitea, Driver( model, # type: ignore[arg-type] config.roles.resolve("driver"), cancel=cancel, compactor=Compactor(model, tuning.context.compaction(), oplog=oplog), ), account=account, gitea_url=config.gitea_url, oplog=oplog, knowledge=Knowledge(vault, memory, oplog), expert=ExpertDesk( model, # type: ignore[arg-type] config.roles, budget=tuning.expert.consult_budget, ), max_turns=tuning.review.max_turns, diff_chars=tuning.review.diff_chars, ) # The repositories' clones, kept between runs (R3). clones = Clones( config.state_db.parent / "scratch" / "repos", config.gitea_url, config.gitea_token, oplog=oplog, cancel=cancel, ) scheduler = Scheduler( store, gitea, reviewer, oplog=oplog, sources=clones, poll_s=tuning.review.poll_s, attempts=tuning.review.attempts, ci_wait_s=tuning.review.ci_wait_s, ci_grace_s=tuning.review.ci_grace_s, operators=config.gitea_operators, max_rounds=tuning.review.max_rounds, patterns=config.repositories, cancel=cancel, ) return Built(scheduler, account, repos, store, gitea, model)