Files
nisse/nisse-dev/src/nisse_dev/loop/build.py
T
dongarandClaude Opus 5.5 9968a0922a
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m59s
CI / e2e (pull_request) Successful in 10m33s
M4.1: toolchain preflight
A model works out and sets up what a project needs on its build VM;
code makes sure that happens, bounds it and checks the result (D3).

- Before each driver run on a project, one round trip checks the build
  VM: the harness's own baseline (missing: the item waits, a template
  problem), the workspace clone (missing: cloned again and said; the
  item resumes from what it pushed) and a per-project marker.
- No marker (a new project, a new or wiped VM): the item's driver model
  runs a bounded preflight first, with its own prompt, the execution
  tools (screened as ever) and toolchain_ready. The harness runs each
  reported version command itself (version-shaped commands only, even
  from the marker, which the model's commands could edit) and writes
  the marker. Gaps and an unfinished preflight are facts and work goes
  on; the thread hears only of installs and gaps. A rebuilt VM starts
  from the last toolchain.
- Later runs check the versions again; a change is a fact and a thread
  line. The PR body says what the work was tested with.
- A changed host key is its own failure (HostKeyChanged): the item
  waits for the operator's `trust new host key`, which drops the pin;
  then the VM is set up again.
- A prompt line: install what the work needs on the build VM, say why,
  and put lasting needs in the project's own pins.
- tuning.preflight: max_turns, command_timeout_s.
- The property walk gains a VM wipe and ready_before_work (checked
  against a mutant that skips preflight); parked_work_safe accepts that
  a wipe loses what wasn't pushed (D3).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-08 15:43:30 +00:00

354 lines
12 KiB
Python

"""Composition root: the one place that knows how the whole agent fits
together. Every component is unit-tested in isolation; this is where they meet,
which is what keeps a built piece from being reachable only from its own tests
(the delegation harness and the comment poller once were)."""
import threading
from collections.abc import Callable
from dataclasses import dataclass
from nisse_dev.cancel import Cancel
from nisse_dev.catalog import ModelCatalog, select_client
from nisse_dev.config import AgentConfig, ConfigError
from nisse_dev.config import Project as ProjectEntry
from nisse_dev.context import BudgetedClient
from nisse_dev.delegation import Caps
from nisse_dev.execution import SSHHost
from nisse_dev.git import GitRepo, HTTPSToken, PushCredential, SSHKey
from nisse_dev.gitea import GiteaClient
from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient
from nisse_dev.loop.agent import LoopAgent, Project
from nisse_dev.loop.classifier import ActionClassifier
from nisse_dev.loop.driver import Driver
from nisse_dev.loop.help import Helpers
from nisse_dev.loop.milestones import Milestones
from nisse_dev.loop.reader import GoAheadReader
from nisse_dev.matrix import MatrixTransport
from nisse_dev.oplog import OpLog
from nisse_dev.state import StateStore
from nisse_dev.tools import Workspace
from nisse_dev.worker import SerialWorker
# How long shutdown waits for running work before leaving the store to the
# durability layer (L8). A milestone can run far longer than a service
# manager allows for a stop, and a hard exit mid-work is already safe.
SHUTDOWN_WAIT_S = 10.0
TOOL_DRIVING_ROLES = ("driver", "helper")
def discover_models(config: AgentConfig, oplog: OpLog) -> ModelCatalog:
"""Resolve every configured role against LiteLLM before anything runs.
Refusing to boot is the whole point: a role pointed at a model this key
cannot see is a misconfiguration, and the cheapest place to learn that is
here rather than ten minutes into a milestone. An unreachable proxy is
reported separately -- it is an outage, not a mistake, and telling them
apart is what lets an operator know whether to edit config or wait.
"""
catalog = ModelCatalog.discover(config.llm_base_url, config.llm_token)
catalog.validate(config.roles.pinned())
# Every role's context window must be known before a request is built
# against it (L6): a guess that is too large reaches a server that may
# silently cut the prompt.
catalog.require_limits(config.roles.pinned())
for role in TOOL_DRIVING_ROLES:
facts = catalog.facts(config.roles.resolve(role))
if facts.supports_tools is False:
# A warning, not a refusal: models LiteLLM declares tool-less are
# observed driving tool loops anyway, and L9 expects weak local
# models to be accommodated rather than excluded.
oplog.emit(
"model_tools_unsupported",
cause="startup",
role=role,
model=facts.alias,
backing=facts.backing,
)
return catalog
@dataclass
class RunningLoop:
config: AgentConfig
account: str
loop: LoopAgent
store: StateStore
transport: MatrixTransport
gitea: GiteaClient
worker: SerialWorker
_stopped: threading.Event
def start(self) -> None:
self.loop.start()
self.transport.post(
f"nisse-dev up as {self.account} on "
f"{', '.join(p.repo for p in self.config.projects)}; "
f"{self.loop_status()}"
)
def loop_status(self) -> str:
return self.worker.call(self.loop.status)
def stop(self) -> None:
self.loop.stop()
self.transport.close()
self.gitea.close()
try:
self.worker.call(self.store.close, timeout=SHUTDOWN_WAIT_S)
except TimeoutError:
pass # still working; L8 covers exiting mid-work
self.worker.stop(timeout=SHUTDOWN_WAIT_S)
self._stopped.set()
def wait(self) -> None:
self._stopped.wait()
def make_llm_clients(
config: AgentConfig, catalog: ModelCatalog
) -> tuple[BudgetedClient, BudgetedClient]:
"""The two LLM routes, as every model call in the agent uses them.
Shared with the benchmark, so that what it measures is exactly what the
agent would do: the same context budget, the same declared limits, and
tool_choice asked only of models declared to enforce it.
"""
tuning = config.tuning
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
def budgeted(client: object) -> BudgetedClient:
# Every request, on either route, is fitted to its model's window.
return BudgetedClient(
client,
limits_for,
max_output_tokens=config.max_output_tokens,
keep_recent_turns=tuning.context.keep_recent_turns,
safety_margin=tuning.context.safety_margin,
enforces_tool_choice=lambda m: (
catalog.facts(m).supports_tool_choice is True
),
)
normalized = budgeted(
LiteLLMClient(
config.llm_base_url,
config.llm_token,
max_tokens=config.max_output_tokens,
timeout_s=config.request_timeout_s,
)
)
# Delegations prefer the vendor-native route; fidelity is cheap
# elsewhere (L9). Which route a given model gets is decided by
# select_client, not by where the caller happens to sit.
passthrough = budgeted(
AnthropicPassthroughClient(
config.llm_base_url,
config.llm_token,
max_tokens=config.max_output_tokens,
timeout_s=config.request_timeout_s,
)
)
return normalized, passthrough
def build_host(config: AgentConfig, project: ProjectEntry) -> SSHHost:
"""A project's build VM (L13). Its known-hosts file and connection socket
live beside the state store."""
host = project.build_host
return SSHHost(
host.address,
key=host.key,
user=host.user,
port=host.port,
state_dir=config.state_db.parent / "ssh",
)
def push_credential(project: ProjectEntry) -> PushCredential | None:
"""What a project's pushes go by: a push-only account's token over
HTTPS, or a deploy key over SSH (D4)."""
if project.push_user and project.push_token:
return HTTPSToken(project.push_user, project.push_token)
if project.push_key is None:
return None
try:
return SSHKey(project.push_key.read_text())
except OSError as exc:
raise RuntimeError(
f"cannot read the push key {project.push_key}: {exc}"
) from exc
def remote_url(gitea: GiteaClient, repo: str, credential: PushCredential | None) -> str:
"""The credential-free URL the workspace clones from and pushes to."""
if isinstance(credential, SSHKey):
return gitea.ssh_url(repo)
return gitea.clone_url(repo)
def prepare_workspace(
host: SSHHost,
config: AgentConfig,
account: str,
remote: str,
credential: PushCredential | None,
project: ProjectEntry,
) -> GitRepo:
"""Ensure the workspace on the build VM is a clone of the project repo.
The clone uses the repository's SSH URL, so no credential is ever stored
in it (#89); the push credential is handed to the clone and nothing else.
"""
repo = GitRepo(host, project.workspace)
if not repo.exists():
repo.clone(remote, credential=credential)
repo.configure_identity(account, f"{account}@users.noreply.local")
repo.install_excludes() # build artifacts are never the work (#80)
return repo
def project_runtime(
config: AgentConfig,
entry: ProjectEntry,
store: StateStore,
gitea: GiteaClient,
account: str,
) -> Project:
"""One project's part of the loop: its clone on its build VM, workspace
(with the secrets its commands may see, redacted on return), push
credential and milestones."""
host = build_host(config, entry)
credential = push_credential(entry)
remote = remote_url(gitea, entry.repo, credential)
git = prepare_workspace(host, config, account, remote, credential, entry)
return Project(
name=entry.name,
repo=entry.repo,
labels=entry.labels,
base=entry.base_branch,
merge=entry.merge,
workspace=Workspace(
host,
entry.workspace,
timeout_s=config.tuning.tools.command_timeout_s,
max_output_chars=config.tuning.tools.max_output_chars,
secrets=entry.command_secrets(),
),
milestones=Milestones(
store,
git,
gitea,
repo=entry.repo,
remote=remote,
credential=credential,
base=entry.base_branch,
),
)
def build_loop_agent(
config: AgentConfig, load: Callable[[], AgentConfig] | None = None
) -> RunningLoop:
"""``load`` reads the configuration again, for a new project's entry
(D8); by default from the file this one came from."""
oplog = OpLog("nisse-dev")
worker = SerialWorker(
on_error=lambda exc: oplog.emit(
"worker.job_failed", cause="unhandled exception", error=repr(exc)
)
)
worker.start()
store = worker.call(lambda: StateStore(config.state_db, oplog=oplog))
catalog = discover_models(config, oplog)
gitea = GiteaClient(config.gitea_url, config.gitea_token)
account = gitea.whoami()
# D8: each project its own build VM, clone, push credential and milestones.
projects = [
project_runtime(config, entry, store, gitea, account)
for entry in config.projects
]
normalized, passthrough = make_llm_clients(config, catalog)
cancel = Cancel()
# The configured driver; each work item runs on the one it pinned (L9).
driver = Driver(
normalized,
config.roles.resolve("driver"),
max_turns=config.tuning.driver.max_turns,
cancel=cancel,
failure_limit=config.tuning.driver.failure_limit,
)
tuning = config.tuning
helpers = Helpers(
client=normalized,
roles=config.roles,
client_for=lambda model: select_client(model, normalized, passthrough, catalog),
backing=lambda model: catalog.facts(model).backing,
caps=Caps(
turns=tuning.delegation.max_turns,
tokens=tuning.delegation.max_tokens,
wall_clock_s=tuning.delegation.wall_clock_s,
),
expert_budget=tuning.expert.consult_budget,
profiles=config.profiles,
)
transport = MatrixTransport(config.matrix)
loop = LoopAgent(
store,
transport,
gitea,
projects=projects,
account=account,
operators=config.operators,
gitea_operators=config.gitea_operators,
driver=driver,
worker=worker,
cancel=cancel,
oplog=oplog,
roles=config.roles.pinned(),
# The classifier role reads plain-language go-aheads (L5, L9).
reader=GoAheadReader(normalized, config.roles.resolve("classifier")),
# L4: what the fast path doesn't know, judged on the classifier role.
classifier=ActionClassifier(
normalized,
config.roles.resolve("classifier"),
backing=lambda model: catalog.facts(model).backing,
),
helpers=helpers,
reload=lambda known: [
project_runtime(config, entry, store, gitea, account)
for entry in (load or _from_file(config))().projects
if entry.name not in known
],
# D3, M4.1: the build VM checked, and the toolchain set up, before
# the driver plans.
preflight=True,
preflight_turns=config.tuning.preflight.max_turns,
preflight_timeout_s=config.tuning.preflight.command_timeout_s,
bug_grace_s=config.tuning.loop.bug_grace_s,
general_idle_s=config.tuning.loop.general_idle_s,
)
return RunningLoop(
config=config,
account=account,
loop=loop,
store=store,
transport=transport,
gitea=gitea,
worker=worker,
_stopped=threading.Event(),
)
def _from_file(config: AgentConfig) -> Callable[[], AgentConfig]:
def load() -> AgentConfig:
if config.config_file is None:
raise ConfigError("this agent wasn't started from a config file")
return AgentConfig.from_file(config.config_file)
return load