CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m22s
Development Plan M0.1. Every section reference in code, tests, specs/, the example config, the tuning guide, and the CLAUDE.md and README files now cites docs/Architecture.md (P, L, D, O, X, Q) instead of the old spec's A-H, and docs/Architecture-Build-Spec.md is deleted (git keeps it). The plan's old-to-new table goes with it. Mostly mechanical, with a per-site choice where one old section split: A4 became L2 where it meant stop/abandon/the loop and L3 where it meant approvals; A10 became L6 in the context-budget code and L7 elsewhere; B2 became D4 for outbound Gitea and publishing and D5 for review intake; 'A3 step N' became the matching step of L8's boot sequence. Comments describing behaviour the architecture drops (planning always escalating, thresholds tuned at an old build step) now describe the current code and point at the Development Plan. docs/Architecture.md gains one line the rewrite missed: intake outages delay but never fail work (old B2), now in L5, where the poller and Matrix code cite it. Comments and docs only; no behaviour change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
331 lines
11 KiB
Python
331 lines
11 KiB
Python
import copy
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from nisse_dev.config import AgentConfig, ConfigError
|
|
from nisse_dev.llm import DEFAULT_REQUEST_TIMEOUT_S
|
|
|
|
# A complete config as the YAML file would give it, with secrets by reference.
|
|
COMPLETE: dict[str, Any] = {
|
|
"state_db": "~/demo/state.db",
|
|
"workspace": "~/demo/work",
|
|
"project": "greet",
|
|
"llm": {"base_url": "https://litellm.test", "token": "${TEST_LLM_TOKEN}"},
|
|
"roles": {
|
|
"executor": "claude-haiku-4-5",
|
|
"planner": "claude-opus-5",
|
|
"delegate": "claude-opus-5",
|
|
"consultant": "claude-haiku-4-5",
|
|
},
|
|
"matrix": {
|
|
"homeserver": "https://matrix.test/",
|
|
"user_id": "@nisse-dev-testing:test",
|
|
"token": "${TEST_MATRIX_TOKEN}",
|
|
"room_id": "!room:test",
|
|
"operators": ["@don:test"],
|
|
},
|
|
"gitea": {
|
|
"url": "https://gitea.test",
|
|
"token": "${TEST_GITEA_TOKEN}",
|
|
"repo": "don/demo",
|
|
"operators": ["don"],
|
|
},
|
|
}
|
|
SECRETS = {
|
|
"TEST_LLM_TOKEN": "sk-x",
|
|
"TEST_MATRIX_TOKEN": "syt_x",
|
|
"TEST_GITEA_TOKEN": "gt",
|
|
}
|
|
|
|
|
|
def tree(**changes: Any) -> dict[str, Any]:
|
|
"""COMPLETE with dotted keys changed; a value of None removes the key."""
|
|
result = copy.deepcopy(COMPLETE)
|
|
for dotted, value in changes.items():
|
|
*parents, leaf = dotted.split("__")
|
|
node = result
|
|
for part in parents:
|
|
node = node.setdefault(part, {})
|
|
if value is None:
|
|
node.pop(leaf, None)
|
|
else:
|
|
node[leaf] = value
|
|
return result
|
|
|
|
|
|
def build(**changes: Any) -> AgentConfig:
|
|
return AgentConfig.from_tree(tree(**changes), SECRETS)
|
|
|
|
|
|
def test_complete_config_builds_with_secrets_by_reference() -> None:
|
|
config = build()
|
|
assert config.project == "greet"
|
|
assert config.llm_token == "sk-x"
|
|
assert config.gitea_token == "gt"
|
|
assert config.roles.planner == "claude-opus-5"
|
|
assert config.matrix.room_id == "!room:test"
|
|
assert config.matrix.homeserver == "https://matrix.test"
|
|
assert config.gitea_repo == "don/demo"
|
|
assert config.base_branch == "main"
|
|
assert config.tuning.executor.max_turns == 40 # absent section: defaults
|
|
|
|
|
|
def test_paths_expand_the_home_directory() -> None:
|
|
config = build()
|
|
assert config.workspace.is_absolute()
|
|
assert "~" not in str(config.state_db)
|
|
|
|
|
|
def test_every_missing_setting_is_reported_at_once() -> None:
|
|
# Failing at first use would surface a missing token twenty minutes into
|
|
# a milestone, on the call that mattered.
|
|
with pytest.raises(ConfigError) as excinfo:
|
|
build(matrix__token=None, gitea__repo=None)
|
|
message = str(excinfo.value)
|
|
assert "matrix.token" in message
|
|
assert "gitea.repo" in message
|
|
|
|
|
|
def test_empty_is_treated_as_missing() -> None:
|
|
with pytest.raises(ConfigError, match="gitea.repo"):
|
|
build(gitea__repo=" ")
|
|
|
|
|
|
def test_a_reference_to_an_unset_variable_is_an_error() -> None:
|
|
# Never an empty string: an empty token would pass for a value and fail
|
|
# at the first request.
|
|
env = {k: v for k, v in SECRETS.items() if k != "TEST_GITEA_TOKEN"}
|
|
with pytest.raises(ConfigError, match=r"gitea.token: references \$\{TEST_GITEA"):
|
|
AgentConfig.from_tree(tree(), env)
|
|
|
|
|
|
def test_operator_lists_drop_blanks() -> None:
|
|
config = build(matrix__operators=[" @don:test ", "@other:test", " "])
|
|
assert config.operators == {"@don:test", "@other:test"}
|
|
# A blank entry must not admit an empty identity into an allowlist.
|
|
assert "" not in config.operators
|
|
|
|
|
|
def test_an_operator_list_of_only_blanks_is_missing() -> None:
|
|
with pytest.raises(ConfigError, match="gitea.operators"):
|
|
build(gitea__operators=[" "])
|
|
|
|
|
|
def test_base_branch_is_configurable() -> None:
|
|
assert build(gitea__base_branch="trunk").base_branch == "trunk"
|
|
|
|
|
|
def test_max_output_tokens_is_configurable_and_checked() -> None:
|
|
assert build(llm__max_output_tokens=8192).max_output_tokens == 8192
|
|
with pytest.raises(ConfigError, match="llm.max_output_tokens"):
|
|
build(llm__max_output_tokens=0)
|
|
|
|
|
|
def test_request_timeout_is_configurable_and_checked() -> None:
|
|
assert build().request_timeout_s == DEFAULT_REQUEST_TIMEOUT_S
|
|
assert build(llm__request_timeout_s=900).request_timeout_s == 900.0
|
|
for bad in (0, -5, "soon"):
|
|
with pytest.raises(ConfigError, match="llm.request_timeout_s"):
|
|
build(llm__request_timeout_s=bad)
|
|
|
|
|
|
def test_the_request_timeout_reaches_both_clients() -> None:
|
|
from nisse_dev.agent import make_llm_clients
|
|
from nisse_dev.catalog import ModelCatalog
|
|
|
|
normalized, passthrough = make_llm_clients(
|
|
build(llm__request_timeout_s=42), ModelCatalog({})
|
|
)
|
|
for client in (normalized, passthrough):
|
|
assert client._inner._http.timeout.read == 42.0
|
|
|
|
|
|
def test_redacted_summary_carries_no_secrets() -> None:
|
|
summary = build().redacted()
|
|
flat = " ".join(summary.values())
|
|
for secret in SECRETS.values():
|
|
assert secret not in flat
|
|
assert summary["project"] == "greet"
|
|
|
|
|
|
def test_tuning_is_read() -> None:
|
|
config = build(
|
|
tuning={
|
|
"executor": {"max_turns": 40, "failure_limit": 3},
|
|
"expert": {"consult_budget": 5},
|
|
"delegation": {"max_turns": 20, "max_tokens": 100000, "wall_clock_s": 600},
|
|
"tools": {"command_timeout_s": 300, "max_output_chars": 2000},
|
|
}
|
|
)
|
|
tuning = config.tuning
|
|
assert (tuning.executor.max_turns, tuning.executor.failure_limit) == (40, 3)
|
|
assert tuning.expert.consult_budget == 5
|
|
assert tuning.delegation.max_tokens == 100_000
|
|
assert tuning.delegation.wall_clock_s == 600.0
|
|
assert tuning.tools.max_output_chars == 2000
|
|
|
|
|
|
def test_a_partly_given_tuning_section_keeps_the_other_defaults() -> None:
|
|
tuning = build(tuning={"executor": {"max_turns": 40}}).tuning
|
|
assert tuning.executor.failure_limit == 2
|
|
|
|
|
|
def test_every_problem_is_reported_at_once() -> None:
|
|
# A misspelt knob that was silently ignored would look exactly like a knob
|
|
# with no effect, so unknown keys are errors.
|
|
bad = tree(project=None, llm_extra=1)
|
|
bad["tuning"] = {
|
|
"executor": {"max_turn": 40, "failure_limit": 0},
|
|
"expert": {"consult_budget": True},
|
|
"bogus": {"x": 1},
|
|
}
|
|
with pytest.raises(ConfigError) as excinfo:
|
|
AgentConfig.from_tree(bad, SECRETS)
|
|
message = str(excinfo.value)
|
|
for expected in (
|
|
"project",
|
|
"llm_extra: unknown setting",
|
|
"tuning.executor.max_turn: unknown setting",
|
|
"tuning.executor.failure_limit: must be a positive number",
|
|
"tuning.expert.consult_budget: must be a positive number",
|
|
"tuning.bogus: unknown section",
|
|
):
|
|
assert expected in message
|
|
|
|
|
|
def test_a_whole_number_is_required_where_one_is_meant() -> None:
|
|
with pytest.raises(ConfigError, match="tuning.executor.max_turns"):
|
|
build(tuning={"executor": {"max_turns": 2.5}})
|
|
|
|
|
|
# --- the file --------------------------------------------------------------
|
|
|
|
FILE = """
|
|
state_db: ~/demo/state.db
|
|
workspace: ~/demo/work
|
|
project: greet
|
|
llm: {base_url: "https://litellm.test", token: "${TEST_LLM_TOKEN}"}
|
|
roles:
|
|
executor: local-fast
|
|
planner: claude-opus-5
|
|
delegate: claude-opus-5
|
|
consultant: claude-haiku-4-5
|
|
matrix:
|
|
homeserver: https://matrix.test
|
|
user_id: "@nisse-dev-testing:test"
|
|
token: ${TEST_MATRIX_TOKEN}
|
|
room_id: "!room:test"
|
|
operators: ["@don:test"]
|
|
gitea:
|
|
url: https://gitea.test
|
|
token: ${TEST_GITEA_TOKEN}
|
|
repo: don/demo
|
|
operators: [don]
|
|
"""
|
|
|
|
|
|
def write(directory: Path, text: str = FILE) -> Path:
|
|
path = directory / "nisse-dev.yaml"
|
|
path.write_text(text)
|
|
return path
|
|
|
|
|
|
def test_a_file_is_read(tmp_path: Path) -> None:
|
|
config = AgentConfig.from_file(write(tmp_path), SECRETS)
|
|
assert config.roles.executor == "local-fast"
|
|
assert config.config_file == tmp_path / "nisse-dev.yaml"
|
|
|
|
|
|
def test_invalid_yaml_is_reported(tmp_path: Path) -> None:
|
|
with pytest.raises(ConfigError, match="not valid YAML"):
|
|
AgentConfig.from_file(write(tmp_path, "roles: [unclosed"), SECRETS)
|
|
|
|
|
|
def test_load_uses_nisse_config(tmp_path: Path) -> None:
|
|
path = write(tmp_path)
|
|
assert AgentConfig.load({**SECRETS, "NISSE_CONFIG": str(path)}).project == "greet"
|
|
|
|
|
|
def test_load_defaults_to_the_working_directory(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
write(tmp_path)
|
|
monkeypatch.chdir(tmp_path)
|
|
assert AgentConfig.load(SECRETS).project == "greet"
|
|
|
|
|
|
def test_load_without_a_file_says_how_to_make_one(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
with pytest.raises(ConfigError, match="nisse-dev.example.yaml"):
|
|
AgentConfig.load(SECRETS)
|
|
|
|
|
|
def test_the_example_file_is_valid() -> None:
|
|
# The example documents every knob; it must not drift out of loading.
|
|
example = Path(__file__).resolve().parents[2] / "nisse-dev.example.yaml"
|
|
env = {
|
|
"NISSE_LLM_TOKEN": "sk-x",
|
|
"NISSE_MATRIX_TOKEN": "syt_x",
|
|
"NISSE_GITEA_TOKEN": "gt",
|
|
}
|
|
config = AgentConfig.from_file(example, env)
|
|
assert config.tuning.executor.max_turns == 40
|
|
assert config.roles.executor == "qwen3.8-27b"
|
|
|
|
|
|
# --- nothing that carries secrets may sit inside the clone -----------------
|
|
|
|
|
|
def test_state_db_and_workspace_are_separate() -> None:
|
|
# L8: build artefacts must not share a volume with agent state.
|
|
config = build()
|
|
assert config.state_db.parent != config.workspace
|
|
|
|
|
|
def test_state_db_inside_the_clone_is_refused(tmp_path: Path) -> None:
|
|
# git add -A would commit it, and the push would publish the agent's
|
|
# own state alongside the project.
|
|
work = tmp_path / "project"
|
|
with pytest.raises(ConfigError, match="inside the workspace"):
|
|
build(workspace=str(work), state_db=str(work / "state.db"))
|
|
|
|
|
|
def test_env_file_inside_the_clone_is_refused(tmp_path: Path) -> None:
|
|
# The severe case: the agent would commit and push its own credentials
|
|
# as a side effect of working correctly.
|
|
work = tmp_path / "project"
|
|
with pytest.raises(ConfigError, match="NISSE_ENV"):
|
|
AgentConfig.from_tree(
|
|
tree(workspace=str(work), state_db=str(tmp_path / "state.db")),
|
|
{**SECRETS, "NISSE_ENV": str(work / ".env")},
|
|
)
|
|
|
|
|
|
def test_a_config_file_inside_the_clone_is_refused(tmp_path: Path) -> None:
|
|
work = tmp_path / "project"
|
|
work.mkdir()
|
|
text = FILE.replace("workspace: ~/demo/work", f"workspace: {work}")
|
|
with pytest.raises(ConfigError, match="NISSE_CONFIG"):
|
|
AgentConfig.from_file(write(work, text), SECRETS)
|
|
|
|
|
|
def test_sibling_paths_are_fine(tmp_path: Path) -> None:
|
|
config = AgentConfig.from_tree(
|
|
tree(workspace=str(tmp_path / "project"), state_db=str(tmp_path / "state.db")),
|
|
{**SECRETS, "NISSE_ENV": str(tmp_path / ".env")},
|
|
)
|
|
assert config.workspace.name == "project"
|
|
|
|
|
|
def test_context_tuning_is_read_and_checked() -> None:
|
|
config = build(tuning={"context": {"keep_recent_turns": 4, "safety_margin": 0.2}})
|
|
assert config.tuning.context.keep_recent_turns == 4
|
|
assert config.tuning.context.safety_margin == 0.2
|
|
assert build().tuning.context.keep_recent_turns == 6
|
|
with pytest.raises(ConfigError, match="safety_margin: must be less than 1"):
|
|
build(tuning={"context": {"safety_margin": 1}})
|