CI / checks (push) Skipped
CI / checks (pull_request) Successful in 49s
`stop` could not have worked by adding the command alone. Planning turns, milestones and review responses ran synchronously on the thread that delivered the message, so the Matrix sync loop read nothing until the work finished. The state store was also opened on the main thread and used from the transport and poller threads, which SQLite refuses with its default same-thread check; the in-memory test transport never showed it. A single worker thread now runs every handler and owns the store: - The worker opens the store, so the same-thread check stays on and fails loudly if anything touches the store from elsewhere - Matrix messages are handed to the worker; pollers hand over and wait, so their cursor never passes an unhandled comment - A message that arrives during long work gets an immediate "busy; say 'stop' to halt" reply and is recorded, not applied later in a state the operator was not answering - Tests keep running inline through InlineWorker, which defers a job submitted from inside another as the real worker would queue it `stop` sets a shared Cancel flag straight from the transport's thread. The executor and delegation loops check it before each model call and between tool calls; the planner checks it before each turn. Calls left unrun get a result saying so, so the transcript still renders. A stopped delegation is discarded like a failed one. The orchestrator moves to AWAIT_INPUT and asks why the work was stopped. The reply is recorded for the model, and a stopped planning turn resumes with it as the answer. `stop` with nothing running says so and changes nothing; at a gate it is no longer taken as rejection feedback. Closes #40 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
232 lines
8.3 KiB
Python
232 lines
8.3 KiB
Python
"""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 <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")
|
|
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(),
|
|
)
|