"""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 from nisse_dev.control import Orchestrator from nisse_dev.delegation import Delegation from nisse_dev.executor import LLMExecutor from nisse_dev.expert import ExpertDesk from nisse_dev.git import GitRepo, authenticated_url 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 (A2/A3). 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", "delegate") 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()) 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 A15 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 A3 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 A2/A3 are for self.worker.stop(timeout=SHUTDOWN_WAIT_S) self._stopped.set() def prepare_workspace(config: AgentConfig, account: str) -> GitRepo: """Ensure the workspace is a clone of the project repo. Cloning here rather than on `use ` is the single-project shortcut: resolving a project name to a repo is issue #27's work. """ repo = GitRepo(config.workspace) remote = authenticated_url( config.gitea_url, config.gitea_repo, account, config.gitea_token ) if not (config.workspace / ".git").exists(): config.workspace.parent.mkdir(parents=True, exist_ok=True) GitRepo(config.workspace.parent).clone(remote, config.workspace) repo.configure_identity(account, f"{account}@users.noreply.local") 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() git_repo = prepare_workspace(config, account) workspace = Workspace(config.workspace) normalized = LiteLLMClient(config.llm_base_url, config.llm_token) # Delegations prefer the vendor-native route; fidelity is cheap # elsewhere (A5). Which route a given model gets is decided by # select_client, not by where the caller happens to sit. passthrough = AnthropicPassthroughClient(config.llm_base_url, config.llm_token) transport = MatrixTransport(config.matrix) announce = notify or transport.send_message expert = ExpertDesk(normalized, store, config.roles) delegate_model = config.roles.resolve("delegate") delegation = Delegation( select_client(delegate_model, normalized, passthrough, catalog), store, workspace, git_repo, model=delegate_model, backing_model=catalog.facts(delegate_model).backing, cancel=cancel, ) executor = LLMExecutor( normalized, store, config.roles, workspace, expert=expert, delegate=delegation, notify=announce, cancel=cancel, ) publisher = MilestonePublisher( store, gitea, config.gitea_repo, git_repo, base_url=config.gitea_url, account=account, token=config.gitea_token, 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, store, 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(), )