CI / checks (push) Skipped
CI / checks (pull_request) Failing after 56s
The clone gets the standard build artifacts in its own exclude file, so they never show as changes, even to a model's `git add -A`. The harness's commits leave the same patterns out even when the project tracks them, as the sandbox's main did. What was left out is reported in the publish note and the PR description. Found again in bring-up run 3 (sandbox PR #208). Fixes #80. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
338 lines
12 KiB
Python
338 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
|
|
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) -> SSHHost:
|
|
"""The tool VM the workspace lives on (L13). Its known-hosts file and
|
|
connection socket live beside the state store."""
|
|
return SSHHost(
|
|
config.build_host.address,
|
|
key=config.build_host.key,
|
|
user=config.build_host.user,
|
|
port=config.build_host.port,
|
|
state_dir=config.state_db.parent / "ssh",
|
|
)
|
|
|
|
|
|
def push_credential(config: AgentConfig) -> PushCredential | None:
|
|
"""What pushes go by: a push-only account's token over HTTPS, or a
|
|
deploy key over SSH (D4)."""
|
|
if config.push_user and config.push_token:
|
|
return HTTPSToken(config.push_user, config.push_token)
|
|
if config.push_key is None:
|
|
return None
|
|
try:
|
|
return SSHKey(config.push_key.read_text())
|
|
except OSError as exc:
|
|
raise RuntimeError(
|
|
f"cannot read the push key {config.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,
|
|
) -> 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, config.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(),
|
|
)
|