Compare commits

...

1 Commits

Author SHA1 Message Date
Alexander Whitestone
98b3b1eaed feat(#696): Config template system — environment-specific overlays
Some checks failed
Architecture Lint / Linter Tests (pull_request) Successful in 26s
PR Checklist / pr-checklist (pull_request) Failing after 3m55s
Smoke Test / smoke (pull_request) Failing after 13s
Validate Config / YAML Lint (pull_request) Failing after 15s
Validate Config / JSON Validate (pull_request) Successful in 8s
Validate Config / Python Syntax & Import Check (pull_request) Failing after 33s
Validate Config / Shell Script Lint (pull_request) Failing after 28s
Validate Config / Cron Syntax Check (pull_request) Successful in 6s
Validate Config / Deploy Script Dry Run (pull_request) Successful in 5s
Validate Config / Playbook Schema Validation (pull_request) Successful in 10s
Architecture Lint / Lint Repository (pull_request) Has been cancelled
Validate Config / Python Test Suite (pull_request) Has been cancelled
Base config + env overlay merge system:
- config_overlay.py: deep_merge() for dict merging, load_config() for
  loading base + overlay, detect_env() for auto-detection
- config.dev.yaml: local dev (qwen3, verbose, reasoning shown)
- config.prod.yaml: production (claude-opus, compact, privacy)
- config.cron.yaml: headless/cron (no memory, minimal display)
- config.gateway.yaml: messaging gateway (balanced settings)
- tests/test_config_overlay.py: 13 tests, all passing

Usage:
  from config_overlay import load_config
  config = load_config('config.yaml', env='prod')

Overlay naming: config.{env}.yaml
Auto-detect: TIMMY_ENV, HERMES_ENV, ENVIRONMENT, NODE_ENV
2026-04-15 11:33:03 -04:00
6 changed files with 333 additions and 0 deletions

19
config.cron.yaml Normal file
View File

@@ -0,0 +1,19 @@
# Cron/headless environment overlay
# For scheduled jobs and autonomous agents
model:
default: qwen3:30b
provider: custom
agent:
max_turns: 100
verbose: false
display:
compact: true
show_reasoning: false
streaming: false
resume_display: minimal
terminal:
timeout: 300
persistent_shell: true
memory:
memory_enabled: false
user_profile_enabled: false

14
config.dev.yaml Normal file
View File

@@ -0,0 +1,14 @@
# Dev environment overlay
# Merges with config.yaml — these keys override the base
model:
default: qwen3:30b
provider: custom
agent:
max_turns: 50
verbose: true
display:
show_reasoning: true
streaming: true
show_cost: true
terminal:
timeout: 300

17
config.gateway.yaml Normal file
View File

@@ -0,0 +1,17 @@
# Gateway environment overlay
# For messaging platform gateway (Telegram, Discord, etc.)
model:
default: qwen3:30b
provider: custom
agent:
max_turns: 30
verbose: false
display:
compact: true
show_reasoning: false
streaming: false
terminal:
timeout: 120
memory:
memory_enabled: true
user_profile_enabled: true

17
config.prod.yaml Normal file
View File

@@ -0,0 +1,17 @@
# Prod environment overlay
# Merges with config.yaml — these keys override the base
model:
default: claude-opus-4-6
provider: anthropic
agent:
max_turns: 90
verbose: false
display:
compact: true
show_reasoning: false
streaming: false
show_cost: false
privacy:
redact_pii: true
security:
redact_secrets: true

130
config_overlay.py Normal file
View File

@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""
config_overlay.py — Environment-specific config overlays.
Merges a base config YAML with an environment-specific overlay.
Overlay keys override base keys (deep merge for dicts, replace for lists).
Usage:
from config_overlay import load_config
config = load_config('config.yaml', env='prod')
# Loads config.yaml + config.prod.yaml, merges, returns dict
Environments: dev, prod, cron, gateway (or custom)
"""
import os
import copy
import yaml
from pathlib import Path
def deep_merge(base: dict, overlay: dict) -> dict:
"""Deep merge overlay into base. Overlay wins on conflicts.
- Dicts are merged recursively
- Lists are replaced (not extended)
- Scalars are replaced
- None in overlay removes the key from base
"""
result = copy.deepcopy(base)
for key, value in overlay.items():
if value is None:
result.pop(key, None)
elif key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = deep_merge(result[key], value)
else:
result[key] = copy.deepcopy(value)
return result
def find_config(base_path: str, env: str = None) -> tuple:
"""Find base and overlay config paths.
Returns: (base_path, overlay_path_or_None)
"""
base = Path(base_path)
if not base.exists():
raise FileNotFoundError(f"Base config not found: {base_path}")
overlay = None
if env:
# Try: config.{env}.yaml, config/{env}.yaml, {env}.overlay.yaml
candidates = [
base.parent / f"{base.stem}.{env}{base.suffix}",
base.parent / env / f"{base.name}",
base.parent / f"{env}.overlay{base.suffix}",
]
for c in candidates:
if c.exists():
overlay = c
break
return base, overlay
def load_config(base_path: str, env: str = None) -> dict:
"""Load base config and merge with environment overlay.
Args:
base_path: Path to base config YAML
env: Environment name (dev, prod, cron, gateway, or custom)
Returns:
Merged config dict
"""
base_file, overlay_file = find_config(base_path, env)
with open(base_file) as f:
base_config = yaml.safe_load(f) or {}
if overlay_file and overlay_file.exists():
with open(overlay_file) as f:
overlay_config = yaml.safe_load(f) or {}
return deep_merge(base_config, overlay_config)
return base_config
def detect_env() -> str:
"""Auto-detect environment from env vars.
Checks: TIMMY_ENV, HERMES_ENV, ENVIRONMENT, NODE_ENV
"""
for var in ('TIMMY_ENV', 'HERMES_ENV', 'ENVIRONMENT', 'NODE_ENV'):
val = os.environ.get(var, '').strip().lower()
if val:
return val
return None
def load_config_auto(base_path: str) -> dict:
"""Load config with auto-detected environment."""
env = detect_env()
return load_config(base_path, env=env)
def list_overlays(base_path: str) -> list:
"""List available overlay files for a base config."""
base = Path(base_path)
pattern = f"{base.stem}.*{base.suffix}"
overlays = []
for f in sorted(base.parent.glob(pattern)):
if f == base:
continue
env_name = f.stem.replace(base.stem + '.', '')
overlays.append({
'env': env_name,
'path': str(f),
})
return overlays
if __name__ == '__main__':
import sys
import json
base = sys.argv[1] if len(sys.argv) > 1 else 'config.yaml'
env = sys.argv[2] if len(sys.argv) > 2 else None
config = load_config(base, env=env)
print(json.dumps(config, indent=2, default=str))

View File

@@ -0,0 +1,136 @@
"""Tests for config overlay system."""
import os
import json
import tempfile
import yaml
import pytest
def test_deep_merge_dicts():
"""Deep merge should recursively merge dicts."""
from config_overlay import deep_merge
base = {"a": {"b": 1, "c": 2}, "d": 3}
overlay = {"a": {"b": 10, "e": 5}}
result = deep_merge(base, overlay)
assert result == {"a": {"b": 10, "c": 2, "e": 5}, "d": 3}
def test_deep_merge_lists_replaced():
"""Lists should be replaced, not extended."""
from config_overlay import deep_merge
base = {"items": [1, 2, 3]}
overlay = {"items": [4, 5]}
result = deep_merge(base, overlay)
assert result == {"items": [4, 5]}
def test_deep_merge_scalars_overridden():
"""Scalar values should be overridden by overlay."""
from config_overlay import deep_merge
base = {"name": "base", "count": 10}
overlay = {"name": "override", "count": 20}
result = deep_merge(base, overlay)
assert result == {"name": "override", "count": 20}
def test_deep_merge_none_removes_key():
"""None in overlay should remove the key from base."""
from config_overlay import deep_merge
base = {"a": 1, "b": 2, "c": 3}
overlay = {"b": None}
result = deep_merge(base, overlay)
assert result == {"a": 1, "c": 3}
def test_deep_merge_empty_overlay():
"""Empty overlay should return base unchanged."""
from config_overlay import deep_merge
base = {"a": 1, "b": {"c": 2}}
result = deep_merge(base, {})
assert result == base
def test_load_config_no_env(tmp_path):
"""Load config without overlay should return base."""
from config_overlay import load_config
base = {"model": "test", "agent": {"max_turns": 10}}
path = tmp_path / "config.yaml"
path.write_text(yaml.dump(base))
result = load_config(str(path))
assert result == base
def test_load_config_with_overlay(tmp_path):
"""Load config with overlay should merge."""
from config_overlay import load_config
base = {"model": "base", "agent": {"max_turns": 10, "verbose": False}}
overlay = {"model": "override", "agent": {"verbose": True}}
(tmp_path / "config.yaml").write_text(yaml.dump(base))
(tmp_path / "config.dev.yaml").write_text(yaml.dump(overlay))
result = load_config(str(tmp_path / "config.yaml"), env="dev")
assert result["model"] == "override"
assert result["agent"]["max_turns"] == 10
assert result["agent"]["verbose"] is True
def test_load_config_missing_overlay(tmp_path):
"""Missing overlay should silently return base."""
from config_overlay import load_config
base = {"model": "base"}
(tmp_path / "config.yaml").write_text(yaml.dump(base))
result = load_config(str(tmp_path / "config.yaml"), env="nonexistent")
assert result == base
def test_find_config(tmp_path):
"""find_config should locate base and overlay."""
from config_overlay import find_config
base = tmp_path / "config.yaml"
base.write_text("a: 1")
overlay = tmp_path / "config.prod.yaml"
overlay.write_text("a: 2")
b, o = find_config(str(base), "prod")
assert b == base
assert o == overlay
def test_list_overlays(tmp_path):
"""list_overlays should find all overlay files."""
from config_overlay import list_overlays
(tmp_path / "config.yaml").write_text("a: 1")
(tmp_path / "config.dev.yaml").write_text("a: 2")
(tmp_path / "config.prod.yaml").write_text("a: 3")
overlays = list_overlays(str(tmp_path / "config.yaml"))
envs = [o['env'] for o in overlays]
assert 'dev' in envs
assert 'prod' in envs
def test_detect_env_from_var(tmp_path, monkeypatch):
"""detect_env should check TIMMY_ENV first."""
from config_overlay import detect_env
monkeypatch.setenv("TIMMY_ENV", "prod")
assert detect_env() == "prod"
def test_detect_env_fallback(tmp_path, monkeypatch):
"""detect_env should fall back through vars."""
from config_overlay import detect_env
monkeypatch.delenv("TIMMY_ENV", raising=False)
monkeypatch.delenv("HERMES_ENV", raising=False)
monkeypatch.setenv("ENVIRONMENT", "cron")
assert detect_env() == "cron"
def test_real_config_overlay():
"""Test against actual config files in the repo."""
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from config_overlay import load_config
base_path = os.path.join(os.path.dirname(__file__), '..', 'config.yaml')
if os.path.exists(base_path):
config = load_config(base_path, env='dev')
assert config['model']['default'] == 'qwen3:30b' # dev overrides
assert config['agent']['max_turns'] == 50 # dev overrides
assert 'terminal' in config # base keys preserved