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
@@ -17,14 +17,23 @@ silently ignored would look exactly like a knob that has no effect.
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field, fields
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from nisse_core.config import (
|
||||
REFERENCE,
|
||||
ConfigError,
|
||||
VaultConfig,
|
||||
flatten,
|
||||
llm_numbers,
|
||||
parse_tuning,
|
||||
parse_vault,
|
||||
people,
|
||||
read_file,
|
||||
resolve,
|
||||
)
|
||||
from nisse_core.conversation.matrix import MatrixConfig
|
||||
from nisse_core.knowledge.vault import ACCESS, DEFAULT_IGNORE
|
||||
from nisse_core.models.llm import (
|
||||
DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
DEFAULT_REQUEST_TIMEOUT_S,
|
||||
@@ -85,12 +94,6 @@ VISIBILITY = ("commands", "harness")
|
||||
_PROJECT_NAME = re.compile(r"[a-z0-9][a-z0-9-]*")
|
||||
GENERAL = "general" # the scope of conversation not about a project (L1)
|
||||
|
||||
_REFERENCE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
|
||||
|
||||
class ConfigError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
# --- tuning ----------------------------------------------------------------
|
||||
# The defaults are the values the agent ran with before they were
|
||||
@@ -170,113 +173,21 @@ class Tuning:
|
||||
memory: MemoryTuning = field(default_factory=MemoryTuning)
|
||||
|
||||
|
||||
_TUNING_SECTIONS: dict[str, type] = {
|
||||
f.name: f.default_factory # type: ignore[misc]
|
||||
for f in fields(Tuning)
|
||||
}
|
||||
|
||||
|
||||
def _parse_tuning(raw: Any, errors: list[str]) -> Tuning:
|
||||
if raw is None:
|
||||
return Tuning()
|
||||
if not isinstance(raw, dict):
|
||||
errors.append("tuning: must be a mapping")
|
||||
return Tuning()
|
||||
sections: dict[str, Any] = {}
|
||||
for name, section in raw.items():
|
||||
cls = _TUNING_SECTIONS.get(name)
|
||||
if name == "executor": # until M2.8
|
||||
errors.append("tuning.executor: retired — it is tuning.driver now")
|
||||
continue
|
||||
if cls is None:
|
||||
errors.append(f"tuning.{name}: unknown section")
|
||||
continue
|
||||
if not isinstance(section, dict):
|
||||
errors.append(f"tuning.{name}: must be a mapping")
|
||||
continue
|
||||
known = {f.name: f for f in fields(cls)}
|
||||
values: dict[str, Any] = {}
|
||||
for key, value in section.items():
|
||||
spec = known.get(key)
|
||||
if spec is None:
|
||||
errors.append(f"tuning.{name}.{key}: unknown setting")
|
||||
continue
|
||||
number = _positive(value, float if spec.type in (float, "float") else int)
|
||||
if number is None:
|
||||
errors.append(f"tuning.{name}.{key}: must be a positive number")
|
||||
continue
|
||||
values[key] = number
|
||||
sections[name] = cls(**values)
|
||||
margin = sections.get("context", ContextTuning()).safety_margin
|
||||
if margin >= 1:
|
||||
tuning = parse_tuning(
|
||||
raw,
|
||||
Tuning,
|
||||
errors,
|
||||
retired={"executor": "it is tuning.driver now"}, # until M2.8
|
||||
)
|
||||
if tuning.context.safety_margin >= 1:
|
||||
errors.append("tuning.context.safety_margin: must be less than 1")
|
||||
return Tuning(**sections)
|
||||
|
||||
|
||||
def _positive(value: Any, kind: type) -> int | float | None:
|
||||
# bool is an int in Python; `true` for a turn limit is a mistake.
|
||||
if isinstance(value, bool) or not isinstance(value, int | float):
|
||||
return None
|
||||
if kind is int and not float(value).is_integer():
|
||||
return None
|
||||
if value <= 0:
|
||||
return None
|
||||
return kind(value)
|
||||
return tuning
|
||||
|
||||
|
||||
# --- loading ---------------------------------------------------------------
|
||||
|
||||
|
||||
def _people(raw: Any) -> frozenset[str]:
|
||||
"""Operator allowlists: a YAML list, or comma-separated in the
|
||||
environment. Blanks are dropped so a trailing comma cannot admit an empty
|
||||
identity."""
|
||||
parts = raw if isinstance(raw, list) else str(raw).split(",")
|
||||
return frozenset(str(part).strip() for part in parts if str(part).strip())
|
||||
|
||||
|
||||
def _flatten(tree: Mapping[str, Any], prefix: str = "") -> dict[str, Any]:
|
||||
flat: dict[str, Any] = {}
|
||||
for key, value in tree.items():
|
||||
dotted = f"{prefix}{key}"
|
||||
if isinstance(value, dict) and dotted != "tuning":
|
||||
flat.update(_flatten(value, f"{dotted}."))
|
||||
else:
|
||||
flat[dotted] = value
|
||||
return flat
|
||||
|
||||
|
||||
def _resolve(value: Any, key: str, env: Mapping[str, str], errors: list[str]) -> Any:
|
||||
"""Replace ${VAR} references with the environment's value. A reference to
|
||||
an unset variable is an error, never an empty string: an empty token would
|
||||
pass for a value and fail at the first request."""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
def lookup(match: re.Match[str]) -> str:
|
||||
name = match.group(1)
|
||||
found = (env.get(name) or "").strip()
|
||||
if not found:
|
||||
errors.append(f"{key}: references ${{{name}}}, which is unset or empty")
|
||||
return found
|
||||
|
||||
return _REFERENCE.sub(lookup, value)
|
||||
|
||||
|
||||
def _read_file(path: Path, errors: list[str]) -> dict[str, Any]:
|
||||
try:
|
||||
loaded = yaml.safe_load(path.read_text())
|
||||
except yaml.YAMLError as exc:
|
||||
errors.append(f"{path}: not valid YAML ({exc})")
|
||||
return {}
|
||||
if loaded is None:
|
||||
return {}
|
||||
if not isinstance(loaded, dict):
|
||||
errors.append(f"{path}: must be a mapping at the top level")
|
||||
return {}
|
||||
return loaded
|
||||
|
||||
|
||||
def _parse_projects(
|
||||
raw: Any, env: Mapping[str, str], errors: list[str]
|
||||
) -> tuple["Project", ...]:
|
||||
@@ -315,12 +226,12 @@ def _parse_project(
|
||||
) -> "Project | None":
|
||||
where = f"projects.{name}"
|
||||
# labels, secrets and egress are structured; the rest flattens.
|
||||
flat = _flatten({k: v for k, v in entry.items() if k not in ("secrets",)})
|
||||
flat = flatten({k: v for k, v in entry.items() if k not in ("secrets",)})
|
||||
known = set(PROJECT_REQUIRED) | set(PROJECT_OPTIONAL)
|
||||
for key in sorted(set(flat) - known):
|
||||
errors.append(f"{where}.{key}: unknown setting")
|
||||
values = {
|
||||
key: _resolve(value, f"{where}.{key}", env, errors)
|
||||
key: resolve(value, f"{where}.{key}", env, errors)
|
||||
for key, value in flat.items()
|
||||
if key in known
|
||||
}
|
||||
@@ -407,10 +318,10 @@ def _parse_secrets(
|
||||
errors.append(f"{at}.visible: must be one of {', '.join(VISIBILITY)}")
|
||||
continue
|
||||
value = spec.get("value")
|
||||
if not isinstance(value, str) or not _REFERENCE.fullmatch(value.strip()):
|
||||
if not isinstance(value, str) or not REFERENCE.fullmatch(value.strip()):
|
||||
errors.append(f"{at}.value: must be a ${{VAR}} reference, never inline")
|
||||
continue
|
||||
secrets[name] = Secret(_resolve(value.strip(), at, env, errors), visible)
|
||||
secrets[name] = Secret(resolve(value.strip(), at, env, errors), visible)
|
||||
return secrets
|
||||
|
||||
|
||||
@@ -501,55 +412,6 @@ class Project:
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VaultConfig:
|
||||
"""The knowledge vault (L10): where it is, and the folders this agent
|
||||
sees, each "rw" or "ro" (one writer per folder)."""
|
||||
|
||||
root: Path
|
||||
folders: Mapping[str, str]
|
||||
ignore: tuple[str, ...] = DEFAULT_IGNORE
|
||||
|
||||
|
||||
def _parse_vault(raw: Any, errors: list[str]) -> VaultConfig | None:
|
||||
if raw is None:
|
||||
return None # no vault: no note tools
|
||||
if not isinstance(raw, dict):
|
||||
errors.append("vault: must be a mapping")
|
||||
return None
|
||||
for key in sorted(set(raw) - {"root", "folders", "ignore"}):
|
||||
errors.append(f"vault.{key}: unknown setting")
|
||||
root = raw.get("root")
|
||||
if not isinstance(root, str) or not root.strip():
|
||||
errors.append("vault.root: the vault's folder on the agent host")
|
||||
folders = raw.get("folders")
|
||||
if not isinstance(folders, dict) or not folders:
|
||||
errors.append("vault.folders: a mapping of folder to rw or ro")
|
||||
folders = {}
|
||||
clean: dict[str, str] = {}
|
||||
for folder, access in folders.items():
|
||||
where = f"vault.folders.{folder}"
|
||||
name = str(folder).strip("/")
|
||||
if not name or name.startswith("/") or ".." in name.split("/"):
|
||||
errors.append(f"{where}: a folder inside the vault")
|
||||
elif access not in ACCESS:
|
||||
errors.append(f"{where}: must be one of {', '.join(ACCESS)}")
|
||||
else:
|
||||
clean[name] = access
|
||||
names = sorted(clean)
|
||||
for a in names:
|
||||
for b in names:
|
||||
if a != b and b.startswith(f"{a}/"):
|
||||
errors.append(f"vault.folders: {b} is inside {a}; one access each")
|
||||
ignore = raw.get("ignore", list(DEFAULT_IGNORE))
|
||||
if not isinstance(ignore, list) or not all(isinstance(i, str) for i in ignore):
|
||||
errors.append("vault.ignore: a list of name patterns")
|
||||
ignore = list(DEFAULT_IGNORE)
|
||||
if errors:
|
||||
return None
|
||||
return VaultConfig(Path(str(root)).expanduser(), clean, tuple(ignore))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentConfig:
|
||||
state_db: Path
|
||||
@@ -587,7 +449,7 @@ class AgentConfig:
|
||||
cls, path: Path, env: Mapping[str, str] = os.environ
|
||||
) -> "AgentConfig":
|
||||
errors: list[str] = []
|
||||
tree = _read_file(path, errors)
|
||||
tree = read_file(path, errors)
|
||||
return cls._build(tree, env, errors, config_file=path)
|
||||
|
||||
@classmethod
|
||||
@@ -609,8 +471,8 @@ class AgentConfig:
|
||||
tuning = _parse_tuning(tree.get("tuning"), errors)
|
||||
projects = _parse_projects(tree.get("projects"), env, errors)
|
||||
profiles = _parse_profiles(tree.get("profiles"), errors)
|
||||
vault = _parse_vault(tree.get("vault"), errors)
|
||||
flat = _flatten(
|
||||
vault = parse_vault(tree.get("vault"), errors)
|
||||
flat = flatten(
|
||||
{
|
||||
k: v
|
||||
for k, v in tree.items()
|
||||
@@ -626,7 +488,7 @@ class AgentConfig:
|
||||
else:
|
||||
errors.append(f"{key}: unknown setting")
|
||||
values = {
|
||||
key: _resolve(value, key, env, errors)
|
||||
key: resolve(value, key, env, errors)
|
||||
for key, value in flat.items()
|
||||
if key in known
|
||||
}
|
||||
@@ -638,26 +500,12 @@ class AgentConfig:
|
||||
for key in REQUIRED_KEYS
|
||||
if values.get(key) is None
|
||||
or (isinstance(values[key], str) and not values[key].strip())
|
||||
or (isinstance(values[key], list) and not _people(values[key]))
|
||||
or (isinstance(values[key], list) and not people(values[key]))
|
||||
]
|
||||
if missing:
|
||||
errors.append("unset or empty — " + ", ".join(missing))
|
||||
|
||||
max_output = values.get("llm.max_output_tokens", DEFAULT_MAX_OUTPUT_TOKENS)
|
||||
try:
|
||||
max_output = int(max_output)
|
||||
if max_output <= 0:
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
errors.append("llm.max_output_tokens: must be a positive integer")
|
||||
|
||||
request_timeout = values.get("llm.request_timeout_s", DEFAULT_REQUEST_TIMEOUT_S)
|
||||
try:
|
||||
request_timeout = float(request_timeout)
|
||||
if request_timeout <= 0:
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
errors.append("llm.request_timeout_s: must be a positive number of seconds")
|
||||
max_output, request_timeout = llm_numbers(values, errors)
|
||||
if errors:
|
||||
raise ConfigError("cannot start: " + "; ".join(errors))
|
||||
|
||||
@@ -677,10 +525,10 @@ class AgentConfig:
|
||||
token=str(values["matrix.token"]),
|
||||
room_id=str(values["matrix.room_id"]),
|
||||
),
|
||||
operators=_people(values["matrix.operators"]),
|
||||
operators=people(values["matrix.operators"]),
|
||||
gitea_url=str(values["gitea.url"]),
|
||||
gitea_token=str(values["gitea.token"]),
|
||||
gitea_operators=_people(values["gitea.operators"]),
|
||||
gitea_operators=people(values["gitea.operators"]),
|
||||
projects=projects,
|
||||
profiles=profiles,
|
||||
max_output_tokens=max_output,
|
||||
|
||||
Reference in new issue
Block a user