* refactor(dream): replace two-phase Dream class with simple cron + process_direct - Remove the heavyweight Dream class (AgentRunner-based two-phase system) from nanobot/agent/memory.py - Delete dream_phase1.md and dream_phase2.md templates - New dream.md template serves as the consolidation prompt - Cron callback uses agent.process_direct(prompt, session_key=\"dream\") instead of agent.dream.run() - Always performs git auto_commit after execution - /dream command updated to use process_direct + git commit - DreamConfig kept for backward compatibility; deprecated fields (model_override, max_batch_size, max_iterations, annotate_line_ages) are ignored but accepted in config - interval_h remains configurable via agents.defaults.dream.interval_h - Update tests and webui settings to match new architecture * feat(loop): add ephemeral mode to process_direct, skip history writes for Dream When ephemeral=True, _state_save skips enforce_file_cap (which calls raw_archive -> append_history) and consolidator.maybe_consolidate_by_tokens. This prevents Dream sessions from creating a positive feedback loop where they process their own output. The session IS still saved to disk. * fix(loop): skip extra hooks for ephemeral sessions (Dream) * feat(dream): per-run timestamped sessions with rotation for WebUI * test(config): restore DreamConfig schedule and alias tests * fix(dream): include LLM response summary in git auto-commit message The old two-phase Dream class included the Phase 1 analysis in the git commit message body. The new single-phase version lost this. Restore it by extracting resp.content from the process_direct return value and appending it to the commit message in both the cron handler and the /dream command. * fix(test): accept ephemeral kwarg in test_openai_api fake_process * refactor(dream): merge dream_session.py into MemoryStore The standalone dream_session.py module only contained three small helpers that all revolve around MemoryStore concerns (session keys, commit messages, file pruning). Fold them into MemoryStore as @staticmethod to reduce indirection and avoid a 35-line module with no independent reason to exist. * fix(test): address code review — patch correct instance, use actual function - Fix test_ephemeral_skips_raw_archive to patch loop.context.memory instead of the fixture's separate MemoryStore instance - Fix TestDreamCommitMessage to call MemoryStore.build_dream_commit_message instead of reimplementing the logic inline - Move Dream helpers in memory.py above the Consolidator section comment to avoid misleading visual boundary * fix(dream): gate cursor advancement and restrict tools maintainer edit: Dream now processes backlog from the oldest unprocessed entries, only advances the cursor after a completed ephemeral run, and uses a restricted file-only tool registry for background consolidation. * fix(dream): skip idle compact for dream sessions Dream runs use internal dream:* sessions that are pruned by Dream retention. Exclude them from AutoCompact scheduling, archive execution, and summary injection so idle-session compaction cannot truncate Dream transcripts. * fix(dream): keep batched history isolated * feat(dream): tag archived memory for single-phase Dream --------- Co-authored-by: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
178 lines
5.9 KiB
Python
178 lines
5.9 KiB
Python
"""Configuration loading utilities."""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pydantic
|
|
from loguru import logger
|
|
from pydantic import BaseModel
|
|
|
|
from nanobot.config.schema import Config, _resolve_tool_config_refs
|
|
|
|
# Global variable to store current config path (for multi-instance support)
|
|
_current_config_path: Path | None = None
|
|
_schema_refs_ready = False
|
|
|
|
|
|
def set_config_path(path: Path) -> None:
|
|
"""Set the current config path (used to derive data directory)."""
|
|
global _current_config_path
|
|
_current_config_path = path
|
|
|
|
|
|
def get_config_path() -> Path:
|
|
"""Get the configuration file path."""
|
|
if _current_config_path:
|
|
return _current_config_path
|
|
return Path.home() / ".nanobot" / "config.json"
|
|
|
|
|
|
def load_config(config_path: Path | None = None) -> Config:
|
|
"""
|
|
Load configuration from file or create default.
|
|
|
|
Args:
|
|
config_path: Optional path to config file. Uses default if not provided.
|
|
|
|
Returns:
|
|
Loaded configuration object.
|
|
"""
|
|
global _schema_refs_ready
|
|
if not _schema_refs_ready:
|
|
_resolve_tool_config_refs()
|
|
_schema_refs_ready = True
|
|
|
|
path = config_path or get_config_path()
|
|
|
|
config = Config()
|
|
if path.exists():
|
|
try:
|
|
with open(path, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
data = _migrate_config(data)
|
|
config = Config.model_validate(data)
|
|
except (json.JSONDecodeError, ValueError, pydantic.ValidationError) as e:
|
|
logger.warning("Failed to load config from {}: {}", path, e)
|
|
logger.warning("Using default configuration.")
|
|
|
|
_apply_ssrf_whitelist(config)
|
|
return config
|
|
|
|
|
|
def _apply_ssrf_whitelist(config: Config) -> None:
|
|
"""Apply SSRF whitelist from config to the network security module."""
|
|
from nanobot.security.network import configure_ssrf_whitelist
|
|
|
|
configure_ssrf_whitelist(config.tools.ssrf_whitelist)
|
|
|
|
|
|
def save_config(config: Config, config_path: Path | None = None) -> None:
|
|
"""
|
|
Save configuration to file.
|
|
|
|
Args:
|
|
config: Configuration to save.
|
|
config_path: Optional path to save to. Uses default if not provided.
|
|
"""
|
|
path = config_path or get_config_path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
data = config.model_dump(mode="json", by_alias=True)
|
|
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
|
|
|
|
_ENV_REF_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
|
|
|
|
def resolve_config_env_vars(config: Config) -> Config:
|
|
"""Return *config* with ``${VAR}`` env-var references resolved.
|
|
|
|
Walks in place so fields declared with ``exclude=True`` survive;
|
|
returns the same instance when no references are present.
|
|
Raises ``ValueError`` if a referenced variable is not set.
|
|
"""
|
|
return _resolve_in_place(config)
|
|
|
|
|
|
def _resolve_in_place(obj: Any) -> Any:
|
|
if isinstance(obj, str):
|
|
new = _ENV_REF_PATTERN.sub(_env_replace, obj)
|
|
return new if new != obj else obj
|
|
if isinstance(obj, BaseModel):
|
|
updates: dict[str, Any] = {}
|
|
for name in type(obj).model_fields:
|
|
old = getattr(obj, name)
|
|
new = _resolve_in_place(old)
|
|
if new is not old:
|
|
updates[name] = new
|
|
extras = obj.__pydantic_extra__
|
|
new_extras: dict[str, Any] | None = None
|
|
if extras:
|
|
resolved = {k: _resolve_in_place(v) for k, v in extras.items()}
|
|
if any(resolved[k] is not extras[k] for k in extras):
|
|
new_extras = resolved
|
|
if not updates and new_extras is None:
|
|
return obj
|
|
copy = obj.model_copy(update=updates) if updates else obj.model_copy()
|
|
if new_extras is not None:
|
|
copy.__pydantic_extra__ = new_extras
|
|
return copy
|
|
if isinstance(obj, dict):
|
|
resolved = {k: _resolve_in_place(v) for k, v in obj.items()}
|
|
return resolved if any(resolved[k] is not obj[k] for k in obj) else obj
|
|
if isinstance(obj, list):
|
|
resolved = [_resolve_in_place(v) for v in obj]
|
|
return resolved if any(nv is not ov for nv, ov in zip(resolved, obj)) else obj
|
|
return obj
|
|
|
|
|
|
def _resolve_env_vars(obj: object) -> object:
|
|
"""Recursively resolve ``${VAR}`` patterns in plain strings/dicts/lists."""
|
|
if isinstance(obj, str):
|
|
return _ENV_REF_PATTERN.sub(_env_replace, obj)
|
|
if isinstance(obj, dict):
|
|
return {k: _resolve_env_vars(v) for k, v in obj.items()}
|
|
if isinstance(obj, list):
|
|
return [_resolve_env_vars(v) for v in obj]
|
|
return obj
|
|
|
|
|
|
def _env_replace(match: re.Match[str]) -> str:
|
|
name = match.group(1)
|
|
value = os.environ.get(name)
|
|
if value is None:
|
|
raise ValueError(
|
|
f"Environment variable '{name}' referenced in config is not set"
|
|
)
|
|
return value
|
|
|
|
|
|
def _migrate_config(data: dict) -> dict:
|
|
"""Migrate old config formats to current."""
|
|
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
|
|
tools = data.get("tools", {})
|
|
exec_cfg = tools.get("exec", {})
|
|
if "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools:
|
|
tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace")
|
|
|
|
# Move tools.myEnabled / tools.mySet → tools.my.{enable, allowSet}.
|
|
# The old flat keys shipped in the initial MyTool landing; wrapping them in a
|
|
# sub-config keeps `web` / `exec` / `my` symmetric and gives room to grow.
|
|
if "myEnabled" in tools or "mySet" in tools:
|
|
my_cfg = tools.setdefault("my", {})
|
|
if "myEnabled" in tools and "enable" not in my_cfg:
|
|
my_cfg["enable"] = tools.pop("myEnabled")
|
|
else:
|
|
tools.pop("myEnabled", None)
|
|
if "mySet" in tools and "allowSet" not in my_cfg:
|
|
my_cfg["allowSet"] = tools.pop("mySet")
|
|
else:
|
|
tools.pop("mySet", None)
|
|
|
|
return data
|