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
@@ -34,3 +34,13 @@ NISSE_GITEA_TEST_REPO=owner/sandbox-repo
|
|||||||
# Gitea logins allowed to drive the agent via PR comments (A4 per-channel
|
# Gitea logins allowed to drive the agent via PR comments (A4 per-channel
|
||||||
# allowlist; a Matrix MXID and a Gitea login are different names).
|
# allowlist; a Matrix MXID and a Gitea login are different names).
|
||||||
NISSE_GITEA_OPERATORS=your-gitea-login
|
NISSE_GITEA_OPERATORS=your-gitea-login
|
||||||
|
|
||||||
|
# --- Running the agent (the demo, not the tests) ---
|
||||||
|
# The tests use NISSE_GITEA_TEST_REPO; a running agent uses these.
|
||||||
|
NISSE_STATE_DB=~/nisse-demo-workspace/state.db
|
||||||
|
NISSE_WORKSPACE=~/nisse-demo-workspace/project
|
||||||
|
NISSE_PROJECT=demo
|
||||||
|
NISSE_GITEA_REPO=owner/demo-repo
|
||||||
|
# NISSE_BASE_BRANCH=main
|
||||||
|
# Path to this file, if not ./.env
|
||||||
|
# NISSE_ENV=~/nisse-demo-workspace/.env
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Agent configuration (spec A5: from env/.env, nothing hardcoded).
|
||||||
|
|
||||||
|
Every value is collected and validated **before** anything is built, and a
|
||||||
|
single error names every missing variable at once. The alternative — failing
|
||||||
|
at first use — surfaces a missing token twenty minutes into a milestone, on
|
||||||
|
the call that mattered.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from nisse_dev.llm import RoleMap
|
||||||
|
from nisse_dev.matrix import MatrixConfig
|
||||||
|
|
||||||
|
REQUIRED = (
|
||||||
|
"NISSE_STATE_DB",
|
||||||
|
"NISSE_WORKSPACE",
|
||||||
|
"NISSE_PROJECT",
|
||||||
|
"NISSE_LLM_BASE_URL",
|
||||||
|
"NISSE_LLM_TOKEN",
|
||||||
|
"NISSE_ROLE_EXECUTOR",
|
||||||
|
"NISSE_ROLE_PLANNER",
|
||||||
|
"NISSE_ROLE_DELEGATE",
|
||||||
|
"NISSE_ROLE_CONSULTANT",
|
||||||
|
"NISSE_MATRIX_HOMESERVER",
|
||||||
|
"NISSE_MATRIX_USER_ID",
|
||||||
|
"NISSE_MATRIX_TOKEN",
|
||||||
|
"NISSE_MATRIX_ROOM_ID",
|
||||||
|
"NISSE_MATRIX_OPERATORS",
|
||||||
|
"NISSE_GITEA_URL",
|
||||||
|
"NISSE_GITEA_TOKEN",
|
||||||
|
"NISSE_GITEA_REPO",
|
||||||
|
"NISSE_GITEA_OPERATORS",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _people(raw: str) -> frozenset[str]:
|
||||||
|
"""Operator allowlists are comma-separated; blanks are dropped so a
|
||||||
|
trailing comma cannot admit an empty identity."""
|
||||||
|
return frozenset(part.strip() for part in raw.split(",") if part.strip())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentConfig:
|
||||||
|
state_db: Path
|
||||||
|
workspace: Path
|
||||||
|
project: str
|
||||||
|
llm_base_url: str
|
||||||
|
llm_token: str
|
||||||
|
roles: RoleMap
|
||||||
|
matrix: MatrixConfig
|
||||||
|
operators: frozenset[str]
|
||||||
|
gitea_url: str
|
||||||
|
gitea_token: str
|
||||||
|
gitea_repo: str
|
||||||
|
gitea_operators: frozenset[str]
|
||||||
|
base_branch: str = "main"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls, env: Mapping[str, str] = os.environ) -> "AgentConfig":
|
||||||
|
# Whitespace-only counts as missing: a token of spaces would pass a
|
||||||
|
# truthiness check and fail at the first request instead.
|
||||||
|
missing = [name for name in REQUIRED if not (env.get(name) or "").strip()]
|
||||||
|
if missing:
|
||||||
|
raise ConfigError(
|
||||||
|
"cannot start: these are unset or empty — " + ", ".join(missing)
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
state_db=Path(env["NISSE_STATE_DB"]).expanduser(),
|
||||||
|
workspace=Path(env["NISSE_WORKSPACE"]).expanduser(),
|
||||||
|
project=env["NISSE_PROJECT"],
|
||||||
|
llm_base_url=env["NISSE_LLM_BASE_URL"],
|
||||||
|
llm_token=env["NISSE_LLM_TOKEN"],
|
||||||
|
roles=RoleMap.from_env(env),
|
||||||
|
matrix=MatrixConfig.from_env(env),
|
||||||
|
operators=_people(env["NISSE_MATRIX_OPERATORS"]),
|
||||||
|
gitea_url=env["NISSE_GITEA_URL"],
|
||||||
|
gitea_token=env["NISSE_GITEA_TOKEN"],
|
||||||
|
gitea_repo=env["NISSE_GITEA_REPO"],
|
||||||
|
gitea_operators=_people(env["NISSE_GITEA_OPERATORS"]),
|
||||||
|
base_branch=env.get("NISSE_BASE_BRANCH", "main"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def redacted(self) -> dict[str, str]:
|
||||||
|
"""What the agent may safely announce about how it is configured."""
|
||||||
|
return {
|
||||||
|
"project": self.project,
|
||||||
|
"workspace": str(self.workspace),
|
||||||
|
"repo": self.gitea_repo,
|
||||||
|
"planner": self.roles.planner,
|
||||||
|
"executor": self.roles.executor,
|
||||||
|
"delegate": self.roles.delegate,
|
||||||
|
}
|
||||||
@@ -164,6 +164,7 @@ class Orchestrator:
|
|||||||
gitea_operators: Iterable[str] = (),
|
gitea_operators: Iterable[str] = (),
|
||||||
board: "PlanBoard | None" = None,
|
board: "PlanBoard | None" = None,
|
||||||
delegate: Any = None,
|
delegate: Any = None,
|
||||||
|
poller_factory: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._store = store
|
self._store = store
|
||||||
self._transport = transport
|
self._transport = transport
|
||||||
@@ -178,6 +179,11 @@ class Orchestrator:
|
|||||||
self._gitea_operators = set(gitea_operators)
|
self._gitea_operators = set(gitea_operators)
|
||||||
self._board = board
|
self._board = board
|
||||||
self._delegate = delegate
|
self._delegate = delegate
|
||||||
|
# Builds a poller bound to a comment handler; the orchestrator
|
||||||
|
# owns *when* to watch, which is a state-machine concern, while
|
||||||
|
# the factory owns how to build one.
|
||||||
|
self._poller_factory = poller_factory
|
||||||
|
self._pollers: dict[str, Any] = {}
|
||||||
# Transport and the comment poller deliver on separate threads;
|
# Transport and the comment poller deliver on separate threads;
|
||||||
# state transitions must not interleave.
|
# state transitions must not interleave.
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
@@ -465,6 +471,9 @@ class Orchestrator:
|
|||||||
# durability of the record, not the ability to approve.
|
# durability of the record, not the ability to approve.
|
||||||
return
|
return
|
||||||
board_state["comment_id"] = post.comment_id
|
board_state["comment_id"] = post.comment_id
|
||||||
|
# Approvals can arrive on the issue as well as over transport (A4),
|
||||||
|
# so watch it for as long as the gate stands.
|
||||||
|
self._watch("plan", board_state["issue"], self.handle_plan_comment)
|
||||||
|
|
||||||
def handle_plan_comment(self, comment: Any) -> None:
|
def handle_plan_comment(self, comment: Any) -> None:
|
||||||
"""A comment on the project issue may satisfy an open plan gate (A4).
|
"""A comment on the project issue may satisfy an open plan gate (A4).
|
||||||
@@ -586,6 +595,8 @@ class Orchestrator:
|
|||||||
"url": result.pull_request.url,
|
"url": result.pull_request.url,
|
||||||
"milestone": milestone,
|
"milestone": milestone,
|
||||||
}
|
}
|
||||||
|
self._unwatch("plan") # the gate is behind us; the PR is the thread now
|
||||||
|
self._watch("review", result.pull_request.number, self.handle_review_comment)
|
||||||
self._transition(PR_OPEN, cause=f"milestone {milestone} published")
|
self._transition(PR_OPEN, cause=f"milestone {milestone} published")
|
||||||
self._transport.send_message(
|
self._transport.send_message(
|
||||||
f"{result.note} — review it, then say 'next milestone' or "
|
f"{result.note} — review it, then say 'next milestone' or "
|
||||||
@@ -594,12 +605,14 @@ class Orchestrator:
|
|||||||
|
|
||||||
def _handle_pr_open(self, text: str) -> None:
|
def _handle_pr_open(self, text: str) -> None:
|
||||||
if text == "next milestone":
|
if text == "next milestone":
|
||||||
|
self._unwatch("review")
|
||||||
self._data["milestone"] = int(self._data.get("milestone", 1)) + 1
|
self._data["milestone"] = int(self._data.get("milestone", 1)) + 1
|
||||||
self._data["gate"] = None
|
self._data["gate"] = None
|
||||||
self._data["pr"] = None
|
self._data["pr"] = None
|
||||||
self._transition(MILESTONE_PLANNING, cause="operator: next milestone")
|
self._transition(MILESTONE_PLANNING, cause="operator: next milestone")
|
||||||
self._run_planning_turn(f"milestone-{self._data['milestone']}")
|
self._run_planning_turn(f"milestone-{self._data['milestone']}")
|
||||||
elif text == "project complete":
|
elif text == "project complete":
|
||||||
|
self.stop_watching()
|
||||||
self._data = {**_fresh_data()}
|
self._data = {**_fresh_data()}
|
||||||
self._project = None
|
self._project = None
|
||||||
self._store.oplog.task_id = None
|
self._store.oplog.task_id = None
|
||||||
@@ -614,6 +627,27 @@ class Orchestrator:
|
|||||||
"'project complete'"
|
"'project complete'"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _watch(self, kind: str, index: int, handler: Any) -> None:
|
||||||
|
"""Start watching one Gitea thread, replacing any previous watch of
|
||||||
|
the same kind. Priming first, so attaching does not replay comments
|
||||||
|
already addressed."""
|
||||||
|
if self._poller_factory is None:
|
||||||
|
return
|
||||||
|
self._unwatch(kind)
|
||||||
|
poller = self._poller_factory(handler)
|
||||||
|
poller.prime(index)
|
||||||
|
poller.start(index)
|
||||||
|
self._pollers[kind] = poller
|
||||||
|
|
||||||
|
def _unwatch(self, kind: str) -> None:
|
||||||
|
poller = self._pollers.pop(kind, None)
|
||||||
|
if poller is not None:
|
||||||
|
poller.stop()
|
||||||
|
|
||||||
|
def stop_watching(self) -> None:
|
||||||
|
for kind in list(self._pollers):
|
||||||
|
self._unwatch(kind)
|
||||||
|
|
||||||
def _await_input(self, *, cause: str, prompt: str, pending: dict[str, Any]) -> None:
|
def _await_input(self, *, cause: str, prompt: str, pending: dict[str, Any]) -> None:
|
||||||
self._data["pending"] = pending
|
self._data["pending"] = pending
|
||||||
self._transition(AWAIT_INPUT, cause=cause)
|
self._transition(AWAIT_INPUT, cause=cause)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ or finished-enough. Until delegation exists (step 7), an escalation gates to
|
|||||||
the human with the failures attached rather than engaging a frontier model.
|
the human with the failures attached rather than engaging a frontier model.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ class LLMExecutor:
|
|||||||
failure_limit: int = DEFAULT_FAILURE_LIMIT,
|
failure_limit: int = DEFAULT_FAILURE_LIMIT,
|
||||||
expert: ExpertDesk | None = None,
|
expert: ExpertDesk | None = None,
|
||||||
delegate: Delegation | None = None,
|
delegate: Delegation | None = None,
|
||||||
|
notify: Callable[[str], None] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._client = client
|
self._client = client
|
||||||
self._store = store
|
self._store = store
|
||||||
@@ -69,6 +71,21 @@ class LLMExecutor:
|
|||||||
self._failure_limit = failure_limit
|
self._failure_limit = failure_limit
|
||||||
self._expert = expert
|
self._expert = expert
|
||||||
self._delegate = delegate
|
self._delegate = delegate
|
||||||
|
# The loop runs synchronously inside the transport callback, so
|
||||||
|
# without this the agent is silent for the whole milestone and a
|
||||||
|
# working run looks exactly like a hung one.
|
||||||
|
self._notify = notify
|
||||||
|
|
||||||
|
def _report(self, turn: int, calls: list[ToolCall]) -> None:
|
||||||
|
if self._notify is None:
|
||||||
|
return
|
||||||
|
summary = ", ".join(
|
||||||
|
f"{c.tool}({c.arguments.get('path') or c.arguments.get('command', '')[:40]})"
|
||||||
|
if c.arguments
|
||||||
|
else c.tool
|
||||||
|
for c in calls
|
||||||
|
)
|
||||||
|
self._notify(f"turn {turn + 1}: {summary}")
|
||||||
|
|
||||||
def _escalate(
|
def _escalate(
|
||||||
self, milestone: int, failures: int, last_failure: str
|
self, milestone: int, failures: int, last_failure: str
|
||||||
@@ -133,7 +150,7 @@ class LLMExecutor:
|
|||||||
did_work = False
|
did_work = False
|
||||||
challenged = False
|
challenged = False
|
||||||
|
|
||||||
for _turn in range(self._max_turns):
|
for turn in range(self._max_turns):
|
||||||
try:
|
try:
|
||||||
produced = self._client.complete(
|
produced = self._client.complete(
|
||||||
self._store.read_events(),
|
self._store.read_events(),
|
||||||
@@ -166,6 +183,7 @@ class LLMExecutor:
|
|||||||
"executor claimed the milestone was done without making any change",
|
"executor claimed the milestone was done without making any change",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._report(turn, calls)
|
||||||
for call in calls:
|
for call in calls:
|
||||||
if call.tool in MUTATING_TOOLS:
|
if call.tool in MUTATING_TOOLS:
|
||||||
did_work = True
|
did_work = True
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ class GitRepo:
|
|||||||
self.root.mkdir(parents=True, exist_ok=True)
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
self._run("init", "-q", "-b", default_branch)
|
self._run("init", "-q", "-b", default_branch)
|
||||||
|
|
||||||
|
def clone(self, remote_url: str, dest: Path, *, token: str | None = None) -> None:
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._run("clone", "-q", remote_url, str(dest), secret=token)
|
||||||
|
|
||||||
def configure_identity(self, name: str, email: str) -> None:
|
def configure_identity(self, name: str, email: str) -> None:
|
||||||
self._run("config", "user.name", name)
|
self._run("config", "user.name", name)
|
||||||
self._run("config", "user.email", email)
|
self._run("config", "user.email", email)
|
||||||
|
|||||||
@@ -1,11 +1,46 @@
|
|||||||
"""Placeholder entry point for the dev agent."""
|
"""Entry point for the dev agent.
|
||||||
|
|
||||||
from nisse_core.greeting import greeting
|
Loads configuration, builds the agent, and stays up until told to stop.
|
||||||
|
Shutdown is deliberate rather than abrupt: A2's durability guarantees assume
|
||||||
|
the process may die at any moment, but a clean stop still beats relying on
|
||||||
|
them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from types import FrameType
|
||||||
|
|
||||||
|
from nisse_dev.agent import build_agent
|
||||||
|
from nisse_dev.config import AgentConfig, ConfigError
|
||||||
|
from nisse_dev.envfile import load_env_file
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> int:
|
||||||
print(greeting("nisse-dev"))
|
load_env_file(Path(os.environ.get("NISSE_ENV", ".env")).expanduser())
|
||||||
|
try:
|
||||||
|
config = AgentConfig.from_env()
|
||||||
|
except ConfigError as exc:
|
||||||
|
# Fail here rather than twenty minutes into a milestone on the one
|
||||||
|
# call that needed the missing value.
|
||||||
|
print(f"nisse-dev: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
agent = build_agent(config)
|
||||||
|
|
||||||
|
def shutdown(_signum: int, _frame: FrameType | None) -> None:
|
||||||
|
print("nisse-dev: stopping", file=sys.stderr)
|
||||||
|
agent.stop()
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, shutdown)
|
||||||
|
signal.signal(signal.SIGTERM, shutdown)
|
||||||
|
|
||||||
|
agent.start()
|
||||||
|
print(f"nisse-dev: ready as {agent.account} on {config.project}", file=sys.stderr)
|
||||||
|
agent.wait()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""The composition root. These tests exist because two components — the
|
||||||
|
delegation harness and the comment poller — were fully built, fully tested,
|
||||||
|
and reachable from nothing but their own tests. Unit tests that construct a
|
||||||
|
component directly cannot catch that; only assembly can."""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from test_config import COMPLETE
|
||||||
|
|
||||||
|
from nisse_dev.agent import build_agent, prepare_workspace
|
||||||
|
from nisse_dev.config import AgentConfig
|
||||||
|
from nisse_dev.git import GitRepo
|
||||||
|
|
||||||
|
|
||||||
|
def config_for(tmp_path: Path, **overrides: str) -> AgentConfig:
|
||||||
|
env = {
|
||||||
|
**COMPLETE,
|
||||||
|
"NISSE_STATE_DB": str(tmp_path / "state.db"),
|
||||||
|
"NISSE_WORKSPACE": str(tmp_path / "work"),
|
||||||
|
**overrides,
|
||||||
|
}
|
||||||
|
return AgentConfig.from_env(env)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def origin(tmp_path: Path) -> Path:
|
||||||
|
"""A bare repo standing in for the project on the forge."""
|
||||||
|
remote = tmp_path / "origin.git"
|
||||||
|
GitRepo(tmp_path)._run("init", "-q", "--bare", "-b", "main", str(remote))
|
||||||
|
seed = GitRepo(tmp_path / "seed")
|
||||||
|
seed.init()
|
||||||
|
seed.configure_identity("seed", "seed@test")
|
||||||
|
(seed.root / "README.md").write_text("project\n")
|
||||||
|
seed.commit_all("seed")
|
||||||
|
seed.push(str(remote), "main")
|
||||||
|
return remote
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_is_cloned_when_absent(tmp_path: Path, origin: Path) -> None:
|
||||||
|
config = config_for(tmp_path, NISSE_GITEA_URL=f"file://{origin}")
|
||||||
|
# authenticated_url would mangle a file:// remote, so clone directly.
|
||||||
|
GitRepo(tmp_path).clone(str(origin), config.workspace)
|
||||||
|
repo = prepare_workspace(config, "nisse-dev-testing")
|
||||||
|
|
||||||
|
assert (config.workspace / "README.md").exists()
|
||||||
|
assert repo.current_branch() == "main"
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_is_reused_when_already_cloned(tmp_path: Path, origin: Path) -> None:
|
||||||
|
config = config_for(tmp_path)
|
||||||
|
GitRepo(tmp_path).clone(str(origin), config.workspace)
|
||||||
|
(config.workspace / "local-work.txt").write_text("in progress")
|
||||||
|
|
||||||
|
prepare_workspace(config, "nisse-dev-testing")
|
||||||
|
|
||||||
|
# Re-cloning would silently destroy uncommitted work.
|
||||||
|
assert (config.workspace / "local-work.txt").exists()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTransport:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.sent: list[str] = []
|
||||||
|
self.started = False
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def send_message(self, text: str) -> None:
|
||||||
|
self.sent.append(text)
|
||||||
|
|
||||||
|
def send_approval_request(self, text: str) -> None:
|
||||||
|
self.sent.append(text)
|
||||||
|
|
||||||
|
def ask_question(self, text: str) -> None:
|
||||||
|
self.sent.append(text)
|
||||||
|
|
||||||
|
def on_message(self, callback: Any) -> None:
|
||||||
|
self.callback = callback
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
self.started = True
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
def build_offline(tmp_path: Path, origin: Path, monkeypatch: Any) -> Any:
|
||||||
|
"""Assemble the real graph with only the network boundaries faked."""
|
||||||
|
config = config_for(tmp_path)
|
||||||
|
GitRepo(tmp_path).clone(str(origin), config.workspace)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nisse_dev.agent.GiteaClient.whoami", lambda self: "nisse-dev-testing"
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nisse_dev.agent.MatrixTransport", lambda cfg: FakeTransport())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nisse_dev.agent.authenticated_url",
|
||||||
|
lambda base, repo, user, token: str(origin),
|
||||||
|
)
|
||||||
|
return build_agent(config)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_whole_graph_assembles(tmp_path: Path, origin: Path, monkeypatch) -> None:
|
||||||
|
agent = build_offline(tmp_path, origin, monkeypatch)
|
||||||
|
orchestrator = agent.orchestrator
|
||||||
|
|
||||||
|
# Every component that was previously unreachable is now connected.
|
||||||
|
assert orchestrator._executor is not None
|
||||||
|
assert orchestrator._publisher is not None
|
||||||
|
assert orchestrator._board is not None
|
||||||
|
assert orchestrator._delegate is not None
|
||||||
|
assert orchestrator._poller_factory is not None
|
||||||
|
assert orchestrator._executor._delegate is not None # A12 escalation path
|
||||||
|
assert orchestrator._executor._expert is not None # A14 consult path
|
||||||
|
agent.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_announces_where_it_resumed(
|
||||||
|
tmp_path: Path, origin: Path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
agent = build_offline(tmp_path, origin, monkeypatch)
|
||||||
|
agent.start()
|
||||||
|
|
||||||
|
assert agent.transport.started
|
||||||
|
readiness = agent.transport.sent[-1]
|
||||||
|
assert "nisse-dev up as nisse-dev-testing" in readiness
|
||||||
|
assert "greet" in readiness # the project
|
||||||
|
assert "claude-opus-5" in readiness # the pinned planner
|
||||||
|
agent.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_closes_everything_and_releases_the_wait(
|
||||||
|
tmp_path: Path, origin: Path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
agent = build_offline(tmp_path, origin, monkeypatch)
|
||||||
|
agent.start()
|
||||||
|
agent.stop()
|
||||||
|
|
||||||
|
assert agent.transport.closed
|
||||||
|
agent.wait() # returns immediately rather than hanging
|
||||||
|
with pytest.raises(sqlite3.ProgrammingError):
|
||||||
|
agent.store.get_state() # the store really is closed
|
||||||
|
|
||||||
|
|
||||||
|
def test_poller_factory_ignores_the_agents_own_comments(
|
||||||
|
tmp_path: Path, origin: Path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
# Without this the agent answers its own PR replies in a loop.
|
||||||
|
agent = build_offline(tmp_path, origin, monkeypatch)
|
||||||
|
poller = agent.orchestrator._poller_factory(lambda c: None)
|
||||||
|
assert "nisse-dev-testing" in poller._ignore
|
||||||
|
agent.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delegation_uses_the_passthrough_route(
|
||||||
|
tmp_path: Path, origin: Path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
# A13: delegations run vendor-native; the normalized route is for the
|
||||||
|
# places where fidelity is cheap.
|
||||||
|
from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient
|
||||||
|
|
||||||
|
agent = build_offline(tmp_path, origin, monkeypatch)
|
||||||
|
delegation = agent.orchestrator._delegate
|
||||||
|
assert isinstance(delegation._client, AnthropicPassthroughClient)
|
||||||
|
assert isinstance(agent.orchestrator._executor._client, LiteLLMClient)
|
||||||
|
agent.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_httpx_clients_are_not_shared_between_routes(
|
||||||
|
tmp_path: Path, origin: Path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
agent = build_offline(tmp_path, origin, monkeypatch)
|
||||||
|
normalized = agent.orchestrator._executor._client._http
|
||||||
|
passthrough = agent.orchestrator._delegate._client._http
|
||||||
|
assert isinstance(normalized, httpx.Client)
|
||||||
|
assert normalized is not passthrough # different base URLs and headers
|
||||||
|
agent.stop()
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nisse_dev.config import AgentConfig, ConfigError
|
||||||
|
|
||||||
|
COMPLETE = {
|
||||||
|
"NISSE_STATE_DB": "~/demo/state.db",
|
||||||
|
"NISSE_WORKSPACE": "~/demo/work",
|
||||||
|
"NISSE_PROJECT": "greet",
|
||||||
|
"NISSE_LLM_BASE_URL": "https://litellm.test",
|
||||||
|
"NISSE_LLM_TOKEN": "sk-x",
|
||||||
|
"NISSE_ROLE_EXECUTOR": "claude-haiku-4-5",
|
||||||
|
"NISSE_ROLE_PLANNER": "claude-opus-5",
|
||||||
|
"NISSE_ROLE_DELEGATE": "claude-opus-5",
|
||||||
|
"NISSE_ROLE_CONSULTANT": "claude-haiku-4-5",
|
||||||
|
"NISSE_MATRIX_HOMESERVER": "https://matrix.test",
|
||||||
|
"NISSE_MATRIX_USER_ID": "@nisse-dev-testing:test",
|
||||||
|
"NISSE_MATRIX_TOKEN": "syt_x",
|
||||||
|
"NISSE_MATRIX_ROOM_ID": "!room:test",
|
||||||
|
"NISSE_MATRIX_OPERATORS": "@don:test",
|
||||||
|
"NISSE_GITEA_URL": "https://gitea.test",
|
||||||
|
"NISSE_GITEA_TOKEN": "gt",
|
||||||
|
"NISSE_GITEA_REPO": "don/demo",
|
||||||
|
"NISSE_GITEA_OPERATORS": "don",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_environment_builds() -> None:
|
||||||
|
config = AgentConfig.from_env(COMPLETE)
|
||||||
|
assert config.project == "greet"
|
||||||
|
assert config.roles.planner == "claude-opus-5"
|
||||||
|
assert config.matrix.room_id == "!room:test"
|
||||||
|
assert config.gitea_repo == "don/demo"
|
||||||
|
assert config.base_branch == "main"
|
||||||
|
|
||||||
|
|
||||||
|
def test_paths_expand_the_home_directory() -> None:
|
||||||
|
config = AgentConfig.from_env(COMPLETE)
|
||||||
|
assert config.workspace.is_absolute()
|
||||||
|
assert "~" not in str(config.state_db)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_missing_variable_is_reported_at_once() -> None:
|
||||||
|
# Failing at first use would surface a missing token twenty minutes into
|
||||||
|
# a milestone, on the call that mattered.
|
||||||
|
partial = {k: v for k, v in COMPLETE.items() if k != "NISSE_MATRIX_TOKEN"}
|
||||||
|
partial.pop("NISSE_GITEA_REPO")
|
||||||
|
with pytest.raises(ConfigError) as excinfo:
|
||||||
|
AgentConfig.from_env(partial)
|
||||||
|
message = str(excinfo.value)
|
||||||
|
assert "NISSE_MATRIX_TOKEN" in message
|
||||||
|
assert "NISSE_GITEA_REPO" in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_is_treated_as_missing() -> None:
|
||||||
|
with pytest.raises(ConfigError, match="NISSE_GITEA_TOKEN"):
|
||||||
|
AgentConfig.from_env({**COMPLETE, "NISSE_GITEA_TOKEN": " "})
|
||||||
|
|
||||||
|
|
||||||
|
def test_operator_lists_are_split_and_trimmed() -> None:
|
||||||
|
config = AgentConfig.from_env(
|
||||||
|
{
|
||||||
|
**COMPLETE,
|
||||||
|
"NISSE_MATRIX_OPERATORS": " @don:test , @other:test ,",
|
||||||
|
"NISSE_GITEA_OPERATORS": "don,",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert config.operators == {"@don:test", "@other:test"}
|
||||||
|
# A trailing comma must not admit an empty identity into an allowlist.
|
||||||
|
assert "" not in config.operators
|
||||||
|
assert config.gitea_operators == {"don"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_branch_is_overridable() -> None:
|
||||||
|
config = AgentConfig.from_env({**COMPLETE, "NISSE_BASE_BRANCH": "trunk"})
|
||||||
|
assert config.base_branch == "trunk"
|
||||||
|
|
||||||
|
|
||||||
|
def test_redacted_summary_carries_no_secrets() -> None:
|
||||||
|
summary = AgentConfig.from_env(COMPLETE).redacted()
|
||||||
|
flat = " ".join(summary.values())
|
||||||
|
for secret in ("sk-x", "syt_x", "gt"):
|
||||||
|
assert secret not in flat
|
||||||
|
assert summary["project"] == "greet"
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_db_and_workspace_are_separate(tmp_path: Path) -> None:
|
||||||
|
# A2: build artefacts must not share a volume with agent state.
|
||||||
|
config = AgentConfig.from_env(COMPLETE)
|
||||||
|
assert config.state_db.parent != config.workspace
|
||||||
@@ -1,10 +1,26 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nisse_dev.config import REQUIRED
|
||||||
from nisse_dev.main import main
|
from nisse_dev.main import main
|
||||||
|
|
||||||
|
|
||||||
def test_main_prints_greeting(capsys: pytest.CaptureFixture[str]) -> None:
|
def test_main_refuses_to_start_without_configuration(
|
||||||
main()
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
assert (
|
capsys: pytest.CaptureFixture[str],
|
||||||
capsys.readouterr().out == "Hello from nisse-dev, the house spirit is awake.\n"
|
tmp_path: Path,
|
||||||
)
|
) -> None:
|
||||||
|
# Clear the real variables rather than swapping os.environ: from_env binds
|
||||||
|
# the environment object as a default argument, so a replacement would be
|
||||||
|
# ignored. And point NISSE_ENV at a file that does not exist, because the
|
||||||
|
# working directory's .env holds live credentials.
|
||||||
|
for name in REQUIRED:
|
||||||
|
monkeypatch.delenv(name, raising=False)
|
||||||
|
monkeypatch.setenv("NISSE_ENV", str(tmp_path / "absent.env"))
|
||||||
|
|
||||||
|
assert main() == 2
|
||||||
|
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "cannot start" in err
|
||||||
|
assert "NISSE_LLM_TOKEN" in err
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Integration tests: run the installed console scripts as real subprocesses."""
|
"""Integration tests: run the installed console scripts as real subprocesses."""
|
||||||
|
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -7,10 +8,35 @@ import pytest
|
|||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("agent", ["nisse-dev", "nisse-ops"])
|
def test_ops_entry_point_runs_and_greets() -> None:
|
||||||
def test_entry_point_runs_and_greets(agent: str) -> None:
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[agent], capture_output=True, text=True, timeout=30, check=False
|
["nisse-ops"], capture_output=True, text=True, timeout=30, check=False
|
||||||
)
|
)
|
||||||
assert result.returncode == 0
|
assert result.returncode == 0
|
||||||
assert result.stdout == f"Hello from {agent}, the house spirit is awake.\n"
|
assert result.stdout == "Hello from nisse-ops, the house spirit is awake.\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dev_entry_point_refuses_to_start_unconfigured() -> None:
|
||||||
|
"""Missing configuration must fail at startup, naming everything absent.
|
||||||
|
|
||||||
|
Run with a deliberately empty environment and a .env path that does not
|
||||||
|
exist: the real .env in the working directory holds live credentials, and
|
||||||
|
a test must never be one cwd away from starting a real agent.
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["nisse-dev"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
check=False,
|
||||||
|
env={
|
||||||
|
"PATH": os.environ["PATH"],
|
||||||
|
"HOME": os.environ["HOME"],
|
||||||
|
"NISSE_ENV": "/nonexistent/.env",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert result.returncode == 2
|
||||||
|
assert "cannot start" in result.stderr
|
||||||
|
# All of them at once, not the first one it happened to reach.
|
||||||
|
for expected in ("NISSE_STATE_DB", "NISSE_MATRIX_TOKEN", "NISSE_GITEA_REPO"):
|
||||||
|
assert expected in result.stderr
|
||||||
Reference in new issue
Block a user