Files
nisse/nisse-review/src/nisse_review/build.py
T
dongarandClaude Opus 5.5 dd060566b2
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m54s
CI / e2e (pull_request) Successful in 4m20s
The call record: every model call, for the operator's study (M6.6a)
nisse-core records each model call of every agent into a file beside its
state store (<state>.calls.db), for the status page (L7, X6):

- the request body exactly as sent (never headers), the raw response with
  its reasoning, the model asked and the one that answered, time, tokens
  and llama-server's own prompt reuse (timings.cache_n);
- its work item, role, run and purpose: Driver.run and nisse-dev's
  delegation loop open runs, nested; compaction, expert consults, the
  classifier and the go-ahead reader label their calls;
- provenance, worked out in BudgetedClient by comparing the events at
  each stage: each event sent with its log id and content as sent, which
  a compaction replaced, which fitting shortened (by how much) and
  dropped.

Content is stored once by hash, compressed. It is diagnostic only: the
agent never reads it, a failed write is logged once and recording stops
while the call goes on, and the file is deleted when the agent starts,
so its layout never migrates; CallReader refuses a layout it doesn't
know. While recording, the system prompt ends with one sentence saying
so. `call_record: false` in either agent's YAML turns it off.

The benchmark records each run beside that run's own state store, and
`--keep-runs DIR` keeps both for the page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
2026-10-24 06:28:53 +00:00

178 lines
6.0 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 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)