"""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.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 @dataclass class Agent: config: AgentConfig store: StateStore transport: MatrixTransport orchestrator: Orchestrator gitea: GiteaClient account: str _stopped: threading.Event def start(self) -> None: """Boot in the order A3 requires: reconcile against external truth before accepting a single operator command.""" self.orchestrator.start() self.transport.start() state = 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.""" self.orchestrator.stop_watching() self.transport.close() self.gitea.close() self.store.close() 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") store = StateStore(config.state_db, oplog=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 take the passthrough route; fidelity is cheap elsewhere (A5). 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) delegation = Delegation( passthrough, store, workspace, git_repo, model=config.roles.resolve("delegate"), ) executor = LLMExecutor( normalized, store, config.roles, workspace, expert=expert, delegate=delegation, notify=announce, ) 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), 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, ) return Agent( config=config, store=store, transport=transport, orchestrator=orchestrator, gitea=gitea, account=account, _stopped=threading.Event(), )