"""Agent configuration (architecture L9: nothing hardcoded). Configuration is a YAML file: roles, connections, and the tuning knobs (see docs/Tuning-Guide.md). It holds no secrets. A credential is written as a reference, ``token: ${NISSE_LLM_TOKEN}``, and resolved from the environment, so the file can be reviewed, shared, or committed without leaking. The environment supplies those references and nothing else: every setting has exactly one home. Every value is collected and validated **before** anything is built, and a single error names every problem at once. The alternative — failing at first use — surfaces a missing token twenty minutes into a milestone, on the call that mattered. Unknown keys are errors too: a misspelt tuning knob that was silently ignored would look exactly like a knob that has no effect. """ import os import re from collections.abc import Mapping from dataclasses import dataclass, field, fields from pathlib import Path from typing import Any import yaml from nisse_core.conversation.matrix import MatrixConfig from nisse_core.knowledge.vault import ACCESS, DEFAULT_IGNORE from nisse_core.models.llm import ( DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_REQUEST_TIMEOUT_S, RoleMap, ) DEFAULT_CONFIG_FILE = "nisse-dev.yaml" REQUIRED_KEYS = ( "state_db", "llm.base_url", "llm.token", "roles.driver", "roles.helper", "roles.expert", "matrix.homeserver", "matrix.user_id", "matrix.token", "matrix.room_id", "matrix.operators", "gitea.url", "gitea.token", "gitea.operators", ) OPTIONAL_KEYS = ( "roles.classifier", "llm.max_output_tokens", "llm.request_timeout_s", ) # Settings the old loop had (until M2.8), and what to do instead. RETIRED_KEYS = { "loop": "the loop is the only one now; remove it", "roles.planner": "use roles.driver", "roles.executor": "the driver does the work now; remove it", } # A project entry (D8), under `projects: {name: {...}}`. PROJECT_REQUIRED = ("repo", "workspace", "build_host.address", "build_host.key") PROJECT_OPTIONAL = ( "base_branch", "push_key", "push_user", "push_token", "build_host.user", "build_host.port", "labels", "secrets", "egress", "trust.merge", ) # The trust dial (L3, D5): who merges a project's PRs. "approved": the # harness merges once an operator approved the PR's current commit and its # checks pass, behind protection that requires that approval. MERGE_MODES = ("operator", "approved") # Who may see a project secret (D7, D8): the model's commands get it as an # environment variable, redacted from what comes back; harness-only ones are # never in a command the model chose. VISIBILITY = ("commands", "harness") _PROJECT_NAME = re.compile(r"[a-z0-9][a-z0-9-]*") GENERAL = "general" # the scope of conversation not about a project (L1) _REFERENCE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") class ConfigError(RuntimeError): pass # --- tuning ---------------------------------------------------------------- # The defaults are the values the agent ran with before they were # configurable, so an absent `tuning:` section changes nothing. @dataclass(frozen=True) class DriverTuning: max_turns: int = 40 failure_limit: int = 2 @dataclass(frozen=True) class PreflightTuning: max_turns: int = 20 # model calls a preflight run may make (M4.1) command_timeout_s: int = 900 # one command in it: installs take a while @dataclass(frozen=True) class ExpertTuning: consult_budget: int = 3 @dataclass(frozen=True) class DelegationTuning: max_turns: int = 30 max_tokens: int = 200_000 wall_clock_s: float = 900.0 @dataclass(frozen=True) class ToolsTuning: command_timeout_s: int = 120 max_output_chars: int = 4000 @dataclass(frozen=True) class ContextTuning: keep_recent_turns: int = 6 safety_margin: float = 0.1 @dataclass(frozen=True) class LoopTuning: # A restatement on a `bug` issue proceeds after this long without a # reply (D1). bug_grace_s: float = 600.0 # A `general` conversation closes after this long without a message (L1). general_idle_s: float = 86400.0 @dataclass(frozen=True) class BuildVMTuning: # An unreachable build VM is tried again this often (#137). retry_s: float = 300.0 # Once it answers, it must still answer this much later before work # goes on, so it has finished booting. settle_s: float = 180.0 @dataclass(frozen=True) class MemoryTuning: # Model calls the memory pass at an item's close may make (L6.4). max_turns: int = 10 @dataclass(frozen=True) class Tuning: driver: DriverTuning = field(default_factory=DriverTuning) preflight: PreflightTuning = field(default_factory=PreflightTuning) expert: ExpertTuning = field(default_factory=ExpertTuning) delegation: DelegationTuning = field(default_factory=DelegationTuning) tools: ToolsTuning = field(default_factory=ToolsTuning) context: ContextTuning = field(default_factory=ContextTuning) loop: LoopTuning = field(default_factory=LoopTuning) build_vm: BuildVMTuning = field(default_factory=BuildVMTuning) memory: MemoryTuning = field(default_factory=MemoryTuning) _TUNING_SECTIONS: dict[str, type] = { f.name: f.default_factory # type: ignore[misc] for f in fields(Tuning) } def _parse_tuning(raw: Any, errors: list[str]) -> Tuning: if raw is None: return Tuning() if not isinstance(raw, dict): errors.append("tuning: must be a mapping") return Tuning() sections: dict[str, Any] = {} for name, section in raw.items(): cls = _TUNING_SECTIONS.get(name) if name == "executor": # until M2.8 errors.append("tuning.executor: retired — it is tuning.driver now") continue if cls is None: errors.append(f"tuning.{name}: unknown section") continue if not isinstance(section, dict): errors.append(f"tuning.{name}: must be a mapping") continue known = {f.name: f for f in fields(cls)} values: dict[str, Any] = {} for key, value in section.items(): spec = known.get(key) if spec is None: errors.append(f"tuning.{name}.{key}: unknown setting") continue number = _positive(value, float if spec.type in (float, "float") else int) if number is None: errors.append(f"tuning.{name}.{key}: must be a positive number") continue values[key] = number sections[name] = cls(**values) margin = sections.get("context", ContextTuning()).safety_margin if margin >= 1: errors.append("tuning.context.safety_margin: must be less than 1") return Tuning(**sections) def _positive(value: Any, kind: type) -> int | float | None: # bool is an int in Python; `true` for a turn limit is a mistake. if isinstance(value, bool) or not isinstance(value, int | float): return None if kind is int and not float(value).is_integer(): return None if value <= 0: return None return kind(value) # --- loading --------------------------------------------------------------- def _people(raw: Any) -> frozenset[str]: """Operator allowlists: a YAML list, or comma-separated in the environment. Blanks are dropped so a trailing comma cannot admit an empty identity.""" parts = raw if isinstance(raw, list) else str(raw).split(",") return frozenset(str(part).strip() for part in parts if str(part).strip()) def _flatten(tree: Mapping[str, Any], prefix: str = "") -> dict[str, Any]: flat: dict[str, Any] = {} for key, value in tree.items(): dotted = f"{prefix}{key}" if isinstance(value, dict) and dotted != "tuning": flat.update(_flatten(value, f"{dotted}.")) else: flat[dotted] = value return flat def _resolve(value: Any, key: str, env: Mapping[str, str], errors: list[str]) -> Any: """Replace ${VAR} references with the environment's value. A reference to an unset variable is an error, never an empty string: an empty token would pass for a value and fail at the first request.""" if not isinstance(value, str): return value def lookup(match: re.Match[str]) -> str: name = match.group(1) found = (env.get(name) or "").strip() if not found: errors.append(f"{key}: references ${{{name}}}, which is unset or empty") return found return _REFERENCE.sub(lookup, value) def _read_file(path: Path, errors: list[str]) -> dict[str, Any]: try: loaded = yaml.safe_load(path.read_text()) except yaml.YAMLError as exc: errors.append(f"{path}: not valid YAML ({exc})") return {} if loaded is None: return {} if not isinstance(loaded, dict): errors.append(f"{path}: must be a mapping at the top level") return {} return loaded def _parse_projects( raw: Any, env: Mapping[str, str], errors: list[str] ) -> tuple["Project", ...]: """`projects:`, a mapping of name to entry (D8). Every problem in every entry is reported, like the rest of the file.""" if not isinstance(raw, dict) or not raw: errors.append("projects: must name at least one project") return () projects: list[Project] = [] repos: dict[str, str] = {} for name, entry in raw.items(): where = f"projects.{name}" if not isinstance(name, str) or not _PROJECT_NAME.fullmatch(name): errors.append(f"{where}: a project name is lowercase letters, digits, -") continue if name == GENERAL: errors.append(f"{where}: `{GENERAL}` is the scope of conversations (L1)") continue if not isinstance(entry, dict): errors.append(f"{where}: must be a mapping") continue project = _parse_project(name, entry, env, errors) if project is None: continue if project.repo in repos: errors.append( f"{where}.repo: {project.repo} is also project {repos[project.repo]}'s" ) repos[project.repo] = name projects.append(project) return tuple(projects) def _parse_project( name: str, entry: Mapping[str, Any], env: Mapping[str, str], errors: list[str] ) -> "Project | None": where = f"projects.{name}" # labels, secrets and egress are structured; the rest flattens. flat = _flatten({k: v for k, v in entry.items() if k not in ("secrets",)}) known = set(PROJECT_REQUIRED) | set(PROJECT_OPTIONAL) for key in sorted(set(flat) - known): errors.append(f"{where}.{key}: unknown setting") values = { key: _resolve(value, f"{where}.{key}", env, errors) for key, value in flat.items() if key in known } missing = [ key for key in PROJECT_REQUIRED if values.get(key) is None or (isinstance(values[key], str) and not values[key].strip()) ] if missing: errors.append(f"{where}: unset or empty — " + ", ".join(missing)) return None port = values.get("build_host.port", 22) try: port = int(port) if not 0 < port < 65536: raise ValueError except (TypeError, ValueError): errors.append(f"{where}.build_host.port: must be a TCP port number") push_user = values.get("push_user") push_token = values.get("push_token") push_key = values.get("push_key") if bool(push_user) != bool(push_token): errors.append(f"{where}: push_user and push_token: set both or neither") if push_token and push_key: errors.append(f"{where}: set a push token or a push key, not both") labels = _strings(values.get("labels", []), f"{where}.labels", errors) egress = _strings(values.get("egress", []), f"{where}.egress", errors) merge = str(values.get("trust.merge") or "operator") if merge not in MERGE_MODES: errors.append(f"{where}.trust.merge: must be one of {', '.join(MERGE_MODES)}") secrets = _parse_secrets(entry.get("secrets"), where, env, errors) return Project( name=name, repo=str(values["repo"]), workspace=Path(values["workspace"]).expanduser(), build_host=BuildHost( address=str(values["build_host.address"]), key=Path(values["build_host.key"]).expanduser(), user=str(values.get("build_host.user") or "root"), port=port if isinstance(port, int) else 22, ), base_branch=str(values.get("base_branch") or "main"), push_key=Path(push_key).expanduser() if push_key else None, push_user=str(push_user) if push_user else None, push_token=str(push_token) if push_token else None, labels=labels, secrets=secrets, egress=egress, merge=merge, ) def _strings(raw: Any, where: str, errors: list[str]) -> tuple[str, ...]: if not isinstance(raw, list) or not all( isinstance(x, str) and x.strip() for x in raw ): errors.append(f"{where}: must be a list of names") return () return tuple(x.strip() for x in raw) def _parse_secrets( raw: Any, where: str, env: Mapping[str, str], errors: list[str] ) -> dict[str, "Secret"]: """name → {value: ${VAR}, visible: commands | harness}. A value must be a reference: secrets live in secret stores, never in this file.""" if raw is None: return {} if not isinstance(raw, dict): errors.append(f"{where}.secrets: must be a mapping of name to secret") return {} secrets: dict[str, Secret] = {} for name, spec in raw.items(): at = f"{where}.secrets.{name}" if not isinstance(name, str) or not re.fullmatch(r"[A-Z_][A-Z0-9_]*", name): errors.append(f"{at}: a secret's name is an environment variable name") continue if not isinstance(spec, dict) or set(spec) - {"value", "visible"}: errors.append(f"{at}: give value and visible, nothing else") continue visible = spec.get("visible") if visible not in VISIBILITY: errors.append(f"{at}.visible: must be one of {', '.join(VISIBILITY)}") continue value = spec.get("value") if not isinstance(value, str) or not _REFERENCE.fullmatch(value.strip()): errors.append(f"{at}.value: must be a ${{VAR}} reference, never inline") continue secrets[name] = Secret(_resolve(value.strip(), at, env, errors), visible) return secrets @dataclass(frozen=True) class BuildHost: """The tool VM the workspace lives on (L13, D3). ``key`` is the path to the agent's SSH private key for it.""" address: str key: Path user: str = "root" port: int = 22 @dataclass(frozen=True) class Profile: """A model's capability profile (L9), from benchmarks: what one role is told about another, never the model's name.""" tools: str = "" # how reliably it keeps the tool-call contract strengths: str = "" weaknesses: str = "" def describe(self) -> str: parts = [ f"tool calls: {self.tools}" if self.tools else "", f"strong at {self.strengths}" if self.strengths else "", f"weak at {self.weaknesses}" if self.weaknesses else "", ] return "; ".join(p for p in parts if p) or "no profile recorded" def _parse_profiles(raw: Any, errors: list[str]) -> dict[str, "Profile"]: """`profiles:`, model alias -> {tools, strengths, weaknesses}.""" if raw is None: return {} if not isinstance(raw, dict): errors.append("profiles: must be a mapping of model alias to profile") return {} profiles: dict[str, Profile] = {} for model, spec in raw.items(): where = f"profiles.{model}" if not isinstance(spec, dict): errors.append(f"{where}: must be a mapping") continue unknown = set(spec) - {"tools", "strengths", "weaknesses"} if unknown: errors.append(f"{where}.{min(unknown)}: unknown setting") continue if not all(isinstance(v, str) for v in spec.values()): errors.append(f"{where}: values are text") continue profiles[str(model)] = Profile(**{k: v.strip() for k, v in spec.items()}) return profiles @dataclass(frozen=True) class Secret: value: str = field(repr=False) visible: str = "harness" # one of VISIBILITY @dataclass(frozen=True) class Project: """A project entry (D8): what makes a project known to the agent, and everything per-project the rest of the agent refers to.""" name: str repo: str workspace: Path # on the build VM build_host: BuildHost base_branch: str = "main" # What may push to the project repository, handed to each push on the # build VM and to nothing else (D4): a push-only account's HTTPS token # (push_user, push_token), or the path to a deploy key (push_key). push_key: Path | None = None push_user: str | None = None push_token: str | None = field(default=None, repr=False) labels: tuple[str, ...] = () # added to every issue's own (D1) secrets: Mapping[str, Secret] = field(default_factory=dict) egress: tuple[str, ...] = () # beyond the default; recorded, not yet enforced merge: str = "operator" # the trust dial (L3): who merges its PRs def command_secrets(self) -> dict[str, str]: """The secrets the model's commands may see, by name.""" return { name: s.value for name, s in self.secrets.items() if s.visible == "commands" } @dataclass(frozen=True) class VaultConfig: """The knowledge vault (L10): where it is, and the folders this agent sees, each "rw" or "ro" (one writer per folder).""" root: Path folders: Mapping[str, str] ignore: tuple[str, ...] = DEFAULT_IGNORE def _parse_vault(raw: Any, errors: list[str]) -> VaultConfig | None: if raw is None: return None # no vault: no note tools if not isinstance(raw, dict): errors.append("vault: must be a mapping") return None for key in sorted(set(raw) - {"root", "folders", "ignore"}): errors.append(f"vault.{key}: unknown setting") root = raw.get("root") if not isinstance(root, str) or not root.strip(): errors.append("vault.root: the vault's folder on the agent host") folders = raw.get("folders") if not isinstance(folders, dict) or not folders: errors.append("vault.folders: a mapping of folder to rw or ro") folders = {} clean: dict[str, str] = {} for folder, access in folders.items(): where = f"vault.folders.{folder}" name = str(folder).strip("/") if not name or name.startswith("/") or ".." in name.split("/"): errors.append(f"{where}: a folder inside the vault") elif access not in ACCESS: errors.append(f"{where}: must be one of {', '.join(ACCESS)}") else: clean[name] = access names = sorted(clean) for a in names: for b in names: if a != b and b.startswith(f"{a}/"): errors.append(f"vault.folders: {b} is inside {a}; one access each") ignore = raw.get("ignore", list(DEFAULT_IGNORE)) if not isinstance(ignore, list) or not all(isinstance(i, str) for i in ignore): errors.append("vault.ignore: a list of name patterns") ignore = list(DEFAULT_IGNORE) if errors: return None return VaultConfig(Path(str(root)).expanduser(), clean, tuple(ignore)) @dataclass(frozen=True) class AgentConfig: state_db: Path llm_base_url: str # Credentials never appear in a repr: a failing test or a log line # would print them. llm_token: str = field(repr=False) roles: RoleMap matrix: MatrixConfig operators: frozenset[str] gitea_url: str gitea_token: str = field(repr=False) gitea_operators: frozenset[str] projects: tuple[Project, ...] profiles: Mapping[str, Profile] = field(default_factory=dict) max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS request_timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S tuning: Tuning = field(default_factory=Tuning) vault: VaultConfig | None = None config_file: Path | None = None @classmethod def load(cls, env: Mapping[str, str] = os.environ) -> "AgentConfig": """The file named by NISSE_CONFIG, or ./nisse-dev.yaml.""" path = Path(env.get("NISSE_CONFIG") or DEFAULT_CONFIG_FILE).expanduser() if not path.is_file(): raise ConfigError( f"cannot start: no config file at {path}; create it from " "nisse-dev.example.yaml, or point NISSE_CONFIG at one" ) return cls.from_file(path, env) @classmethod def from_file( cls, path: Path, env: Mapping[str, str] = os.environ ) -> "AgentConfig": errors: list[str] = [] tree = _read_file(path, errors) return cls._build(tree, env, errors, config_file=path) @classmethod def from_tree( cls, tree: Mapping[str, Any], env: Mapping[str, str] = os.environ ) -> "AgentConfig": """From an already-parsed config, as the file would give it.""" return cls._build(tree, env, [], config_file=None) @classmethod def _build( cls, tree: Mapping[str, Any], env: Mapping[str, str], errors: list[str], *, config_file: Path | None, ) -> "AgentConfig": tuning = _parse_tuning(tree.get("tuning"), errors) projects = _parse_projects(tree.get("projects"), env, errors) profiles = _parse_profiles(tree.get("profiles"), errors) vault = _parse_vault(tree.get("vault"), errors) flat = _flatten( { k: v for k, v in tree.items() if k not in ("projects", "profiles", "vault") } ) flat.pop("tuning", None) known = set(REQUIRED_KEYS) | set(OPTIONAL_KEYS) for key in sorted(set(flat) - known): if key in RETIRED_KEYS: errors.append(f"{key}: retired — {RETIRED_KEYS[key]}") else: errors.append(f"{key}: unknown setting") values = { key: _resolve(value, key, env, errors) for key, value in flat.items() if key in known } # Whitespace-only counts as missing: a token of spaces would pass a # truthiness check and fail at the first request instead. missing = [ key for key in REQUIRED_KEYS if values.get(key) is None or (isinstance(values[key], str) and not values[key].strip()) or (isinstance(values[key], list) and not _people(values[key])) ] if missing: errors.append("unset or empty — " + ", ".join(missing)) max_output = values.get("llm.max_output_tokens", DEFAULT_MAX_OUTPUT_TOKENS) try: max_output = int(max_output) if max_output <= 0: raise ValueError except (TypeError, ValueError): errors.append("llm.max_output_tokens: must be a positive integer") request_timeout = values.get("llm.request_timeout_s", DEFAULT_REQUEST_TIMEOUT_S) try: request_timeout = float(request_timeout) if request_timeout <= 0: raise ValueError except (TypeError, ValueError): errors.append("llm.request_timeout_s: must be a positive number of seconds") if errors: raise ConfigError("cannot start: " + "; ".join(errors)) config = cls( state_db=Path(values["state_db"]).expanduser(), llm_base_url=str(values["llm.base_url"]), llm_token=str(values["llm.token"]), roles=RoleMap( driver=str(values["roles.driver"]), helper=str(values["roles.helper"]), expert=str(values["roles.expert"]), classifier=str(values.get("roles.classifier") or ""), ), matrix=MatrixConfig( homeserver=str(values["matrix.homeserver"]).rstrip("/"), user_id=str(values["matrix.user_id"]), token=str(values["matrix.token"]), room_id=str(values["matrix.room_id"]), ), operators=_people(values["matrix.operators"]), gitea_url=str(values["gitea.url"]), gitea_token=str(values["gitea.token"]), gitea_operators=_people(values["gitea.operators"]), projects=projects, profiles=profiles, max_output_tokens=max_output, request_timeout_s=request_timeout, tuning=tuning, vault=vault, config_file=config_file, ) config._refuse_secrets_inside_the_clone(env) return config def _refuse_secrets_inside_the_clone(self, env: Mapping[str, str]) -> None: """The workspace is a git clone the agent commits with ``git add -A`` and pushes. It lives on the build VM, so this only bites when the build host is this machine (tests, a local bring-up), and there it matters exactly as before. Anything inside it reaches the project repository, so a state store or an env file placed there would publish credentials as a side effect of the agent working correctly — silently, and once. The config file holds no secrets itself, but it names where they come from and how the agent is deployed, which is not the project's business either. """ candidates = [ ("state_db", self.state_db), ("NISSE_ENV", Path(env.get("NISSE_ENV", ".env"))), ] if self.config_file is not None: candidates.append(("NISSE_CONFIG", self.config_file)) for project in self.projects: workspace = project.workspace.expanduser().resolve() offenders = [] for label, path in candidates: resolved = path.expanduser().resolve() if resolved == workspace or workspace in resolved.parents: offenders.append(f"{label} ({resolved})") if offenders: raise ConfigError( "refusing to start: these sit inside the workspace clone at " f"{workspace} (project {project.name}) and would be " "committed and pushed to the project repository — " + ", ".join(offenders) ) def redacted(self) -> dict[str, str]: """What the agent may safely announce about how it is configured.""" return { "projects": ", ".join( f"{p.name} ({p.repo}, {p.build_host.address}:{p.workspace})" for p in self.projects ), "driver": self.roles.driver, "helper": self.roles.helper, }