"""Composition root: the one place that knows how the whole agent fits together. Every component below has been unit-tested in isolation for some time; nothing had ever assembled them. That gap hid two live bugs — the delegation harness and the Gitea comment poller were both fully built and reachable from nothing but their own tests — which is the argument for having a single root rather than constructing pieces ad hoc. """ import threading from collections.abc import Callable from dataclasses import dataclass from nisse_dev.approvals import PlanBoard from nisse_dev.cancel import Cancel from nisse_dev.catalog import ModelCatalog, select_client from nisse_dev.config import AgentConfig, Project from nisse_dev.context import BudgetedClient from nisse_dev.control import Orchestrator from nisse_dev.delegation import Caps, Delegation from nisse_dev.execution import SSHHost from nisse_dev.executor import LLMExecutor from nisse_dev.expert import ExpertDesk from nisse_dev.git import GitRepo, HTTPSToken, PushCredential, SSHKey from nisse_dev.gitea import Comment, GiteaClient from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient from nisse_dev.matrix import MatrixTransport from nisse_dev.oplog import OpLog from nisse_dev.planner import LLMPlanningAgent from nisse_dev.poller import CommentPoller from nisse_dev.publish import MilestonePublisher 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 = ("planner", "executor", "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 Agent: config: AgentConfig store: StateStore transport: MatrixTransport orchestrator: Orchestrator gitea: GiteaClient account: str worker: SerialWorker _stopped: threading.Event def start(self) -> None: """Boot in the order L8 requires: reconcile against external truth before accepting a single operator command.""" self.worker.call(self.orchestrator.start) self.transport.start() state = self.worker.call(self.store.get_state) where = state.state if state else "IDLE" self.transport.send_message( f"nisse-dev up as {self.account} — project {self.config.project}, " f"state {where}, planner {self.config.roles.planner}, " f"executor {self.config.roles.executor}" ) def wait(self) -> None: self._stopped.wait() def stop(self) -> None: """Shut down outermost-first: stop accepting work, then stop talking, then close the state store last so nothing writes to a closed file. The store belongs to the worker, so the worker closes it.""" self.orchestrator.stop_watching() self.transport.close() self.gitea.close() try: self.worker.call(self.store.close, timeout=SHUTDOWN_WAIT_S) except TimeoutError: pass # still working; exiting mid-work is what L8 are for self.worker.stop(timeout=SHUTDOWN_WAIT_S) self._stopped.set() 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: Project | None = None) -> SSHHost: """A project's build VM (L13; the only project's, if none is named). Its known-hosts file and connection socket live beside the state store.""" host = (project or config.only_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( config: AgentConfig, project: Project | None = None ) -> PushCredential | None: """What a project's pushes go by: a push-only account's token over HTTPS, or a deploy key over SSH (D4).""" project = project or config.only_project() 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: Project | None = None, ) -> 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. Cloning here rather than when a work item starts is the single-project shortcut that M1.4 and D3's fresh workspace replace. """ repo = GitRepo(host, (project or config.only_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 build_agent( config: AgentConfig, *, notify: Callable[[str], None] | None = None ) -> Agent: oplog = OpLog("nisse-dev") worker = SerialWorker( on_error=lambda exc: oplog.emit( "worker.job_failed", cause="unhandled exception", error=repr(exc) ) ) worker.start() # Opened on the worker, which is the only thread allowed to use it. store = worker.call(lambda: StateStore(config.state_db, oplog=oplog)) cancel = Cancel() # Before the clone, before the token checks: a typo'd role name should # cost a second, not a repository. catalog = discover_models(config, oplog) gitea = GiteaClient(config.gitea_url, config.gitea_token) # Whoami rather than another config variable: it names the identity every # Gitea permission is evaluated against, and proves the token works before # anything depends on it. account = gitea.whoami() host = build_host(config) credential = push_credential(config) remote = remote_url(gitea, config.gitea_repo, credential) git_repo = prepare_workspace(host, config, account, remote, credential) tuning = config.tuning workspace = Workspace( host, config.workspace, timeout_s=tuning.tools.command_timeout_s, max_output_chars=tuning.tools.max_output_chars, ) normalized, passthrough = make_llm_clients(config, catalog) transport = MatrixTransport(config.matrix) announce = notify or transport.send_message expert = ExpertDesk(normalized, config.roles, budget=tuning.expert.consult_budget) delegate_model = config.roles.resolve("helper") delegation = Delegation( select_client(delegate_model, normalized, passthrough, catalog), store, workspace, git_repo, model=delegate_model, backing_model=catalog.facts(delegate_model).backing, caps=Caps( turns=tuning.delegation.max_turns, tokens=tuning.delegation.max_tokens, wall_clock_s=tuning.delegation.wall_clock_s, ), cancel=cancel, ) executor = LLMExecutor( normalized, config.roles, workspace, expert=expert, delegate=delegation, notify=announce, cancel=cancel, max_turns=tuning.executor.max_turns, failure_limit=tuning.executor.failure_limit, ) publisher = MilestonePublisher( store, gitea, config.gitea_repo, git_repo, remote_url=remote, push_credential=credential, base_branch=config.base_branch, ) def poller_factory(on_comment: Callable[[Comment], None]) -> CommentPoller: return CommentPoller( gitea, config.gitea_repo, on_comment, oplog=oplog, # Its own comments are not new input; without this the agent # answers itself. ignore_authors={account}, ) orchestrator = Orchestrator( store, transport, LLMPlanningAgent(normalized, config.roles, cancel=cancel), operators=config.operators, roles=config.roles, executor=executor, publisher=publisher, gitea_operators=config.gitea_operators, board=PlanBoard(gitea, config.gitea_repo), delegate=delegation, poller_factory=poller_factory, catalog=catalog, worker=worker, cancel=cancel, ) return Agent( config=config, store=store, transport=transport, orchestrator=orchestrator, gitea=gitea, account=account, worker=worker, _stopped=threading.Event(), )