Add the composition root and a runnable entry point
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m14s
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m14s
Nothing had ever assembled the agent. main.py was still the hello-world placeholder from the scaffold, and no code outside the test suite constructed an Orchestrator — which is how two fully built, fully tested components came to be reachable from nothing: the delegation harness, and the Gitea comment poller. Unit tests that construct a component directly cannot catch that; only assembly can. - config.py validates every variable before anything is built and names all of them at once, rather than failing at first use twenty minutes into a milestone on the call that mattered. Whitespace-only counts as missing, since a token of spaces passes a truthiness check and fails at the first request instead - agent.py builds the whole graph and owns startup and shutdown order: reconcile before accepting commands, and on stop, stop watching, then stop talking, then close the store last - The Gitea account is read via whoami rather than configured: it names the identity every permission is evaluated against, and proves the token works before anything depends on it - Poller lifecycle is now a state-machine concern: watch the plan issue while a gate stands, the PR while it is open, and stop on leaving. The orchestrator owns when to watch; an injected factory owns how to build one - The executor reports each turn, because it runs synchronously inside the transport callback: without it the agent is silent for a whole milestone and a working run looks exactly like a hung one - main.py loads .env, builds, and shuts down cleanly on SIGINT/SIGTERM The entry-point tests changed shape: nisse-dev no longer greets, it refuses to start unconfigured and says everything that is missing. Both tests now run with a deliberately empty environment and a .env path that does not exist, because the working directory's .env holds live credentials and a test must never be one cwd away from starting a real agent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6331fcafd9
commit
2fb25b8195
11 files changed
+694
-15
No files matched your search
@@ -0,0 +1,167 @@
|
||||
"""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 <project>` 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(),
|
||||
)
|
||||
Reference in new issue
Block a user