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
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user