oc_chat
Loading...
Searching...
No Matches
settings.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/settings.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/unit/test_settings.py"
6"""
7Central OC_Chat settings — the single source of truth for tunable behaviour.
8
9Purpose
10-------
11Loads ``.oc_chat/oc_chat-settings.json`` (committed project defaults) deep-merged
12over the built-in defaults, then deep-merged with an optional gitignored
13``oc_chat-settings.local.json`` (per-machine overrides) — the same split as
14``.claude/settings.json`` + ``settings.local.json``. Settings migrate here
15opportunistically; new settings live here, not as scattered constants.
16
17The headline consumers (plan 133): the system-prompt composition (which named
18*mode* is active, and which of persona / tools / project-tree / extra files it
19injects) and the project-tree generator (exclude dirs/suffixes, honor-gitignore,
20work dir).
21
22Responsibilities
23----------------
24- :data:`DEFAULT_SETTINGS` — the built-in schema + safe defaults (mode
25 ``standard`` = persona + tools, no tree, no files).
26- :class:`OCChatSettings` — the resolved, validated settings + any load-time
27 warnings + helpers (:meth:`mode_profile`, :meth:`resolve_mode`).
28- :func:`load_settings` — read + deep-merge + validate; never raises on a
29 missing/malformed file (falls back to defaults with a warning).
30
31Forward compatibility (lenient reader, strict writer)
32-----------------------------------------------------
33Unknown top-level keys, unknown keys inside a mode profile, and unknown
34``project_tree`` keys are recorded as a warning and otherwise ignored, so a
35newer settings file (new mode / new toggle) never breaks an older OC_Chat. An
36active ``mode`` absent from ``modes`` warns and falls back to ``standard``.
37Warnings are returned on :attr:`OCChatSettings.warnings`; the caller surfaces
38them (terminal-visible) once a presenter exists.
39
40Diagnostics
41-----------
42Domain: OC_CHAT.MAIN
43Levels:
44 L2 - lifecycle events (load entry/exit)
45 L3 - semantic details (merge sources, fallbacks)
46 L4 - deep tracing (per-key unknown-warning emission)
47
48Contracts
49---------
50- :func:`load_settings` is total: any IO/JSON error degrades to defaults + a
51 warning; it never raises.
52- :class:`OCChatSettings` is read-only by convention after load.
53- Deep-merge order is defaults < committed < local; ``modes`` and
54 ``project_tree`` merge per-key (a partial override does not drop sibling keys).
55
56Dependencies
57------------
58- oc_diagnostics (structured debug logging via _dbg)
59- oc_chat.core.defaults (shipped built-in values that the settings files merge over)
60- oc_chat.core._paths (lazy; find_workspace_root fallback for the settings file location)
61- oc_chat.tools.filesystem (lazy; read_prompt_file for the hardened system_prompt_file read)
62"""
63from __future__ import annotations
64
65import json
66from copy import deepcopy
67from dataclasses import dataclass, field
68from pathlib import Path
69from typing import Any
70
71from oc_diagnostics import _dbg
72
73# CONFIG-01 (plan 238): the built-in values below are bound at import from the
74# shipped, read-only ``oc_chat.defaults.toml``. ``shipped()`` is used rather than
75# ``default()`` on purpose — these ARE the built-in defaults a settings file
76# merges over, so consulting the settings file to build them would be circular.
77from oc_chat.core.defaults import shipped
78
79_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
80
81
82#: Committed project settings (relative to the workspace root).
83SETTINGS_FILENAME = ".oc_chat/oc_chat-settings.json"
84#: Gitignored per-machine override, deep-merged over the committed file.
85LOCAL_SETTINGS_FILENAME = ".oc_chat/oc_chat-settings.local.json"
86
87
88#: Built-in defaults + the authoritative schema. Anything not here is "unknown".
89DEFAULT_SETTINGS: dict[str, Any] = {
90 "mode": "standard",
91 "modes": {
92 "standard": {
93 "include_persona": shipped("mode_standard_include_persona"),
94 "include_tools": shipped("mode_standard_include_tools"),
95 "tools": None,
96 "include_env": shipped("mode_standard_include_env"),
97 "env_fields": ["date", "timezone"],
98 "include_project_tree": shipped("mode_standard_include_project_tree"),
99 "include_files": [],
100 },
101 "option-c": {
102 "include_persona": shipped("mode_option_c_include_persona"),
103 "include_tools": shipped("mode_option_c_include_tools"),
104 "tools": None,
105 "include_env": shipped("mode_option_c_include_env"),
106 "env_fields": ["date", "timezone", "cwd", "branch", "git", "model", "mode"],
107 "include_project_tree": shipped("mode_option_c_include_project_tree"),
108 "include_files": ["AGENT_CONTEXT.md"],
109 },
110 },
111 # "" → use the built-in CANONICAL_POLICY; non-empty → inline override.
112 "system_prompt": "",
113 # None → unused; else a workspace-relative path read as the system prompt.
114 "system_prompt_file": None,
115 # Plan 134 C1: default launch verbosity. Dev checkouts set true in the
116 # gitignored .local.json; shipped quiet for end users. The --verbose /
117 # --no-verbose CLI flags override per launch (CLI > local > committed > here).
118 "verbose": shipped("settings_verbose"),
119 # M8.1 Appendix-B amendment: per-turn tool-call ceiling (was 10).
120 # Operator-configurable — raise for large multi-tool turns. Still
121 # non-overridable by model args (a runaway model cannot self-raise it) and
122 # still clamped by the shipped ceiling — see defaults.ceiling.
123 "max_tool_calls_per_turn": shipped("tool_calls_per_turn"),
124 # CAPS-02 (plan 246): None is an explicit absent request. This lets an
125 # operator's OOB environment ceiling apply when the workspace did not lower it.
126 "stall_repeat_calls": None,
127 "stall_error_streak": None,
128 "stall_no_progress_iterations": None,
129 # Per-completion output ceiling. 0 = use the shipped default
130 # (config.OUTPUT_TOKENS_CEILING). May only LOWER that default — see
131 # config.get_output_tokens_ceiling.
132 "max_output_tokens": shipped("settings_max_output_tokens"),
133 # CAPS-01 (plan 237): the session dollar hard cap, LOWER-ONLY. 0 = no
134 # request, i.e. use the shipped ceiling (or the operator's out-of-band
135 # OC_CHAT_SESSION_COST_CEILING_USD). A value above the ceiling is clamped,
136 # never honoured — see resource_caps.resolve_session_cost_hard_cap_usd.
137 "session_cost_hard_cap_usd": shipped("settings_session_cost_hard_cap_usd"),
138 # CAPS-01 (plan 237): EXTRA cost checkpoints, in USD. Merged with the
139 # shipped ladder, never replacing it: entries below the shipped ceiling are
140 # added (the session asks sooner), entries at or above it are ignored, and
141 # no shipped checkpoint can be removed — see resolve_cost_thresholds_usd.
142 "session_cost_thresholds_usd": [],
143 "project_tree": {
144 "max_depth": shipped("project_tree_max_depth"),
145 "exclude_dirs": [
146 "__pycache__", "logs", "memory_store", "_backup", "nulldir",
147 "venv", ".venv",
148 ],
149 "exclude_suffixes": [".bk", ".bak", "~"],
150 # Dirs shown as existence-only (contents omitted, "use list_dir" hint) —
151 # high-churn dirs whose individual filenames bloat the prompt for no gain.
152 "stub_dirs": ["sessions", "plans"],
153 "honor_gitignore": shipped("project_tree_honor_gitignore"),
154 "work_dir": "_work",
155 },
156 # Plan 137: verification backpressure — the inner-loop post-mutation feedback
157 # sensor. Off by default globally; an agentic mode opts in via a per-mode
158 # ``verification`` override (a known mode key — see ``_MODE_DEFAULTS``).
159 # ``subloop_readonly``/``baseline_scope`` use ``None`` sentinels resolved by
160 # ``verification_config`` (None subloop_readonly ⇒ follow ``enabled``).
161 "verification": {
162 "enabled": shipped("verification_enabled"),
163 "mutating_tools": ["write_file", "oct_new"],
164 "lint": shipped("verification_lint"),
165 "typecheck": shipped("verification_typecheck"),
166 "tests": shipped("verification_tests"),
167 "profile": "default",
168 "timeout_s": shipped("verification_timeout_s"),
169 "max_files": shipped("verification_max_files"),
170 "max_findings": shipped("verification_max_findings"),
171 "jobs": shipped("verification_jobs"),
172 "max_runs_per_turn": shipped("verification_max_runs_per_turn"),
173 "baseline": shipped("verification_baseline"),
174 "baseline_timeout_s": shipped("verification_baseline_timeout_s"),
175 "baseline_scope": None,
176 "subloop_readonly": None,
177 },
178}
179
180#: Neutral per-mode backfill defaults (plan 135 §3.2 table). A partially-declared
181#: mode answers every toggle from this dict — NOT from the ``standard`` mode, so
182#: ``standard`` is not privileged as a backfill source. For the four pre-135 keys
183#: these values equal ``standard``'s, so existing behaviour is unchanged; the new
184#: keys (``tools``/``include_env``/``env_fields``) default to the minimal/off form.
185_MODE_DEFAULTS: dict[str, Any] = {
186 "include_persona": True,
187 "include_tools": True,
188 "tools": None,
189 "include_env": False,
190 "env_fields": [],
191 "include_project_tree": False,
192 "include_files": [],
193 # Plan 137: per-mode ``verification`` override is a KNOWN mode key (so it does
194 # not warn as unknown). Empty ⇒ the mode inherits the top-level default;
195 # ``verification_config`` deep-merges this partial over the top-level block.
196 "verification": {},
197}
198
199
200# Known-key sets resolve from the live dicts at CALL time (not module-import
201# time) so a test that patches DEFAULT_SETTINGS / _MODE_DEFAULTS sees the
202# current schema and new keys never emit spurious forward-compat warnings (§4.2).
203def _known_top_keys() -> frozenset[str]:
204 return frozenset(DEFAULT_SETTINGS)
205
206
207def _known_mode_keys() -> frozenset[str]:
208 return frozenset(_MODE_DEFAULTS)
209
210
211def _known_tree_keys() -> frozenset[str]:
212 return frozenset(DEFAULT_SETTINGS["project_tree"])
213
214
215@dataclass
217 """Resolved, validated settings. Read-only by convention after load."""
218
219 mode: str
220 modes: dict[str, dict[str, Any]]
221 system_prompt: str
222 system_prompt_file: str | None
223 project_tree: dict[str, Any]
224 verbose: bool = shipped("settings_verbose")
225 # M8.1 Appendix-B (amended): operator-configurable per-turn tool-call ceiling,
226 # non-overridable by model args (was 10).
227 max_tool_calls_per_turn: int = shipped("tool_calls_per_turn")
228 # CAPS-02: lower-only requests. None means the workspace made no request.
229 stall_repeat_calls: int | None = None
230 stall_error_streak: int | None = None
231 stall_no_progress_iterations: int | None = None
232 # Lower-only override of config.OUTPUT_TOKENS_CEILING; 0 = shipped default.
233 max_output_tokens: int = shipped("settings_max_output_tokens")
234 # CAPS-01: lower-only session dollar cap (0 = no request) and EXTRA cost
235 # checkpoints (merged below the shipped ceiling, never replacing the ladder).
236 session_cost_hard_cap_usd: int = shipped("settings_session_cost_hard_cap_usd")
237 session_cost_thresholds_usd: list[float] = field(default_factory=list)
238 # Plan 137: resolved top-level ``verification`` block; per-mode overrides live
239 # under ``modes[<mode>]["verification"]`` and are composed by ``verification_config``.
240 verification: dict[str, Any] = field(default_factory=dict)
241 warnings: list[str] = field(default_factory=list)
242
243 def resolve_mode(self, cli_mode: str | None) -> str:
244 """Pick the effective mode: CLI override > file `mode` > 'standard'.
245
246 An unknown name (from either source) warns and falls back to a defined
247 mode ('standard' if present, else the first defined mode)."""
248 chosen = (cli_mode or self.mode or "standard").strip()
249 if chosen in self.modes:
250 return chosen
251 fallback = "standard" if "standard" in self.modes else next(iter(self.modes), "standard")
252 self.warnings.append(
253 f"unknown mode {chosen!r} (not in modes: {sorted(self.modes)}); using {fallback!r}"
254 )
255 return fallback
256
257 def mode_profile(self, name: str) -> dict[str, Any]:
258 """Composition toggles for *name*, backfilled from the neutral mode
259 defaults so a partially-specified mode still answers every toggle.
260
261 Backfill source is :data:`_MODE_DEFAULTS` (the §3.2 schema), not the
262 ``standard`` mode — so ``standard`` carries no privileged status and a
263 new mode that omits a key gets the minimal/off default, not standard's.
264 """
265 base = deepcopy(_MODE_DEFAULTS)
266 base.update(self.modes.get(name, {}))
267 return base
268
269 def drain_warnings(self) -> list[str]:
270 """Return and clear pending warnings (emit-once across call sites)."""
271 out = list(self.warnings)
272 self.warnings.clear()
273 return out
274
275
276def _read_json(path: Path, warnings: list[str]) -> dict[str, Any]:
277 """Best-effort JSON read; a malformed/unreadable file warns and yields {}."""
278 try:
279 text = path.read_text(encoding="utf-8")
280 except (OSError, UnicodeDecodeError):
281 return {}
282 try:
283 data = json.loads(text)
284 except json.JSONDecodeError as exc:
285 warnings.append(f"{path.name}: invalid JSON ({exc}); ignored")
286 return {}
287 if not isinstance(data, dict):
288 warnings.append(f"{path.name}: top-level value must be an object; ignored")
289 return {}
290 return data
291
292
293def _deep_merge(base: dict[str, Any], over: dict[str, Any]) -> dict[str, Any]:
294 """Recursively merge *over* onto *base* (dicts merge per-key; others replace)."""
295 out = deepcopy(base)
296 for k, v in over.items():
297 if isinstance(v, dict) and isinstance(out.get(k), dict):
298 out[k] = _deep_merge(out[k], v)
299 else:
300 out[k] = deepcopy(v)
301 return out
302
303
304def _as_int(raw: Any, fallback: int) -> int:
305 """Coerce a settings value to ``int``, degrading to *fallback* (plan 237).
306
307 ``load_settings`` is total by contract, so a hostile or fat-fingered value
308 must not raise. Both CAPS-01 keys are lower-only requests, and the fallback
309 for both is "no request" — the bounded shipped value, never "unbounded"."""
310 try:
311 return int(raw) # type: ignore[arg-type]
312 except (TypeError, ValueError):
313 return fallback
314
315
317 raw: Any, *, key: str, warnings: list[str],
318) -> int | None:
319 """Read a positive integer request; tolerate invalid values as absent."""
320 if raw is None:
321 return None
322 try:
323 value = int(raw)
324 except (TypeError, ValueError):
325 value = 0
326 if isinstance(raw, bool) or value <= 0:
327 warnings.append(
328 f"setting {key!r} must be a positive integer; ignoring {raw!r}"
329 )
330 return None
331 return value
332
333
334def _as_float_list(raw: Any) -> list[float]:
335 """Coerce a settings value to a list of floats, dropping what will not parse."""
336 if not isinstance(raw, (list, tuple)):
337 return []
338 out: list[float] = []
339 for item in raw:
340 try:
341 out.append(float(item))
342 except (TypeError, ValueError):
343 continue
344 return out
345
346
347def _warn_unknown(raw: dict[str, Any], warnings: list[str]) -> None:
348 """Record (but tolerate) keys not in the known schema — forward-compat."""
349 known_top = _known_top_keys()
350 known_mode = _known_mode_keys()
351 known_tree = _known_tree_keys()
352 for k in raw:
353 if k not in known_top:
354 warnings.append(f"unknown setting {k!r} ignored (forward-compat)")
355 for mode_name, profile in (raw.get("modes") or {}).items():
356 if isinstance(profile, dict):
357 for k in profile:
358 if k not in known_mode:
359 warnings.append(
360 f"unknown key {k!r} in mode {mode_name!r} ignored (forward-compat)"
361 )
362 tree = raw.get("project_tree")
363 if isinstance(tree, dict):
364 for k in tree:
365 if k not in known_tree:
366 warnings.append(
367 f"unknown project_tree key {k!r} ignored (forward-compat)"
368 )
369
370
371def load_settings(workspace_root: Path | str | None = None) -> OCChatSettings:
372 """Load + deep-merge + validate settings. Never raises (degrades to defaults).
373
374 Merge order: built-in defaults < committed ``oc_chat-settings.json`` <
375 gitignored ``oc_chat-settings.local.json``. Unknown keys in the user files
376 are warned and ignored (forward-compat); the merged result still validates
377 against the known schema."""
378 func = "load_settings"
379 warnings: list[str] = []
380
381 if workspace_root is None:
382 # Plan 151 T2 step 11(b) — BOUNDED subproject-root resolution (the
383 # session_log V23 pattern: one adjacency check, no upward walk).
384 # Post-monorepo-migration find_workspace_root() returns the OUTERMOST
385 # .git (the option_c monorepo root), while the committed settings live
386 # at the oc_chat SUBPROJECT root — the zero-arg form silently degraded
387 # to defaults. The subproject root is the parent of the oc_chat package
388 # dir; if the committed settings file sits there, use it. Otherwise
389 # (wheel install / unusual layout) fall back to the old resolver.
390 pkg_parent = Path(__file__).resolve().parent.parent.parent
391 if (pkg_parent / SETTINGS_FILENAME).is_file():
392 root = pkg_parent
393 else:
394 try:
395 from oc_chat.core._paths import find_workspace_root
396 root = find_workspace_root()
397 except (FileNotFoundError, Exception): # noqa: BLE001 - total by contract
398 root = Path.cwd()
399 else:
400 root = Path(workspace_root)
401
402 committed = _read_json(root / SETTINGS_FILENAME, warnings)
403 local = _read_json(root / LOCAL_SETTINGS_FILENAME, warnings)
404
405 # Warn on unknown keys in BOTH user files (before they're dropped by merge).
406 _warn_unknown(committed, warnings)
407 _warn_unknown(local, warnings)
408
409 merged = _deep_merge(DEFAULT_SETTINGS, committed)
410 merged = _deep_merge(merged, local)
411
412 settings = OCChatSettings(
413 mode=str(merged.get("mode") or "standard"),
414 modes={k: dict(v) for k, v in (merged.get("modes") or {}).items() if isinstance(v, dict)},
415 system_prompt=str(merged.get("system_prompt") or ""),
416 system_prompt_file=(merged.get("system_prompt_file") or None),
417 project_tree=dict(merged.get("project_tree") or {}),
418 verbose=bool(merged.get("verbose", shipped("settings_verbose"))),
419 max_tool_calls_per_turn=int(
420 merged.get("max_tool_calls_per_turn", shipped("tool_calls_per_turn"))),
421 stall_repeat_calls=_as_positive_int_or_none(
422 merged.get("stall_repeat_calls"),
423 key="stall_repeat_calls",
424 warnings=warnings,
425 ),
426 stall_error_streak=_as_positive_int_or_none(
427 merged.get("stall_error_streak"),
428 key="stall_error_streak",
429 warnings=warnings,
430 ),
431 stall_no_progress_iterations=_as_positive_int_or_none(
432 merged.get("stall_no_progress_iterations"),
433 key="stall_no_progress_iterations",
434 warnings=warnings,
435 ),
436 max_output_tokens=int(
437 merged.get("max_output_tokens", shipped("settings_max_output_tokens"))),
438 # CAPS-01: read tolerantly — a malformed value must degrade to "no
439 # request" (the bounded shipped cap / the shipped ladder), never abort
440 # the load, because load_settings is total by contract.
441 session_cost_hard_cap_usd=_as_int(
442 merged.get("session_cost_hard_cap_usd"),
443 shipped("settings_session_cost_hard_cap_usd")),
444 session_cost_thresholds_usd=_as_float_list(
445 merged.get("session_cost_thresholds_usd")),
446 verification=deepcopy(merged.get("verification") or {}),
447 warnings=warnings,
448 )
449 _dbg("OC_CHAT.MAIN", func,
450 f"loaded mode={settings.mode} modes={sorted(settings.modes)} "
451 f"committed={bool(committed)} local={bool(local)} warnings={len(warnings)}", 2)
452 return settings
453
454
456 settings: OCChatSettings, *, default: str, workspace_root: Path | str | None = None,
457) -> str:
458 """Pick the persona text: non-empty inline `system_prompt` > readable
459 `system_prompt_file` > built-in *default*. If both inline and file are set,
460 warn and use inline. A set-but-unreadable file warns and falls back."""
461 if settings.system_prompt.strip():
462 if settings.system_prompt_file:
463 settings.warnings.append(
464 "both system_prompt and system_prompt_file set; using inline system_prompt"
465 )
466 return settings.system_prompt
467 spf = settings.system_prompt_file
468 if spf:
469 # M8.0 WU-2b (F73): route system_prompt_file through the hardened
470 # prompt-read primitive -- forbid absolute paths, clamp to the effective
471 # sandbox root, deny the protected-read (secret) class -- so a secret
472 # cannot be injected into messages[0] and egressed to the provider. The
473 # effective sandbox root (WU-1) supersedes the legacy workspace_root for
474 # this clamp; the parameter is retained for signature compatibility.
475 try:
476 from oc_chat.tools.filesystem import read_prompt_file
477 return read_prompt_file(spf)
478 except PermissionError as exc:
479 settings.warnings.append(
480 f"system_prompt_file {spf!r} refused ({exc}); using built-in default"
481 )
482 except (OSError, UnicodeDecodeError):
483 settings.warnings.append(
484 f"system_prompt_file {spf!r} empty or unreadable; using built-in default"
485 )
486 return default
487
488
489# ── Process-level runtime mode (the launch setting) ───────────────────────
490# The active mode is a per-process launch choice (settings default + CLI
491# override), resolved once and read by the prompt-composition functions.
492_RUNTIME: OCChatSettings | None = None
493_RUNTIME_MODE: str = "standard"
494
495
497 cli_mode: str | None = None, *, workspace_root: Path | str | None = None,
498) -> OCChatSettings:
499 """Resolve + cache the process settings and effective mode. Call once at
500 launch (after CLI parse). ``cli_mode`` overrides the file's ``mode``."""
501 func = "init_runtime_settings"
502 global _RUNTIME, _RUNTIME_MODE
503 _RUNTIME = load_settings(workspace_root)
504 _RUNTIME_MODE = _RUNTIME.resolve_mode(cli_mode)
505 _dbg("OC_CHAT.MAIN", func, f"effective mode={_RUNTIME_MODE}", 2)
506 return _RUNTIME
507
508
509def runtime_settings() -> OCChatSettings:
510 """The cached process settings (lazily loaded with defaults if uninitialised)."""
511 if _RUNTIME is None:
513 assert _RUNTIME is not None
514 return _RUNTIME
515
516
517def runtime_mode() -> str:
518 """The effective active mode for this process."""
519 if _RUNTIME is None:
521 return _RUNTIME_MODE
522
523
524def active_profile() -> dict[str, Any]:
525 """Composition toggles for the effective mode (backfilled defaults)."""
526 return runtime_settings().mode_profile(runtime_mode())
527
528
529def resolve_tool_filter(profile: dict[str, Any]) -> list[str] | None:
530 """Per-mode tool whitelist with IDENTITY semantics (plan 135 §3.2).
531
532 The single helper used at every tool-consumption point (registry, API
533 schemas, compact map). Returns:
534 * ``[]`` when the mode exposes no tools (``include_tools`` false, or
535 ``tools == []``) — an explicit empty whitelist;
536 * ``None`` when the mode exposes ALL discovered tools (``tools`` absent or
537 explicitly ``null``);
538 * the explicit list otherwise (a subset).
539
540 Never branch on ``if profile.get("tools")`` — that conflates ``[]`` (none)
541 with ``None`` (all). This function keys on ``is None``.
542 """
543 if not profile.get("include_tools", True):
544 return []
545 tools = profile.get("tools")
546 if tools is None:
547 return None
548 return list(tools)
549
550
552 """Test helper: clear the cached runtime settings so the next access reloads."""
553 global _RUNTIME, _RUNTIME_MODE
554 _RUNTIME = None
555 _RUNTIME_MODE = "standard"
556
557
558# ── Plan 137: verification-backpressure config (typed resolver) ───────────────
559
560
561@dataclass
563 """Resolved, typed verification-backpressure config (plan 137 §3.7).
564
565 Built by :func:`verification_config` from the deep-merge of the top-level
566 ``verification`` block with the active mode's partial override. ``react_loop``
567 and the sensor consume this typed shape, never ad-hoc nested dicts.
568 """
569
570 enabled: bool = shipped("verification_enabled")
571 mutating_tools: tuple[str, ...] = ("write_file", "oct_new")
572 lint: bool = shipped("verification_lint")
573 typecheck: bool = shipped("verification_typecheck")
574 tests: bool = shipped("verification_tests")
575 profile: str = "default"
576 timeout_s: int = shipped("verification_timeout_s")
577 max_files: int = shipped("verification_max_files")
578 max_findings: int = shipped("verification_max_findings")
579 jobs: int = shipped("verification_jobs")
580 max_runs_per_turn: int = shipped("verification_max_runs_per_turn")
581 baseline: bool = shipped("verification_baseline")
582 baseline_timeout_s: int = shipped("verification_baseline_timeout_s")
583 baseline_scope: tuple[str, ...] | None = None
584 # Not a DEFAULT_SETTINGS leaf: the settings schema carries ``None`` here and
585 # ``verification_config`` resolves None to follow ``enabled``. This dataclass
586 # default is the resolved off-state, deliberately NOT coupled to the shipped
587 # ``verification_enabled`` value.
588 subloop_readonly: bool = False
589
590
591def verification_config(settings: OCChatSettings, mode: str | None = None) -> VerificationConfig:
592 """Resolve the typed :class:`VerificationConfig` for *mode* (plan 137 §3.7).
593
594 Precedence: built-in defaults < committed/local top-level ``verification``
595 (already folded into ``settings.verification`` by :func:`load_settings`) <
596 the active mode's per-mode ``verification`` override. The override is
597 **deep-merged** over the top-level block (``mode_profile``'s shallow update is
598 not relied upon for the sub-keys). ``subloop_readonly`` defaults to follow
599 ``enabled`` when unset (``None``), so the off path is byte-identical.
600 """
601 func = "verification_config"
602 top: dict[str, Any] = dict(settings.verification or {})
603 override: dict[str, Any] = {}
604 if mode:
605 ov = (settings.modes.get(mode) or {}).get("verification")
606 if isinstance(ov, dict):
607 override = ov
608 merged = _deep_merge(top, override) if override else top
609
610 enabled = bool(merged.get("enabled", shipped("verification_enabled")))
611 raw_subloop = merged.get("subloop_readonly", None)
612 subloop_readonly = enabled if raw_subloop is None else bool(raw_subloop)
613
614 raw_scope = merged.get("baseline_scope", None)
615 if raw_scope is None:
616 scope: tuple[str, ...] | None = None
617 elif isinstance(raw_scope, (list, tuple)):
618 scope = tuple(str(s) for s in raw_scope)
619 else:
620 scope = (str(raw_scope),)
621
622 cfg = VerificationConfig(
623 enabled=enabled,
624 mutating_tools=tuple(merged.get("mutating_tools") or ("write_file", "oct_new")),
625 lint=bool(merged.get("lint", shipped("verification_lint"))),
626 typecheck=bool(merged.get("typecheck", shipped("verification_typecheck"))),
627 tests=bool(merged.get("tests", shipped("verification_tests"))),
628 profile=str(merged.get("profile") or "default"),
629 timeout_s=int(merged.get("timeout_s", shipped("verification_timeout_s"))),
630 max_files=int(merged.get("max_files", shipped("verification_max_files"))),
631 max_findings=int(merged.get("max_findings", shipped("verification_max_findings"))),
632 jobs=int(merged.get("jobs", shipped("verification_jobs"))),
633 max_runs_per_turn=int(
634 merged.get("max_runs_per_turn", shipped("verification_max_runs_per_turn"))),
635 baseline=bool(merged.get("baseline", shipped("verification_baseline"))),
636 baseline_timeout_s=int(
637 merged.get("baseline_timeout_s", shipped("verification_baseline_timeout_s"))),
638 baseline_scope=scope,
639 subloop_readonly=subloop_readonly,
640 )
641 _dbg("OC_CHAT.MAIN", func,
642 f"mode={mode} enabled={cfg.enabled} subloop_readonly={cfg.subloop_readonly}", 3)
643 return cfg
str resolve_mode(self, str|None cli_mode)
Definition settings.py:243
dict[str, Any] mode_profile(self, str name)
Definition settings.py:257
dict[str, Any] active_profile()
Definition settings.py:524
None _warn_unknown(dict[str, Any] raw, list[str] warnings)
Definition settings.py:347
dict[str, Any] _deep_merge(dict[str, Any] base, dict[str, Any] over)
Definition settings.py:293
OCChatSettings init_runtime_settings(str|None cli_mode=None, *, Path|str|None workspace_root=None)
Definition settings.py:498
int|None _as_positive_int_or_none(Any raw, *, str key, list[str] warnings)
Definition settings.py:318
OCChatSettings runtime_settings()
Definition settings.py:509
dict[str, Any] _read_json(Path path, list[str] warnings)
Definition settings.py:276
frozenset[str] _known_tree_keys()
Definition settings.py:211
int _as_int(Any raw, int fallback)
Definition settings.py:304
str resolve_system_prompt(OCChatSettings settings, *, str default, Path|str|None workspace_root=None)
Definition settings.py:457
None reset_runtime_settings_for_tests()
Definition settings.py:551
frozenset[str] _known_top_keys()
Definition settings.py:203
frozenset[str] _known_mode_keys()
Definition settings.py:207
list[str]|None resolve_tool_filter(dict[str, Any] profile)
Definition settings.py:529
VerificationConfig verification_config(OCChatSettings settings, str|None mode=None)
Definition settings.py:591
list[float] _as_float_list(Any raw)
Definition settings.py:334
OCChatSettings load_settings(Path|str|None workspace_root=None)
Definition settings.py:371