A declarative, hermetic harness for Nix-defined agents.
0

Configure Feed

Select the types of activity you want to include in your feed.

config: use pydantic-settings

+115 -78
+1
pyproject.toml
··· 11 11 dependencies = [ 12 12 "httpx>=0.28.1", 13 13 "pydantic>=2.13.4", 14 + "pydantic-settings>=2.14.2", 14 15 ] 15 16 16 17 [project.scripts]
+77 -78
tartarus/config.py
··· 7 7 8 8 from __future__ import annotations 9 9 10 - import json 11 10 import os 12 11 13 - from pydantic import BaseModel, ConfigDict, Field 12 + from pydantic import ( 13 + AliasChoices, 14 + BaseModel, 15 + ConfigDict, 16 + Field, 17 + ValidationError, 18 + model_validator, 19 + ) 20 + from pydantic_settings import BaseSettings, SettingsConfigDict 21 + from typing_extensions import Self 14 22 15 23 from tartarus.manifest import Manifest, Sampling 16 24 ··· 21 29 # tunes its own feel via the `model.sampling` block (see agent.nix). 22 30 DEFAULT_MAX_TOKENS = 16384 23 31 DEFAULT_STATE_DIR = ".tartarus" 24 - DEFAULT_AUDIT_PATH = f"{DEFAULT_STATE_DIR}/audit.jsonl" 25 - DEFAULT_SESSIONS_DIR = f"{DEFAULT_STATE_DIR}/sessions" 32 + # Leaf names under <work_tree>/.tartarus, shared by every path-deriving call site. 33 + AUDIT_LOG_LEAF = "audit.jsonl" 34 + SESSIONS_LEAF = "sessions" 26 35 DEFAULT_OUTPUT_TRUNCATE_CHARS = 10_000 27 36 # `path:` copies the directory regardless of git tracking, which keeps local 28 37 # capability edits visible before they are committed. ··· 43 52 """Raised when required configuration is missing or invalid.""" 44 53 45 54 46 - class Config(BaseModel): 47 - """Mutable harness config built from environment variables.""" 55 + class Config(BaseSettings): 56 + """Harness config, loaded from TARTARUS_* environment variables (PLAN.md §9). 57 + 58 + Each field reads from TARTARUS_<FIELD>; a few keep legacy env names via an 59 + explicit alias. Runtime fields (provider/base_url/model/max_tokens) stay None 60 + when unset so the agent's `model` block can supply them (resolve_runtime); an 61 + explicit env value still wins. 62 + """ 48 63 49 - model_config = ConfigDict(extra="forbid", strict=True) 64 + model_config = SettingsConfigDict( 65 + env_prefix="TARTARUS_", extra="ignore", populate_by_name=True 66 + ) 50 67 51 - # The runtime fields are None when their env var is unset, so the agent's 52 - # `model` block can supply the value (resolve_runtime). An explicit env var 53 - # still wins. api_key is the exception: env-only, since it is a secret. 68 + # The one secret: env-only, accepted under either the TARTARUS_ name or the 69 + # provider's own OPENCODE_API_KEY. Empty is allowed here so non-auth paths can 70 + # build a Config; load_config enforces a non-empty key (fail closed). 54 71 api_key: str = "" 55 72 provider: str | None = None 56 73 base_url: str | None = None ··· 65 82 flake_ref: str = DEFAULT_FLAKE_REF 66 83 # A realized agent bundle store path (e.g. received via `nix copy`). When set, 67 84 # the harness loads it directly and never touches the flake (PLAN.md §14). 68 - bundle_path: str = "" 85 + bundle_path: str = Field("", validation_alias=AliasChoices("TARTARUS_BUNDLE")) 69 86 # Agent name under #agents.<system> to load. 70 - agent_name: str = DEFAULT_AGENT_NAME 87 + agent_name: str = Field( 88 + DEFAULT_AGENT_NAME, validation_alias=AliasChoices("TARTARUS_AGENT") 89 + ) 71 90 # When true there is no human to approve ask-* policies, so they fail closed. 72 91 headless: bool = False 73 - # Append-only JSONL audit log for brokered tool calls. 92 + # Append-only JSONL audit log for brokered tool calls. Empty -> derived below. 74 93 audit_path: str = "" 75 94 # Directory holding per-conversation transcript files (<id>.jsonl). 76 - session_dir: str = "" 95 + session_dir: str = Field("", validation_alias=AliasChoices("TARTARUS_SESSIONS_DIR")) 77 96 output_truncate: int = DEFAULT_OUTPUT_TRUNCATE_CHARS 78 97 98 + @model_validator(mode="after") 99 + def _derive_state_paths(self) -> Self: 100 + # The audit log and sessions dir default under <work_tree>/.tartarus unless 101 + # the environment supplied an explicit path. 102 + if not self.audit_path: 103 + self.audit_path = _default_state_path(self.work_tree, AUDIT_LOG_LEAF) 104 + if not self.session_dir: 105 + self.session_dir = _default_state_path(self.work_tree, SESSIONS_LEAF) 106 + return self 107 + 79 108 80 109 def session_dir_from_env() -> str: 81 110 """Resolve the sessions directory from the environment, no API key required. 82 111 83 - Shared by load_config and the CLI's read-only `--list-sessions` path. 112 + Shared by load_config (via Config) and the CLI's read-only `--list-sessions` 113 + path, which runs before any API key is required, so it cannot build a Config. 84 114 """ 85 - work_tree = _read_env("WORK_TREE", os.getcwd()) or os.getcwd() 86 - return _read_env("SESSIONS_DIR", _default_state_path(work_tree, "sessions")) or "" 87 - 88 - 89 - def _read_env(name: str, default: str | None = None) -> str | None: 90 - return os.environ.get(f"TARTARUS_{name}", default) 91 - 92 - 93 - def _read_bool_env(name: str) -> bool: 94 - return (_read_env(name, "") or "").lower() in {"1", "true", "yes"} 115 + work_tree = os.environ.get("TARTARUS_WORK_TREE") or os.getcwd() 116 + return os.environ.get("TARTARUS_SESSIONS_DIR") or _default_state_path( 117 + work_tree, SESSIONS_LEAF 118 + ) 95 119 96 120 97 121 def _default_state_path(work_tree: str, leaf: str) -> str: 98 122 return os.path.join(work_tree, DEFAULT_STATE_DIR, leaf) 99 123 100 124 101 - def _read_api_key() -> str: 102 - for variable in API_KEY_ENV_VARS: 103 - value = os.environ.get(variable) 104 - if value: 105 - return value 106 - return "" 107 - 108 - 109 - def _read_extra_headers() -> dict[str, str] | None: 110 - raw_headers = _read_env("EXTRA_HEADERS") 111 - if not raw_headers: 112 - return None 113 - try: 114 - parsed = json.loads(raw_headers) 115 - except json.JSONDecodeError as error: 116 - raise ConfigError(f"TARTARUS_EXTRA_HEADERS must be JSON: {error}") from error 117 - if not isinstance(parsed, dict): 118 - raise ConfigError("TARTARUS_EXTRA_HEADERS must be a JSON object") 119 - return {str(key): str(value) for key, value in parsed.items()} 120 - 121 - 122 125 def load_config() -> Config: 123 - """Build a Config from the environment, applying defaults. 126 + """Build a Config from the environment. 124 127 125 - Fails closed: a missing API key raises ConfigError rather than attempting an 126 - unauthenticated request. 128 + Fails closed: a missing API key or malformed value raises ConfigError rather 129 + than attempting an unauthenticated or misconfigured request. 127 130 """ 128 - api_key = _read_api_key() 129 - if not api_key: 130 - names = " or ".join(API_KEY_ENV_VARS) 131 - raise ConfigError(f"no API key found; set {names}") 131 + try: 132 + config = Config() 133 + except ValidationError as error: 134 + raise _config_error(error) from error 135 + if not config.api_key: 136 + # TARTARUS_API_KEY is read via env_prefix; fall back to the alternates here. 137 + for variable in API_KEY_ENV_VARS: 138 + value = os.environ.get(variable, "") 139 + if value: 140 + config.api_key = value 141 + break 142 + if not config.api_key: 143 + raise ConfigError(f"no API key found; set {' or '.join(API_KEY_ENV_VARS)}") 144 + return config 132 145 133 - work_tree = _read_env("WORK_TREE", os.getcwd()) or os.getcwd() 134 - default_audit_path = _default_state_path(work_tree, "audit.jsonl") 135 - audit_path = _read_env("AUDIT_PATH", default_audit_path) or default_audit_path 136 - session_dir = session_dir_from_env() 137 146 138 - raw_max_tokens = _read_env("MAX_TOKENS") 139 - return Config( 140 - # Left None when unset so the agent's profile can supply them; an explicit 141 - # value here still overrides the profile (resolve_runtime). 142 - provider=_read_env("PROVIDER"), 143 - base_url=_read_env("BASE_URL"), 144 - api_key=api_key, 145 - model=_read_env("MODEL"), 146 - max_tokens=int(raw_max_tokens) if raw_max_tokens else None, 147 - extra_headers=_read_extra_headers(), 148 - work_tree=work_tree, 149 - flake_ref=_read_env("FLAKE_REF", DEFAULT_FLAKE_REF) or DEFAULT_FLAKE_REF, 150 - bundle_path=_read_env("BUNDLE", "") or "", 151 - agent_name=_read_env("AGENT", DEFAULT_AGENT_NAME) or DEFAULT_AGENT_NAME, 152 - headless=_read_bool_env("HEADLESS"), 153 - audit_path=audit_path, 154 - session_dir=session_dir, 155 - output_truncate=int( 156 - _read_env("OUTPUT_TRUNCATE", str(DEFAULT_OUTPUT_TRUNCATE_CHARS)) 157 - or DEFAULT_OUTPUT_TRUNCATE_CHARS 158 - ), 147 + def _config_error(error: ValidationError) -> ConfigError: 148 + """Translate a settings ValidationError into a user-facing ConfigError.""" 149 + failed = {str(location) for entry in error.errors() for location in entry["loc"]} 150 + if "extra_headers" in failed: 151 + return ConfigError("TARTARUS_EXTRA_HEADERS must be a JSON object") 152 + # loc + msg only: ValidationError's str/input fields echo the offending value, 153 + # which would leak secrets like api_key into logs. 154 + details = "; ".join( 155 + f"{' -> '.join(str(loc) for loc in entry['loc'])}: {entry['msg']}" 156 + for entry in error.errors() 159 157 ) 158 + return ConfigError(f"invalid configuration: {details}") 160 159 161 160 162 161 class ResolvedRuntime(BaseModel):
+12
tests/test_config.py
··· 1 + import os 2 + 1 3 import pytest 2 4 3 5 from tartarus.config import ( 6 + API_KEY_ENV_VARS, 4 7 DEFAULT_BASE_URL, 5 8 DEFAULT_MAX_TOKENS, 6 9 DEFAULT_MODEL, ··· 10 13 resolve_runtime, 11 14 ) 12 15 from tartarus.manifest import Manifest, ModelConfig 16 + 17 + 18 + @pytest.fixture(autouse=True) 19 + def _clear_harness_env(monkeypatch): 20 + # Config now reads ambient env (BaseSettings), so clear it for hermetic tests; 21 + # each test sets only the vars it exercises. 22 + for key in list(os.environ): 23 + if key.startswith("TARTARUS_") or key in API_KEY_ENV_VARS: 24 + monkeypatch.delenv(key, raising=False) 13 25 14 26 15 27 def _manifest(**kwargs) -> Manifest:
+25
uv.lock
··· 186 186 ] 187 187 188 188 [[package]] 189 + name = "pydantic-settings" 190 + version = "2.14.2" 191 + source = { registry = "https://pypi.org/simple" } 192 + dependencies = [ 193 + { name = "pydantic" }, 194 + { name = "python-dotenv" }, 195 + { name = "typing-inspection" }, 196 + ] 197 + sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } 198 + wheels = [ 199 + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, 200 + ] 201 + 202 + [[package]] 189 203 name = "pygments" 190 204 version = "2.20.0" 191 205 source = { registry = "https://pypi.org/simple" } ··· 211 225 ] 212 226 213 227 [[package]] 228 + name = "python-dotenv" 229 + version = "1.2.2" 230 + source = { registry = "https://pypi.org/simple" } 231 + sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } 232 + wheels = [ 233 + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, 234 + ] 235 + 236 + [[package]] 214 237 name = "ruff" 215 238 version = "0.15.20" 216 239 source = { registry = "https://pypi.org/simple" } ··· 242 265 dependencies = [ 243 266 { name = "httpx" }, 244 267 { name = "pydantic" }, 268 + { name = "pydantic-settings" }, 245 269 ] 246 270 247 271 [package.dev-dependencies] ··· 255 279 requires-dist = [ 256 280 { name = "httpx", specifier = ">=0.28.1" }, 257 281 { name = "pydantic", specifier = ">=2.13.4" }, 282 + { name = "pydantic-settings", specifier = ">=2.14.2" }, 258 283 ] 259 284 260 285 [package.metadata.requires-dev]