Files
nisse/nisse-dev/src/nisse_dev/agent.py
T
dongarandClaude Opus 5.5 064d9bf554
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 5m7s
Projects as configuration entries (M1.4, D8)
- 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
2026-10-06 01:12:57 +00:00

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(),
)