M2.8: switch over to the new loop
nisse-dev now runs only the conversational loop. The gated state machine it was built alongside is gone: - Removed control.py, planner.py, executor.py, approvals.py, gates.py (command_form moves to loop/reader.py), publish.py and poller.py, the old assembly in agent.py (its shared helpers move to loop/build.py), the store's state-machine view (get_state, transition), their tests, the old end-to-end and live planning/milestone/gate tests, and specs/orchestrator.qnt with its scenarios. - Roles are driver, helper, expert and classifier; driver is required. loop:, roles.planner, roles.executor and tuning.executor are refused with what to use instead; tuning.driver takes the turn cap and failure limit. - The benchmark and the capability matrix measure the driver: the loop's own system prompt and tools, the task's plan as the agreed restatement. Task plans end with finish_milestone, so earlier runs show as (old); stored runs' executor tuning reads as the driver's. - A delegation on the loop records what its helper's alias resolved to (L9), as the old path's did. - The dump shows a work item's revision and what it waits for. - Behaviour the old tests covered and the loop shares stays covered: driver runs that fail on a model call, stop mid-turn, reset the failure count; go-aheads ignoring case and punctuation; a request too big to fit is never sent; the assembly tests, now on build_loop_agent. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
1 parent
09232dcbdd
commit
6ae2375194
89 files changed
+951
-8592
No files matched your search
@@ -4,7 +4,7 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_dev.config import AgentConfig, ConfigError
|
||||
from nisse_dev.config import AgentConfig, ConfigError, Project
|
||||
from nisse_dev.llm import DEFAULT_REQUEST_TIMEOUT_S
|
||||
|
||||
# A complete config as the YAML file would give it, with secrets by reference.
|
||||
@@ -19,9 +19,8 @@ COMPLETE: dict[str, Any] = {
|
||||
},
|
||||
"llm": {"base_url": "https://litellm.test", "token": "${TEST_LLM_TOKEN}"},
|
||||
"roles": {
|
||||
"executor": "claude-haiku-4-5",
|
||||
"planner": "claude-opus-5",
|
||||
"helper": "claude-opus-5",
|
||||
"driver": "claude-opus-5",
|
||||
"helper": "claude-haiku-4-5",
|
||||
"expert": "claude-haiku-4-5",
|
||||
},
|
||||
"matrix": {
|
||||
@@ -64,22 +63,27 @@ def build(**changes: Any) -> AgentConfig:
|
||||
return AgentConfig.from_tree(tree(**changes), SECRETS)
|
||||
|
||||
|
||||
def only(config: AgentConfig) -> Project:
|
||||
[project] = config.projects
|
||||
return project
|
||||
|
||||
|
||||
def test_complete_config_builds_with_secrets_by_reference() -> None:
|
||||
config = build()
|
||||
assert config.project == "greet"
|
||||
assert only(config).name == "greet"
|
||||
assert config.llm_token == "sk-x"
|
||||
assert config.gitea_token == "gt"
|
||||
assert config.roles.planner == "claude-opus-5"
|
||||
assert config.roles.driver == "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
|
||||
assert only(config).repo == "don/demo"
|
||||
assert only(config).base_branch == "main"
|
||||
assert config.tuning.driver.max_turns == 40 # absent section: defaults
|
||||
|
||||
|
||||
def test_paths_expand_the_home_directory() -> None:
|
||||
config = build()
|
||||
assert config.workspace.is_absolute()
|
||||
assert only(config).workspace.is_absolute()
|
||||
assert "~" not in str(config.state_db)
|
||||
|
||||
|
||||
@@ -119,7 +123,7 @@ def test_an_operator_list_of_only_blanks_is_missing() -> None:
|
||||
|
||||
|
||||
def test_base_branch_is_configurable() -> None:
|
||||
assert build(**{f"{P}base_branch": "trunk"}).base_branch == "trunk"
|
||||
assert only(build(**{f"{P}base_branch": "trunk"})).base_branch == "trunk"
|
||||
|
||||
|
||||
def test_max_output_tokens_is_configurable_and_checked() -> None:
|
||||
@@ -137,8 +141,8 @@ def test_request_timeout_is_configurable_and_checked() -> None:
|
||||
|
||||
|
||||
def test_the_request_timeout_reaches_both_clients() -> None:
|
||||
from nisse_dev.agent import make_llm_clients
|
||||
from nisse_dev.catalog import ModelCatalog
|
||||
from nisse_dev.loop.build import make_llm_clients
|
||||
|
||||
normalized, passthrough = make_llm_clients(
|
||||
build(llm__request_timeout_s=42), ModelCatalog({})
|
||||
@@ -158,7 +162,7 @@ def test_redacted_summary_carries_no_secrets() -> None:
|
||||
def test_tuning_is_read() -> None:
|
||||
config = build(
|
||||
tuning={
|
||||
"executor": {"max_turns": 40, "failure_limit": 3},
|
||||
"driver": {"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},
|
||||
@@ -166,7 +170,7 @@ def test_tuning_is_read() -> None:
|
||||
}
|
||||
)
|
||||
tuning = config.tuning
|
||||
assert (tuning.executor.max_turns, tuning.executor.failure_limit) == (40, 3)
|
||||
assert (tuning.driver.max_turns, tuning.driver.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
|
||||
@@ -175,8 +179,8 @@ def test_tuning_is_read() -> None:
|
||||
|
||||
|
||||
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
|
||||
tuning = build(tuning={"driver": {"max_turns": 40}}).tuning
|
||||
assert tuning.driver.failure_limit == 2
|
||||
|
||||
|
||||
def test_every_problem_is_reported_at_once() -> None:
|
||||
@@ -184,7 +188,7 @@ def test_every_problem_is_reported_at_once() -> None:
|
||||
# with no effect, so unknown keys are errors.
|
||||
bad = tree(llm_extra=1, **{f"{P}workspace": None})
|
||||
bad["tuning"] = {
|
||||
"executor": {"max_turn": 40, "failure_limit": 0},
|
||||
"driver": {"max_turn": 40, "failure_limit": 0},
|
||||
"expert": {"consult_budget": True},
|
||||
"bogus": {"x": 1},
|
||||
}
|
||||
@@ -194,8 +198,8 @@ def test_every_problem_is_reported_at_once() -> None:
|
||||
for expected in (
|
||||
"projects.greet: unset or empty — workspace",
|
||||
"llm_extra: unknown setting",
|
||||
"tuning.executor.max_turn: unknown setting",
|
||||
"tuning.executor.failure_limit: must be a positive number",
|
||||
"tuning.driver.max_turn: unknown setting",
|
||||
"tuning.driver.failure_limit: must be a positive number",
|
||||
"tuning.expert.consult_budget: must be a positive number",
|
||||
"tuning.bogus: unknown section",
|
||||
):
|
||||
@@ -203,8 +207,8 @@ def test_every_problem_is_reported_at_once() -> None:
|
||||
|
||||
|
||||
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}})
|
||||
with pytest.raises(ConfigError, match="tuning.driver.max_turns"):
|
||||
build(tuning={"driver": {"max_turns": 2.5}})
|
||||
|
||||
|
||||
# --- the file --------------------------------------------------------------
|
||||
@@ -220,8 +224,7 @@ projects:
|
||||
key: ~/.ssh/nisse-dev
|
||||
llm: {base_url: "https://litellm.test", token: "${TEST_LLM_TOKEN}"}
|
||||
roles:
|
||||
executor: local-fast
|
||||
planner: claude-opus-5
|
||||
driver: local-fast
|
||||
helper: claude-opus-5
|
||||
expert: claude-haiku-4-5
|
||||
matrix:
|
||||
@@ -245,7 +248,7 @@ def write(directory: Path, text: str = FILE) -> 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.roles.driver == "local-fast"
|
||||
assert config.config_file == tmp_path / "nisse-dev.yaml"
|
||||
|
||||
|
||||
@@ -256,7 +259,9 @@ def test_invalid_yaml_is_reported(tmp_path: Path) -> None:
|
||||
|
||||
def test_load_uses_nisse_config(tmp_path: Path) -> None:
|
||||
path = write(tmp_path)
|
||||
assert AgentConfig.load({**SECRETS, "NISSE_CONFIG": str(path)}).project == "greet"
|
||||
assert (
|
||||
only(AgentConfig.load({**SECRETS, "NISSE_CONFIG": str(path)})).name == "greet"
|
||||
)
|
||||
|
||||
|
||||
def test_load_defaults_to_the_working_directory(
|
||||
@@ -264,7 +269,7 @@ def test_load_defaults_to_the_working_directory(
|
||||
) -> None:
|
||||
write(tmp_path)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert AgentConfig.load(SECRETS).project == "greet"
|
||||
assert only(AgentConfig.load(SECRETS)).name == "greet"
|
||||
|
||||
|
||||
def test_load_without_a_file_says_how_to_make_one(
|
||||
@@ -284,8 +289,8 @@ def test_the_example_file_is_valid() -> None:
|
||||
"NISSE_GITEA_TOKEN": "gt",
|
||||
}
|
||||
config = AgentConfig.from_file(example, env)
|
||||
assert config.tuning.executor.max_turns == 40
|
||||
assert config.roles.executor == "qwen3.8-27b"
|
||||
assert config.tuning.driver.max_turns == 40
|
||||
assert config.roles.driver == "claude-opus-5-5"
|
||||
|
||||
|
||||
# --- nothing that carries secrets may sit inside the clone -----------------
|
||||
@@ -294,7 +299,7 @@ def test_the_example_file_is_valid() -> None:
|
||||
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
|
||||
assert config.state_db.parent != only(config).workspace
|
||||
|
||||
|
||||
def test_state_db_inside_the_clone_is_refused(tmp_path: Path) -> None:
|
||||
@@ -333,7 +338,7 @@ def test_sibling_paths_are_fine(tmp_path: Path) -> None:
|
||||
),
|
||||
{**SECRETS, "NISSE_ENV": str(tmp_path / ".env")},
|
||||
)
|
||||
assert config.workspace.name == "project"
|
||||
assert only(config).workspace.name == "project"
|
||||
|
||||
|
||||
def test_context_tuning_is_read_and_checked() -> None:
|
||||
@@ -346,10 +351,10 @@ def test_context_tuning_is_read_and_checked() -> None:
|
||||
|
||||
|
||||
def test_the_build_host_is_required_and_defaults_to_root_on_22() -> None:
|
||||
config = build()
|
||||
assert (config.build_host.address, config.build_host.user) == ("build.test", "root")
|
||||
assert config.build_host.port == 22
|
||||
assert config.build_host.key.is_absolute()
|
||||
host = only(build()).build_host
|
||||
assert (host.address, host.user) == ("build.test", "root")
|
||||
assert host.port == 22
|
||||
assert host.key.is_absolute()
|
||||
with pytest.raises(ConfigError, match="build_host.address"):
|
||||
build(**{f"{P}build_host__address": None})
|
||||
|
||||
@@ -360,14 +365,14 @@ def test_a_bad_build_host_port_is_reported() -> None:
|
||||
|
||||
|
||||
def test_the_push_key_is_optional_and_a_path() -> None:
|
||||
assert build().push_key is None
|
||||
assert build(**{f"{P}push_key": "~/k"}).push_key == Path("~/k").expanduser()
|
||||
assert only(build()).push_key is None
|
||||
assert only(build(**{f"{P}push_key": "~/k"})).push_key == Path("~/k").expanduser()
|
||||
|
||||
|
||||
def test_a_push_token_needs_its_user_and_excludes_a_key() -> None:
|
||||
token = {f"{P}push_user": "pusher", f"{P}push_token": "${TEST_GITEA_TOKEN}"}
|
||||
config = build(**token)
|
||||
assert (config.push_user, config.push_token) == ("pusher", "gt")
|
||||
project = only(build(**token))
|
||||
assert (project.push_user, project.push_token) == ("pusher", "gt")
|
||||
with pytest.raises(ConfigError, match="set both or neither"):
|
||||
build(**{f"{P}push_user": "pusher"})
|
||||
with pytest.raises(ConfigError, match="not both"):
|
||||
@@ -386,11 +391,21 @@ def test_no_credential_appears_in_the_config_repr() -> None:
|
||||
assert secret not in text
|
||||
|
||||
|
||||
def test_the_loop_defaults_to_old_and_takes_new() -> None:
|
||||
assert build().loop == "old"
|
||||
assert build(loop="new").loop == "new"
|
||||
with pytest.raises(ConfigError, match="loop: must be old or new"):
|
||||
build(loop="newer")
|
||||
def test_the_old_loops_settings_are_refused_with_what_to_do_instead() -> None:
|
||||
old = tree(loop="new", roles__planner="claude-opus-5", roles__driver=None)
|
||||
old["roles"]["executor"] = "local-fast"
|
||||
old["tuning"] = {"executor": {"max_turns": 40}}
|
||||
with pytest.raises(ConfigError) as excinfo:
|
||||
AgentConfig.from_tree(old, SECRETS)
|
||||
message = str(excinfo.value)
|
||||
for expected in (
|
||||
"loop: retired — the loop is the only one now; remove it",
|
||||
"roles.planner: retired — use roles.driver",
|
||||
"roles.executor: retired",
|
||||
"tuning.executor: retired — it is tuning.driver now",
|
||||
"unset or empty — roles.driver",
|
||||
):
|
||||
assert expected in message
|
||||
|
||||
|
||||
# --- projects (D8) -----------------------------------------------------------
|
||||
@@ -410,14 +425,6 @@ def test_a_second_project_is_a_second_entry() -> None:
|
||||
assert other.base_branch == "main" and other.labels == () and other.secrets == {}
|
||||
|
||||
|
||||
def test_the_old_loop_refuses_more_than_one_project() -> None:
|
||||
config = build(projects__other=SECOND)
|
||||
with pytest.raises(ConfigError, match="exactly one project"):
|
||||
config.only_project()
|
||||
with pytest.raises(ConfigError, match="exactly one project"):
|
||||
_ = config.gitea_repo
|
||||
|
||||
|
||||
def test_at_least_one_project_is_needed() -> None:
|
||||
with pytest.raises(ConfigError, match="projects: must name at least one"):
|
||||
build(projects={})
|
||||
@@ -439,8 +446,8 @@ def test_a_project_name_is_a_slug() -> None:
|
||||
|
||||
def test_labels_and_egress_are_lists_of_names() -> None:
|
||||
config = build(**{f"{P}labels": ["discuss"], f"{P}egress": ["pypi.org"]})
|
||||
assert config.only_project().labels == ("discuss",)
|
||||
assert config.only_project().egress == ("pypi.org",)
|
||||
assert only(config).labels == ("discuss",)
|
||||
assert only(config).egress == ("pypi.org",)
|
||||
with pytest.raises(ConfigError, match="projects.greet.labels: must be a list"):
|
||||
build(**{f"{P}labels": "discuss"})
|
||||
|
||||
@@ -454,7 +461,7 @@ def test_secrets_are_by_reference_with_a_visibility() -> None:
|
||||
}
|
||||
}
|
||||
)
|
||||
project = config.only_project()
|
||||
project = only(config)
|
||||
assert project.command_secrets() == {"DB_URL": "sk-x"}
|
||||
assert project.secrets["DEPLOY"].visible == "harness"
|
||||
|
||||
|
||||
Reference in new issue
Block a user