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