CI / checks (push) Skipped
CI / checks (pull_request) Failing after 5m7s
- Config: `projects: {name: entry}` with repo, base_branch, workspace,
build_host, push credential, labels (defaults added to every issue),
secrets by ${VAR} reference with visibility (commands | harness), and
egress (recorded, not enforced). Every problem in every entry is
reported at once; an inline secret, a bad visibility, a repeated repo or
a non-slug name is an error.
- The old loop runs exactly one project (only_project) and refuses more.
- The new loop is per project: each its own clone, workspace (with its
commands-visible secrets, redacted on return), push credential and
milestones; intake polls every project's repository; items are scoped
to their project for every run, publish and PR watch. Issues are named
project#14 when there is more than one project, and a bare #14 that
matches two is asked about. Switching projects parks the one worked on
last (all others after a boot).
- A restatement that ends in a question is sent back to the driver, not
posted: the e2e driver kept asking "Is this the right interpretation?",
which a plain yes can't answer.
Tests: config entries and errors, two-project assembly, two-project loop
tests (intake, own workspaces and repos, #14 in both, do-next, label
defaults, parking across projects), the property walk over two projects
with a new own_repository_only property (an item using the first
project's repo fails 15 of 16 seeds), and the restatement check.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
343 lines
12 KiB
Python
343 lines
12 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, 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(),
|
|
)
|