Before each driver call, over compact_at (0.6) of the prompt budget, the same model is asked for notes over the very request it was about to get, plus one message, so the server's cache covers all but the notes. The notes, with code's list of the calls they replace, become a compaction event that renders in place of its range, keeping word for word what started the work and the last few turns. Rare, large steps keep the prompt's start unchanged between them. A later compaction folds in the earlier; one from an earlier run's view is hidden. Failed notes are logged and fitting takes over. Fitting now applies compactions before it measures, records what it cuts (Trim) and logs each cut as context.trimmed. Wired into both agents' drivers and the benchmark; nisse-dev's helper loop is a follow-up. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
176 lines
5.9 KiB
Python
176 lines
5.9 KiB
Python
"""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.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
|
|
)
|
|
|
|
|
|
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)
|