M7.2: share the loop's parts
Shared parts, not a shared scheduler (agreed): nisse-review will have its own small scheduler; a common one waits until two exist to compare. - nisse_core.loop.driver: the driver run, moved; the agent names its waiting tools per run (nisse-dev's are WAITING_TOOLS), and execution goes to any Executor (ToolOutcome moves with it). - nisse_core.loop.tools: the tool helper and the tools any agent offers (notes, memory, memory_done, ask_expert); nisse_core.loop.expert. - nisse_core.knowledge.handlers: Knowledge, the vault and memory as control tools, the index and prompt text, out of LoopAgent. - nisse_core.config: file reading, references, people, tuning sections of the agent's own, LLM numbers, the vault. - New core tests: a driver run with a non-workspace executor and the agent's own waiting set; Knowledge alone; the shared config pieces. Docs: the Development Plan marks M7.2 done; Architecture Part L and the nisse-core README name the new parts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
This commit is contained in:
1 parent
90afc4f8dd
commit
f32ccf9881
34 files changed
+899
-485
No files matched your search
@@ -5,8 +5,9 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_core.config import ConfigError
|
||||
from nisse_core.models.llm import DEFAULT_REQUEST_TIMEOUT_S
|
||||
from nisse_dev.config import AgentConfig, ConfigError, Project
|
||||
from nisse_dev.config import AgentConfig, Project
|
||||
|
||||
# A complete config as the YAML file would give it, with secrets by reference.
|
||||
COMPLETE: dict[str, Any] = {
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_core.log.canonical import (
|
||||
AssistantText,
|
||||
CanonicalEvent,
|
||||
DelegationCompleted,
|
||||
DelegationStarted,
|
||||
StoredEvent,
|
||||
)
|
||||
from nisse_core.models.llm import LLMError, RoleMap
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.state.store import Transcript
|
||||
from nisse_dev.expert import (
|
||||
DEFAULT_BUDGET,
|
||||
BudgetExhausted,
|
||||
ExpertAnswer,
|
||||
ExpertDesk,
|
||||
parse_answer,
|
||||
)
|
||||
from nisse_dev.state import StateStore
|
||||
|
||||
ROLES = RoleMap(
|
||||
driver="haiku",
|
||||
helper="haiku",
|
||||
expert="claude-haiku-4-5",
|
||||
)
|
||||
|
||||
GOOD = '{"answer": "Use SQLite; it is enough here.", "confidence": "high", "caveats": "Assumes one writer."}'
|
||||
|
||||
|
||||
class ScriptedClient:
|
||||
def __init__(self, replies: list[str]) -> None:
|
||||
self._replies = replies
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def complete(
|
||||
self,
|
||||
events: list[StoredEvent],
|
||||
tools: list[dict[str, Any]] | None,
|
||||
model: str,
|
||||
system: str | None = None,
|
||||
) -> list[CanonicalEvent]:
|
||||
self.calls.append(
|
||||
{"events": events, "tools": tools, "model": model, "system": system}
|
||||
)
|
||||
if not self._replies:
|
||||
return [AssistantText(text="nothing more", model=model)]
|
||||
return [AssistantText(text=self._replies.pop(0), model=model)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path: Path) -> StateStore:
|
||||
return StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
|
||||
|
||||
|
||||
ITEM = "t" # the work item the consults happen in
|
||||
|
||||
|
||||
def log(store: StateStore, item: str = ITEM) -> Transcript:
|
||||
return store.transcript(item, "expert")
|
||||
|
||||
|
||||
class Bound:
|
||||
"""The desk as the executor uses it: within one work item."""
|
||||
|
||||
def __init__(self, desk: ExpertDesk, store: StateStore, item: str) -> None:
|
||||
self.desk, self.store, self.item = desk, store, item
|
||||
|
||||
def ask(self, question: str, **kw: Any) -> Any:
|
||||
return self.desk.ask(log(self.store, self.item), question, **kw)
|
||||
|
||||
|
||||
def desk(client: Any, store: StateStore, item: str = ITEM, **kw: Any) -> Bound:
|
||||
return Bound(ExpertDesk(client, ROLES, **kw), store, item)
|
||||
|
||||
|
||||
# --- parsing (the contract is prompt-level, so parsing is the guard) ------
|
||||
|
||||
|
||||
def test_parses_a_clean_object() -> None:
|
||||
parsed = parse_answer(GOOD)
|
||||
assert parsed == ExpertAnswer(
|
||||
answer="Use SQLite; it is enough here.",
|
||||
confidence="high",
|
||||
caveats="Assumes one writer.",
|
||||
)
|
||||
|
||||
|
||||
def test_parses_a_fenced_block_because_models_wrap_json() -> None:
|
||||
assert parse_answer(f"Here you go:\n```json\n{GOOD}\n```") is not None
|
||||
|
||||
|
||||
def test_missing_or_bad_fields_are_a_failed_turn() -> None:
|
||||
assert parse_answer('{"answer": "", "confidence": "high"}') is None
|
||||
assert parse_answer('{"answer": "x", "confidence": "certain"}') is None
|
||||
assert parse_answer('{"confidence": "high"}') is None
|
||||
assert parse_answer("no json at all") is None
|
||||
assert parse_answer('["not", "an", "object"]') is None
|
||||
|
||||
|
||||
def test_absent_caveats_is_tolerated() -> None:
|
||||
parsed = parse_answer('{"answer": "yes", "confidence": "low"}')
|
||||
assert parsed is not None
|
||||
assert parsed.caveats == ""
|
||||
|
||||
|
||||
# --- the desk ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ask_returns_a_validated_answer(store: StateStore) -> None:
|
||||
client = ScriptedClient([GOOD])
|
||||
answer = desk(client, store).ask("SQLite or Postgres?")
|
||||
assert answer.confidence == "high"
|
||||
assert "SQLite" in answer.render()
|
||||
|
||||
|
||||
def test_consult_is_stateless_and_uses_the_consultant_role(
|
||||
store: StateStore,
|
||||
) -> None:
|
||||
log(store).append(AssistantText(text="secret repo context", model="m"))
|
||||
client = ScriptedClient([GOOD])
|
||||
desk(client, store).ask("Is this API deprecated?")
|
||||
|
||||
call = client.calls[0]
|
||||
# L9: no repo access, no session state — exactly the question, and none
|
||||
# of the log the caller was working from.
|
||||
assert [e.event.text for e in call["events"]] == ["Is this API deprecated?"]
|
||||
assert all("secret repo context" not in e.event.text for e in call["events"])
|
||||
assert call["tools"] is None
|
||||
assert call["model"] == "claude-haiku-4-5"
|
||||
|
||||
|
||||
def test_unparseable_reply_is_retried_once_then_raises(store: StateStore) -> None:
|
||||
client = ScriptedClient(["waffle", "more waffle"])
|
||||
with pytest.raises(LLMError, match="no usable answer"):
|
||||
desk(client, store).ask("q")
|
||||
assert len(client.calls) == 2
|
||||
assert "not a single valid JSON object" in client.calls[1]["system"]
|
||||
|
||||
|
||||
def test_retry_succeeds_after_a_bad_first_reply(store: StateStore) -> None:
|
||||
client = ScriptedClient(["waffle", GOOD])
|
||||
assert desk(client, store).ask("q").confidence == "high"
|
||||
|
||||
|
||||
def test_consults_are_recorded_as_composite_events(store: StateStore) -> None:
|
||||
# L9 wants a consult/delegation eval set; reusing the pair gets consult
|
||||
# evaluation for free.
|
||||
desk(ScriptedClient([GOOD]), store).ask("q")
|
||||
events = [e.event for e in log(store).read()]
|
||||
started = next(e for e in events if isinstance(e, DelegationStarted))
|
||||
completed = next(e for e in events if isinstance(e, DelegationCompleted))
|
||||
assert started.role == "expert"
|
||||
assert started.brief == "q"
|
||||
assert completed.exit_status == "answered"
|
||||
|
||||
|
||||
def test_a_failed_consult_is_recorded_too(store: StateStore) -> None:
|
||||
with pytest.raises(LLMError):
|
||||
desk(ScriptedClient(["waffle", "waffle"]), store).ask("q")
|
||||
completed = [
|
||||
e.event for e in log(store).read() if isinstance(e.event, DelegationCompleted)
|
||||
]
|
||||
assert completed[0].exit_status == "failed"
|
||||
|
||||
|
||||
def test_budget_is_enforced_across_calls(store: StateStore) -> None:
|
||||
client = ScriptedClient([GOOD] * 10)
|
||||
d = desk(client, store, budget=2)
|
||||
d.ask("one")
|
||||
d.ask("two")
|
||||
with pytest.raises(BudgetExhausted, match="budget of 2"):
|
||||
d.ask("three")
|
||||
assert len(client.calls) == 2 # the third never reached the model
|
||||
|
||||
|
||||
def test_budget_survives_a_restart(tmp_path: Path) -> None:
|
||||
# Counted from the canonical log, not from memory, so a crash cannot
|
||||
# hand a circling executor a fresh allowance.
|
||||
db = tmp_path / "state.db"
|
||||
with StateStore(db, oplog=OpLog("d", io.StringIO())) as store:
|
||||
desk(ScriptedClient([GOOD]), store, budget=1).ask("one")
|
||||
with (
|
||||
StateStore(db, oplog=OpLog("d", io.StringIO())) as store,
|
||||
pytest.raises(BudgetExhausted),
|
||||
):
|
||||
desk(ScriptedClient([GOOD]), store, budget=1).ask("two")
|
||||
|
||||
|
||||
def test_budget_counts_only_after_the_task_marker(store: StateStore) -> None:
|
||||
d = desk(ScriptedClient([GOOD] * 5), store, budget=1)
|
||||
d.ask("earlier task")
|
||||
marker = log(store).read()[-1].id
|
||||
# A new task starts its allowance afresh from its own marker.
|
||||
assert d.ask("new task", since_event_id=marker).confidence == "high"
|
||||
|
||||
|
||||
def test_the_budget_is_per_work_item(store: StateStore) -> None:
|
||||
# Consults in one task never use up another's allowance (L9).
|
||||
desk(ScriptedClient([GOOD]), store, item="a", budget=1).ask("one")
|
||||
assert desk(ScriptedClient([GOOD]), store, item="b", budget=1).ask("two")
|
||||
with pytest.raises(BudgetExhausted):
|
||||
desk(ScriptedClient([GOOD]), store, item="a", budget=1).ask("three")
|
||||
|
||||
|
||||
def test_default_budget_is_small() -> None:
|
||||
assert DEFAULT_BUDGET <= 5 # L9: do not let this become a second subsystem
|
||||
@@ -17,16 +17,16 @@ from nisse_core.log.canonical import (
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
)
|
||||
from nisse_core.loop.driver import Driver, ToolOutcome
|
||||
from nisse_core.models.llm import LLMError
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.state.cancel import Cancel
|
||||
from nisse_core.state.worker import InlineWorker
|
||||
from nisse_dev.loop.agent import LoopAgent
|
||||
from nisse_dev.loop.driver import Driver
|
||||
from nisse_dev.loop.items import CLOSED, GO_AHEAD, READY, RUNNING, WAITING, loop_of
|
||||
from nisse_dev.loop.reader import command_form
|
||||
from nisse_dev.loop.tools import WAITING_TOOLS
|
||||
from nisse_dev.state import StateStore
|
||||
from nisse_dev.tools import ToolOutcome
|
||||
|
||||
DON = "@don:test"
|
||||
|
||||
@@ -357,7 +357,11 @@ def test_calls_after_a_waiting_tool_are_not_run(tmp_path: Path) -> None:
|
||||
)
|
||||
log = driver_log(tmp_path)
|
||||
outcome = Driver(model, "m").run(
|
||||
log, system="s", tools=_tools("ask_human", "run_command"), workspace=None
|
||||
log,
|
||||
system="s",
|
||||
tools=_tools("ask_human", "run_command"),
|
||||
waiting=WAITING_TOOLS,
|
||||
workspace=None,
|
||||
)
|
||||
assert (outcome.kind, outcome.tool) == ("wait", "ask_human")
|
||||
results = [e.event.result for e in log.read() if hasattr(e.event, "result")]
|
||||
@@ -368,7 +372,11 @@ def test_an_invented_tool_is_answered_not_run(tmp_path: Path) -> None:
|
||||
model = ScriptedModel([call("fly")], [call("ask_human", question="?")])
|
||||
log = driver_log(tmp_path)
|
||||
outcome = Driver(model, "m").run(
|
||||
log, system="s", tools=_tools("ask_human"), workspace=None
|
||||
log,
|
||||
system="s",
|
||||
tools=_tools("ask_human"),
|
||||
waiting=WAITING_TOOLS,
|
||||
workspace=None,
|
||||
)
|
||||
assert outcome.kind == "wait"
|
||||
assert any(
|
||||
@@ -379,7 +387,11 @@ def test_an_invented_tool_is_answered_not_run(tmp_path: Path) -> None:
|
||||
def test_the_turn_cap_ends_a_run(tmp_path: Path) -> None:
|
||||
model = ScriptedModel(*[[call("read_file", path="a")]] * 3)
|
||||
outcome = Driver(model, "m", max_turns=3).run(
|
||||
driver_log(tmp_path), system="s", tools=_tools("read_file"), workspace=None
|
||||
driver_log(tmp_path),
|
||||
system="s",
|
||||
tools=_tools("read_file"),
|
||||
waiting=WAITING_TOOLS,
|
||||
workspace=None,
|
||||
)
|
||||
assert outcome.kind == "capped"
|
||||
|
||||
@@ -388,7 +400,11 @@ def test_stop_ends_a_run_at_the_next_boundary(tmp_path: Path) -> None:
|
||||
cancel = Cancel()
|
||||
cancel.set()
|
||||
outcome = Driver(ScriptedModel(), "m", cancel=cancel).run(
|
||||
driver_log(tmp_path), system="s", tools=_tools("read_file"), workspace=None
|
||||
driver_log(tmp_path),
|
||||
system="s",
|
||||
tools=_tools("read_file"),
|
||||
waiting=WAITING_TOOLS,
|
||||
workspace=None,
|
||||
)
|
||||
assert outcome.kind == "stopped"
|
||||
|
||||
@@ -399,7 +415,11 @@ def test_a_failed_model_call_fails_the_run(tmp_path: Path) -> None:
|
||||
raise LLMError("proxy down")
|
||||
|
||||
outcome = Driver(Down(), "m").run( # type: ignore[arg-type]
|
||||
driver_log(tmp_path), system="s", tools=_tools("read_file"), workspace=None
|
||||
driver_log(tmp_path),
|
||||
system="s",
|
||||
tools=_tools("read_file"),
|
||||
waiting=WAITING_TOOLS,
|
||||
workspace=None,
|
||||
)
|
||||
assert outcome.kind == "failed" and "proxy down" in outcome.note
|
||||
|
||||
@@ -419,6 +439,7 @@ def test_stop_mid_turn_skips_the_rest_of_its_calls(tmp_path: Path) -> None:
|
||||
log,
|
||||
system="s",
|
||||
tools=_tools("start_milestone", "read_file"),
|
||||
waiting=WAITING_TOOLS,
|
||||
workspace=None,
|
||||
handlers={"start_milestone": start},
|
||||
)
|
||||
@@ -443,6 +464,7 @@ def test_a_command_that_works_resets_the_failure_count(tmp_path: Path) -> None:
|
||||
driver_log(tmp_path),
|
||||
system="s",
|
||||
tools=_tools("run_command", "ask_human"),
|
||||
waiting=WAITING_TOOLS,
|
||||
workspace=Commands(), # type: ignore[arg-type]
|
||||
)
|
||||
assert outcome.kind == "wait" # never two failures in a row
|
||||
|
||||
@@ -21,12 +21,12 @@ from nisse_core.gitea import (
|
||||
ReviewNote,
|
||||
)
|
||||
from nisse_core.log.canonical import ToolCall
|
||||
from nisse_core.loop.driver import Driver
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.state.cancel import Cancel
|
||||
from nisse_core.state.worker import InlineWorker
|
||||
from nisse_dev.git import GitRepo
|
||||
from nisse_dev.loop.agent import LoopAgent
|
||||
from nisse_dev.loop.driver import Driver
|
||||
from nisse_dev.loop.items import ANSWER, MERGE, WAITING, loop_of
|
||||
from nisse_dev.loop.milestones import OPEN_PR, Milestones
|
||||
from nisse_dev.state import StateStore
|
||||
|
||||
@@ -16,12 +16,12 @@ from test_loop_projects import Router
|
||||
|
||||
from nisse_core.conversation.transport import InMemoryTransport
|
||||
from nisse_core.execution.ssh import SSHHost
|
||||
from nisse_core.loop.driver import Driver
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.state.cancel import Cancel
|
||||
from nisse_core.state.worker import InlineWorker
|
||||
from nisse_dev.config import _parse_projects
|
||||
from nisse_dev.loop.agent import LoopAgent, Project
|
||||
from nisse_dev.loop.driver import Driver
|
||||
from nisse_dev.loop.items import CHAT, GO_AHEAD, MERGE, PROVISION, loop_of
|
||||
from nisse_dev.tools import Workspace
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@ from test_loop_milestones import DON, Forge, ScriptedModel, call, git, make_worl
|
||||
|
||||
from nisse_core.conversation.transport import InMemoryTransport
|
||||
from nisse_core.execution.ssh import SSHHost
|
||||
from nisse_core.loop.driver import Driver
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.state.cancel import Cancel
|
||||
from nisse_core.state.worker import InlineWorker
|
||||
from nisse_dev.loop.agent import LoopAgent, Project
|
||||
from nisse_dev.loop.driver import Driver
|
||||
from nisse_dev.loop.items import ANSWER, GO_AHEAD, MERGE, item_for_issue, loop_of
|
||||
from nisse_dev.tools import Workspace
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ from nisse_core.log.canonical import (
|
||||
ToolResult,
|
||||
UserMessage,
|
||||
)
|
||||
from nisse_core.loop.driver import Driver
|
||||
from nisse_core.models.llm import LLMError, RoleMap
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.state.cancel import Cancel
|
||||
@@ -45,7 +46,6 @@ from nisse_core.state.worker import InlineWorker
|
||||
from nisse_dev.delegation import DELEGATION_INTENT, Caps
|
||||
from nisse_dev.loop.agent import LoopAgent, Project
|
||||
from nisse_dev.loop.classifier import ActionClassifier
|
||||
from nisse_dev.loop.driver import Driver
|
||||
from nisse_dev.loop.help import Helpers
|
||||
from nisse_dev.loop.items import (
|
||||
ANSWER,
|
||||
|
||||
@@ -30,12 +30,12 @@ from nisse_core.conversation.transport import InMemoryTransport
|
||||
from nisse_core.execution.ssh import HostKeyChanged
|
||||
from nisse_core.gitea import Issue
|
||||
from nisse_core.log.canonical import Fact, ToolResult
|
||||
from nisse_core.loop.driver import Driver
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.state.cancel import Cancel
|
||||
from nisse_core.state.worker import InlineWorker
|
||||
from nisse_dev.loop import preflight
|
||||
from nisse_dev.loop.agent import LoopAgent, Project
|
||||
from nisse_dev.loop.driver import Driver
|
||||
from nisse_dev.loop.items import ANSWER, GO_AHEAD, WAITING, loop_of
|
||||
from nisse_dev.tools import Workspace
|
||||
|
||||
|
||||
@@ -20,11 +20,11 @@ from test_loop_milestones import item as the_item
|
||||
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
||||
|
||||
from nisse_core.conversation.transport import InMemoryTransport
|
||||
from nisse_core.loop.driver import Driver
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.state.cancel import Cancel
|
||||
from nisse_core.state.worker import InlineWorker
|
||||
from nisse_dev.loop.agent import LoopAgent, Project
|
||||
from nisse_dev.loop.driver import Driver
|
||||
from nisse_dev.loop.items import MERGE, loop_of
|
||||
from nisse_dev.tools import Workspace
|
||||
|
||||
|
||||
Reference in new issue
Block a user