CI / checks (push) Skipped
CI / checks (pull_request) Successful in 3m45s
- Commands that name their item ("go #14", "abandon #14", "stop #14")
apply to it wherever they're said; go, approve and abandon alone at top
level are asked about (L5). stop is never taken as an answer.
- A plain-language reply to a restatement is read by GoAheadReader, one
model call that agrees only on a clear yes; questions never reach it. The
agent says "taking that as a go-ahead on rev-N" first, bound to the
revision on screen at arrival (L3, L5, D1).
- Labels steer (D1): `bug` goes ahead after tuning.loop.bug_grace_s
(default 600 s) unless the operator replies; `discuss` isn't offered
restate until the operator has spoken; `bug`, `discuss` and `design` add
to the driver's instructions. A restatement asks no questions.
Tests: label and command unit tests, the property walk with labelled
issues, plain yeses, named gos and time passing (grace_only_unanswered,
discuss_talks_first), and e2e tests for a bug proceeding after its grace
period and a plain-language go-ahead.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
448 lines
16 KiB
Python
448 lines
16 KiB
Python
"""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_dev.llm import DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_REQUEST_TIMEOUT_S, RoleMap
|
|
from nisse_dev.matrix import MatrixConfig
|
|
|
|
DEFAULT_CONFIG_FILE = "nisse-dev.yaml"
|
|
|
|
REQUIRED_KEYS = (
|
|
"state_db",
|
|
"workspace",
|
|
"project",
|
|
"llm.base_url",
|
|
"llm.token",
|
|
"roles.executor",
|
|
"roles.planner",
|
|
"roles.helper",
|
|
"roles.expert",
|
|
"matrix.homeserver",
|
|
"matrix.user_id",
|
|
"matrix.token",
|
|
"matrix.room_id",
|
|
"matrix.operators",
|
|
"gitea.url",
|
|
"gitea.token",
|
|
"gitea.repo",
|
|
"gitea.operators",
|
|
"build_host.address",
|
|
"build_host.key",
|
|
)
|
|
OPTIONAL_KEYS = (
|
|
"llm.max_output_tokens",
|
|
"llm.request_timeout_s",
|
|
"gitea.base_branch",
|
|
"gitea.push_key",
|
|
"gitea.push_user",
|
|
"gitea.push_token",
|
|
"build_host.user",
|
|
"build_host.port",
|
|
"loop",
|
|
)
|
|
|
|
_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 ExecutorTuning:
|
|
max_turns: int = 40
|
|
failure_limit: int = 2
|
|
|
|
|
|
@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
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Tuning:
|
|
executor: ExecutorTuning = field(default_factory=ExecutorTuning)
|
|
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)
|
|
|
|
|
|
_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 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
|
|
|
|
|
|
@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 AgentConfig:
|
|
state_db: Path
|
|
workspace: Path
|
|
project: str
|
|
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_repo: str
|
|
gitea_operators: frozenset[str]
|
|
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)
|
|
max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS
|
|
request_timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S
|
|
tuning: Tuning = field(default_factory=Tuning)
|
|
# Which loop runs: "old" (control.py) until the switch-over (M2.8), or
|
|
# "new" (nisse_dev.loop).
|
|
loop: str = "old"
|
|
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)
|
|
flat = _flatten(tree)
|
|
flat.pop("tuning", None)
|
|
|
|
known = set(REQUIRED_KEYS) | set(OPTIONAL_KEYS)
|
|
for key in sorted(set(flat) - known):
|
|
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")
|
|
port = values.get("build_host.port", 22)
|
|
try:
|
|
port = int(port)
|
|
if not 0 < port < 65536:
|
|
raise ValueError
|
|
except (TypeError, ValueError):
|
|
errors.append("build_host.port: must be a TCP port number")
|
|
loop = str(values.get("loop") or "old")
|
|
if loop not in ("old", "new"):
|
|
errors.append("loop: must be old or new")
|
|
push_user = values.get("gitea.push_user")
|
|
push_token = values.get("gitea.push_token")
|
|
if bool(push_user) != bool(push_token):
|
|
errors.append("gitea.push_user and gitea.push_token: set both or neither")
|
|
if push_token and values.get("gitea.push_key"):
|
|
errors.append("gitea: set a push token or a push key, not both")
|
|
if errors:
|
|
raise ConfigError("cannot start: " + "; ".join(errors))
|
|
|
|
push_key = values.get("gitea.push_key")
|
|
config = cls(
|
|
state_db=Path(values["state_db"]).expanduser(),
|
|
workspace=Path(values["workspace"]).expanduser(),
|
|
project=str(values["project"]),
|
|
llm_base_url=str(values["llm.base_url"]),
|
|
llm_token=str(values["llm.token"]),
|
|
roles=RoleMap(
|
|
executor=str(values["roles.executor"]),
|
|
planner=str(values["roles.planner"]),
|
|
helper=str(values["roles.helper"]),
|
|
expert=str(values["roles.expert"]),
|
|
),
|
|
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_repo=str(values["gitea.repo"]),
|
|
gitea_operators=_people(values["gitea.operators"]),
|
|
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,
|
|
),
|
|
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,
|
|
base_branch=str(values.get("gitea.base_branch") or "main"),
|
|
max_output_tokens=max_output,
|
|
request_timeout_s=request_timeout,
|
|
tuning=tuning,
|
|
loop=loop,
|
|
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.
|
|
"""
|
|
workspace = self.workspace.expanduser().resolve()
|
|
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))
|
|
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} 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 {
|
|
"project": self.project,
|
|
"workspace": f"{self.build_host.address}:{self.workspace}",
|
|
"repo": self.gitea_repo,
|
|
"planner": self.roles.planner,
|
|
"executor": self.roles.executor,
|
|
"helper": self.roles.helper,
|
|
}
|