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
@@ -14,6 +14,7 @@ from nisse_core.log.canonical import (
|
||||
ToolResult,
|
||||
UserMessage,
|
||||
)
|
||||
from nisse_core.loop.driver import Driver
|
||||
from nisse_core.models.catalog import ModelCatalog, UnknownLimits
|
||||
from nisse_core.models.context import (
|
||||
Budget,
|
||||
@@ -25,7 +26,6 @@ from nisse_core.models.context import (
|
||||
)
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.state.store import StateStore
|
||||
from nisse_dev.loop.driver import Driver
|
||||
|
||||
|
||||
def stored(events: list[CanonicalEvent]) -> list[StoredEvent]:
|
||||
@@ -244,6 +244,7 @@ def test_a_request_that_cannot_fit_ends_the_run(tmp_path: Path) -> None:
|
||||
store.transcript("t", "driver"),
|
||||
system="s" * 20_000, # a system prompt too big to fit
|
||||
tools=[],
|
||||
waiting=(),
|
||||
workspace=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""A driver run on its own (L2), as any agent uses it: the agent says which
|
||||
tools wait, and execution goes to whatever executor it gives (L13)."""
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nisse_core.log.canonical import CanonicalEvent, ToolCall, ToolResult
|
||||
from nisse_core.loop.driver import Driver, ToolOutcome
|
||||
from nisse_core.loop.tools import tool
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.state.store import StateStore
|
||||
|
||||
|
||||
class Scripted:
|
||||
def __init__(self, *turns: list[CanonicalEvent]) -> None:
|
||||
self._turns = list(turns)
|
||||
|
||||
def complete(self, events, tools, model, system=None) -> list[CanonicalEvent]:
|
||||
return self._turns.pop(0)
|
||||
|
||||
|
||||
def call(name: str, **arguments: str) -> ToolCall:
|
||||
return ToolCall(call_id=f"c-{name}", tool=name, arguments=arguments)
|
||||
|
||||
|
||||
class Reader:
|
||||
"""Not a workspace: reads from a dict, as a review agent's might."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def dispatch(self, tool: str, arguments: dict[str, Any]) -> ToolOutcome:
|
||||
self.calls.append((tool, arguments))
|
||||
return ToolOutcome({"a.py": "x = 1"}.get(arguments["path"], "none"), True)
|
||||
|
||||
|
||||
READ = tool("read_file", "Read a file.", path="Its path.")
|
||||
POST = tool("post_review", "Post the review. Ends your turn.", body="The review.")
|
||||
ASK = tool("ask_human", "Ask the operator.", question="The question.")
|
||||
|
||||
|
||||
def transcript(tmp_path: Path) -> Any:
|
||||
store = StateStore(tmp_path / "s.db", oplog=OpLog("t", io.StringIO()))
|
||||
return store.transcript("x", "driver")
|
||||
|
||||
|
||||
def results(log: Any) -> list[str]:
|
||||
return [e.event.result for e in log.read() if isinstance(e.event, ToolResult)]
|
||||
|
||||
|
||||
def test_the_agent_says_which_tools_wait(tmp_path: Path) -> None:
|
||||
model = Scripted(
|
||||
[call("read_file", path="a.py")], [call("post_review", body="looks fine")]
|
||||
)
|
||||
reader = Reader()
|
||||
log = transcript(tmp_path)
|
||||
ran = Driver(model, "m").run(
|
||||
log,
|
||||
system="s",
|
||||
tools=[READ, POST],
|
||||
waiting={"post_review"},
|
||||
workspace=reader,
|
||||
)
|
||||
assert (ran.kind, ran.tool, ran.arguments) == (
|
||||
"wait",
|
||||
"post_review",
|
||||
{"body": "looks fine"},
|
||||
)
|
||||
assert reader.calls == [("read_file", {"path": "a.py"})]
|
||||
assert results(log) == ["x = 1", "posted; your turn has ended"]
|
||||
|
||||
|
||||
def test_a_tool_not_named_waiting_doesnt_end_the_run(tmp_path: Path) -> None:
|
||||
# ask_human waits in nisse-dev; here no handler answers it and it isn't
|
||||
# waiting, so it is no tool at all, and the run goes on.
|
||||
model = Scripted([call("ask_human", question="?")], [call("post_review", body="")])
|
||||
log = transcript(tmp_path)
|
||||
ran = Driver(model, "m").run(
|
||||
log, system="s", tools=[ASK, POST], waiting={"post_review"}, workspace=None
|
||||
)
|
||||
assert ran.tool == "post_review"
|
||||
assert results(log)[0].startswith("no such tool: ask_human")
|
||||
@@ -0,0 +1,211 @@
|
||||
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.loop.expert import (
|
||||
DEFAULT_BUDGET,
|
||||
BudgetExhausted,
|
||||
ExpertAnswer,
|
||||
ExpertDesk,
|
||||
parse_answer,
|
||||
)
|
||||
from nisse_core.models.llm import LLMError, RoleMap
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.state.store import Transcript
|
||||
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
|
||||
@@ -0,0 +1,69 @@
|
||||
"""The vault and memory as a loop offers them (L10, L6.4), apart from any
|
||||
agent: tools only for what exists, labelled results, failures as results,
|
||||
notes only about the item's own project, provenance recorded."""
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from nisse_core.knowledge.handlers import Knowledge
|
||||
from nisse_core.knowledge.memory import Memory
|
||||
from nisse_core.knowledge.vault import Vault, split_frontmatter
|
||||
from nisse_core.loop.tools import names
|
||||
from nisse_core.oplog import OpLog
|
||||
|
||||
FOLDER = "memory/agent"
|
||||
|
||||
|
||||
def knowledge(tmp_path: Path, *, memory: bool = True) -> Knowledge:
|
||||
(tmp_path / "notes").mkdir()
|
||||
(tmp_path / "notes" / "Style.md").write_text("Use split.\n")
|
||||
vault = Vault(tmp_path, {FOLDER: "rw", "notes": "ro"}, agent="agent")
|
||||
return Knowledge(
|
||||
vault, Memory(vault, FOLDER) if memory else None, OpLog("t", io.StringIO())
|
||||
)
|
||||
|
||||
|
||||
def test_tools_and_prompt_follow_what_exists(tmp_path: Path) -> None:
|
||||
both = knowledge(tmp_path)
|
||||
assert names(both.tools()) == {
|
||||
"read_note",
|
||||
"list_notes",
|
||||
"search_notes",
|
||||
"remember",
|
||||
"forget",
|
||||
}
|
||||
assert "knowledge vault" in both.prompt() and "Your memory" in both.prompt()
|
||||
none = Knowledge(None, None, OpLog("t", io.StringIO()))
|
||||
assert none.tools() == [] and none.prompt() == "" and none.index("x", None) is None
|
||||
assert none.handlers("x", project=None, provenance={}, say=print) == {}
|
||||
|
||||
|
||||
def test_notes_are_read_labelled_and_failures_are_results(tmp_path: Path) -> None:
|
||||
handlers = knowledge(tmp_path, memory=False).handlers(
|
||||
"x", project=None, provenance={}, say=print
|
||||
)
|
||||
assert "remember" not in handlers
|
||||
assert handlers["read_note"]({"path": "notes/Style.md"}) == (
|
||||
"[vault: notes/Style.md]\nUse split.\n",
|
||||
True,
|
||||
)
|
||||
assert handlers["read_note"]({"path": "notes/No.md"})[1] is False
|
||||
|
||||
|
||||
def test_memory_is_about_the_items_own_project_only(tmp_path: Path) -> None:
|
||||
k = knowledge(tmp_path)
|
||||
said: list[str] = []
|
||||
handlers = k.handlers(
|
||||
"x", project="demo", provenance={"source": "work item x"}, say=said.append
|
||||
)
|
||||
fact = {"name": "short-prs", "description": "Small PRs", "fact": "Don likes them"}
|
||||
assert handlers["remember"]({**fact, "scope": "project"}) == (
|
||||
"remembered short-prs",
|
||||
True,
|
||||
)
|
||||
note = (tmp_path / FOLDER / "projects" / "demo" / "short-prs.md").read_text()
|
||||
assert split_frontmatter(note)[0]["provenance"]["project"] == "demo"
|
||||
assert said == ["Remembered: Small PRs"]
|
||||
assert "short-prs" in (k.index("x", "demo") or "")
|
||||
general = k.handlers("y", project=None, provenance={}, say=said.append)
|
||||
assert general["remember"]({**fact, "scope": "project"})[1] is False
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Configuration any agent builds on: references resolved from the
|
||||
environment, people lists, tuning sections of the agent's own, the LLM's
|
||||
numbers, and the vault; every problem collected, none raised one by one."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from nisse_core.config import (
|
||||
flatten,
|
||||
llm_numbers,
|
||||
parse_tuning,
|
||||
parse_vault,
|
||||
people,
|
||||
resolve,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Review:
|
||||
max_rounds: int = 3
|
||||
wait_s: float = 60.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tuning:
|
||||
review: Review = field(default_factory=Review)
|
||||
|
||||
|
||||
def test_an_agents_own_tuning_sections() -> None:
|
||||
errors: list[str] = []
|
||||
tuning = parse_tuning({"review": {"max_rounds": 2}}, Tuning, errors)
|
||||
assert tuning == Tuning(Review(max_rounds=2)) and errors == []
|
||||
assert parse_tuning(None, Tuning, errors) == Tuning()
|
||||
parse_tuning(
|
||||
{"review": {"max_rounds": 0, "x": 1}, "old": {}, "new": {}},
|
||||
Tuning,
|
||||
errors,
|
||||
retired={"old": "use review"},
|
||||
)
|
||||
assert errors == [
|
||||
"tuning.review.max_rounds: must be a positive number",
|
||||
"tuning.review.x: unknown setting",
|
||||
"tuning.old: retired — use review",
|
||||
"tuning.new: unknown section",
|
||||
]
|
||||
|
||||
|
||||
def test_references_and_people() -> None:
|
||||
errors: list[str] = []
|
||||
env = {"TOKEN": "s3cret"}
|
||||
assert resolve("${TOKEN}", "gitea.token", env, errors) == "s3cret"
|
||||
resolve("${NOPE}", "llm.token", env, errors)
|
||||
assert errors == ["llm.token: references ${NOPE}, which is unset or empty"]
|
||||
assert people("don, ,ann,") == {"don", "ann"}
|
||||
assert flatten({"llm": {"base_url": "u"}, "tuning": {"a": {}}}) == {
|
||||
"llm.base_url": "u",
|
||||
"tuning": {"a": {}},
|
||||
}
|
||||
|
||||
|
||||
def test_llm_numbers_and_the_vault(tmp_path: Path) -> None:
|
||||
errors: list[str] = []
|
||||
assert llm_numbers({"llm.max_output_tokens": 100}, errors)[0] == 100
|
||||
llm_numbers({"llm.request_timeout_s": -1}, errors)
|
||||
assert errors == ["llm.request_timeout_s: must be a positive number of seconds"]
|
||||
vault = parse_vault({"root": str(tmp_path), "folders": {"notes": "ro"}}, [])
|
||||
assert vault is not None and vault.folders == {"notes": "ro"}
|
||||
bad: list[str] = []
|
||||
assert parse_vault({"root": "/v", "folders": {"../x": "rw"}}, bad) is None
|
||||
assert bad == ["vault.folders.../x: a folder inside the vault"]
|
||||
Reference in new issue
Block a user