CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m16s
Two setup failures found while calibrating the new benchmark tasks, both of which would also fail real milestones. Devstral's chat template (Mistral) rejected any turn in which the model wrote some text before a tool call: the OpenAI renderer made one message per event, so the turn became a text message and a tool-call message in a row, and llama.cpp answered 500. The renderer now folds consecutive user or assistant messages into one, which is the OpenAI format for a turn anyway. Checked against the live server: the captured failing request no longer occurs, over six real runs. LiteLLMClient gave up on any request after a fixed 120 s. A dense 27B model writing a whole module in one turn takes longer, so the executor escalated work that was going well. llm.request_timeout_s now sets it for both clients, default 600. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
329 lines
11 KiB
Python
329 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 == 24 # 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",
|
|
}
|
|
assert AgentConfig.from_file(example, env).tuning.executor.max_turns == 24
|
|
|
|
|
|
# --- nothing that carries secrets may sit inside the clone -----------------
|
|
|
|
|
|
def test_state_db_and_workspace_are_separate() -> None:
|
|
# A2: 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}})
|