Coverage for src/ai_shell/config.py: 88%
331 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-13 23:19 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-13 23:19 +0000
1"""Configuration loading for ai-shell.
3Priority (highest wins): CLI flags > env vars > project config > global config > defaults.
5Global config lookup order (first match wins):
6 ~/.ai-shell.yaml > ~/.ai-shell.yml > ~/.ai-shell.toml
7 > ~/.config/ai-shell/config.yaml > ~/.config/ai-shell/config.yml > ~/.config/ai-shell/config.toml
9Project config lookup order (first match wins):
10 .ai-shell.yaml > .ai-shell.yml > .ai-shell.toml > ai-shell.toml
11"""
13from __future__ import annotations
15import logging
16import os
17import tomllib
18from dataclasses import dataclass, field
19from pathlib import Path
21import yaml
23from ai_shell.defaults import (
24 DEFAULT_COMFYUI_PORT,
25 DEFAULT_CONTEXT_SIZE,
26 DEFAULT_DEV_PORTS,
27 DEFAULT_EXTRA_MODELS,
28 DEFAULT_IMAGE,
29 DEFAULT_KOKORO_PORT,
30 DEFAULT_KOKORO_VOICE,
31 DEFAULT_N8N_PORT,
32 DEFAULT_OLLAMA_PORT,
33 DEFAULT_PRIMARY_CHAT_MODEL,
34 DEFAULT_PRIMARY_CODING_MODEL,
35 DEFAULT_SECONDARY_CHAT_MODEL,
36 DEFAULT_SECONDARY_CODING_MODEL,
37 DEFAULT_VOICE_AGENT_PORT,
38 DEFAULT_WEBUI_PORT,
39 DEFAULT_WHISPER_MODEL,
40 DEFAULT_WHISPER_PORT,
41)
43logger = logging.getLogger(__name__)
46@dataclass
47class VoiceAgentModelProfile:
48 """A named pair of primary + secondary chat models for the voice agent."""
50 primary: str = ""
51 secondary: str = ""
54@dataclass
55class VoiceAgentVadConfig:
56 """Silero VAD / barge-in behavior."""
58 silence_timeout_ms: int = 2500
59 barge_in: bool = True
62@dataclass
63class VoiceAgentFilesystemConfig:
64 """Filesystem tool scoping. Consumed by Phase 4."""
66 root: str = "~/gigachad"
67 read: list[str] = field(default_factory=lambda: ["~/gigachad"])
68 write: list[str] = field(default_factory=lambda: ["~/gigachad"])
69 deny_glob: list[str] = field(default_factory=lambda: ["**/.env*", "**/.git/**"])
72@dataclass
73class VoiceAgentMemoryConfig:
74 """Sqlite memory behavior. Consumed by Phase 5."""
76 enabled: bool = True
77 summarize_after_turns: int = 20
80@dataclass
81class VoiceAgentAuthConfig:
82 """App-level session auth. Consumed by Phase 3."""
84 username: str = ""
85 password_bcrypt: str = ""
86 session_secret: str = ""
89@dataclass
90class VoiceAgentProvidersConfig:
91 """LLM provider selection. Consumed by Phase 6."""
93 default: str = "ollama"
94 available: list[str] = field(default_factory=lambda: ["ollama"])
97@dataclass
98class VoiceAgentToolConfig:
99 """A single tool entry under `voice_agent.tools`."""
101 enabled: bool = False
102 provider: str = ""
105@dataclass
106class VoiceAgentToolsConfig:
107 """Tool registry. Consumed by Phase 4."""
109 filesystem: VoiceAgentToolConfig = field(default_factory=VoiceAgentToolConfig)
110 web_search: VoiceAgentToolConfig = field(
111 default_factory=lambda: VoiceAgentToolConfig(provider="brave")
112 )
113 github: VoiceAgentToolConfig = field(default_factory=VoiceAgentToolConfig)
116@dataclass
117class VoiceAgentWakeWordConfig:
118 """Wake-word gating. Consumed by Phase 3."""
120 enabled: bool = False
121 name: str = "hey_jarvis"
124@dataclass
125class VoiceAgentConfig:
126 """Full voice-agent config tree.
128 Phase 2 wires only ``port`` at the container layer. The remaining fields
129 are schema placeholders for Phases 3-6 with reasonable defaults so early
130 adopters can see the shape without the CLI refusing unknown keys.
131 """
133 port: int = DEFAULT_VOICE_AGENT_PORT
134 domain: str = ""
135 profile: str = "resident"
136 profiles: dict[str, VoiceAgentModelProfile] = field(
137 default_factory=lambda: {
138 "resident": VoiceAgentModelProfile(
139 primary="qwen3.5:9b",
140 secondary="huihui_ai/qwen3.5-abliterated:9b",
141 ),
142 "swap": VoiceAgentModelProfile(
143 primary="qwen3.5:27b",
144 secondary="dolphin3:8b",
145 ),
146 }
147 )
148 vad: VoiceAgentVadConfig = field(default_factory=VoiceAgentVadConfig)
149 filesystem: VoiceAgentFilesystemConfig = field(default_factory=VoiceAgentFilesystemConfig)
150 memory: VoiceAgentMemoryConfig = field(default_factory=VoiceAgentMemoryConfig)
151 auth: VoiceAgentAuthConfig = field(default_factory=VoiceAgentAuthConfig)
152 providers: VoiceAgentProvidersConfig = field(default_factory=VoiceAgentProvidersConfig)
153 tools: VoiceAgentToolsConfig = field(default_factory=VoiceAgentToolsConfig)
154 wake_word: VoiceAgentWakeWordConfig = field(default_factory=VoiceAgentWakeWordConfig)
157@dataclass
158class AiShellConfig:
159 """Configuration for ai-shell."""
161 # Container
162 image: str = DEFAULT_IMAGE
163 image_tag: str = "latest"
164 project_name: str = ""
165 project_dir: Path = field(default_factory=Path.cwd)
167 # LLM model slots. Primary = best-available; secondary = best uncensored
168 # alternative. Chat slots are routed to Open WebUI, coding slots to
169 # OpenCode. `extra_models` is a free-form list of additional
170 # Ollama tags to pull alongside the 4 slots (deduped).
171 primary_chat_model: str = DEFAULT_PRIMARY_CHAT_MODEL
172 secondary_chat_model: str = DEFAULT_SECONDARY_CHAT_MODEL
173 primary_coding_model: str = DEFAULT_PRIMARY_CODING_MODEL
174 secondary_coding_model: str = DEFAULT_SECONDARY_CODING_MODEL
175 extra_models: list[str] = field(default_factory=lambda: list(DEFAULT_EXTRA_MODELS))
176 context_size: int = DEFAULT_CONTEXT_SIZE
177 ollama_port: int = DEFAULT_OLLAMA_PORT
178 webui_port: int = DEFAULT_WEBUI_PORT
179 kokoro_port: int = DEFAULT_KOKORO_PORT
180 kokoro_voice: str = DEFAULT_KOKORO_VOICE
181 n8n_port: int = DEFAULT_N8N_PORT
182 whisper_port: int = DEFAULT_WHISPER_PORT
183 whisper_model: str = DEFAULT_WHISPER_MODEL
184 comfyui_port: int = DEFAULT_COMFYUI_PORT
186 # Voice agent (Phase 2 wires `port`; remaining fields are schema
187 # placeholders that Phases 3-6 consume — see VoiceAgentConfig).
188 voice_agent: VoiceAgentConfig = field(default_factory=VoiceAgentConfig)
190 # Extra configuration
191 extra_env: dict[str, str] = field(default_factory=dict)
192 extra_volumes: list[str] = field(default_factory=list)
193 extra_ports: list[int] = field(default_factory=list)
194 # Glob patterns (relative to project_dir) for monorepo workspace
195 # node_modules directories. Each match gets an isolated named volume.
196 node_modules_paths: list[str] = field(default_factory=list)
197 # Home-config basenames (e.g. ".claude", ".codex") to back with a shared
198 # named volume instead of a host bind mount, so tool config/state never
199 # touches the host home directory. Empty = current bind-mount behavior.
200 isolate_home_paths: list[str] = field(default_factory=list)
202 # AWS
203 ai_profile: str = "" # AWS profile for infra (sets AWS_PROFILE in container)
204 aws_region: str = "" # Override AWS_REGION
205 bedrock_profile: str = "" # AWS profile for Bedrock LLM API calls
206 bedrock_region: str = "" # AWS region for Bedrock (falls back to aws_region)
207 bedrock_model: str = "us.meta.llama3-3-70b-instruct-v1:0"
209 # OpenAI
210 openai_profile: str = "" # Suffixed .env key name for multi-account switching
212 # Claude options
213 local_chrome: bool = False # Attach Chrome DevTools MCP to project-scoped host Chrome
214 skip_updates: bool = False # When True, skip pre-launch tool freshness checks
216 # Pre-launch cache TTLs (seconds). Set to 0 to disable.
217 image_pull_cache_ttl: int = 900 # 15 min: skip docker pull if checked recently
218 bedrock_check_cache_ttl: int = 86400 # 24 h: skip Bedrock preflight if checked recently
220 # Per-tool provider
221 claude_provider: str = "" # "anthropic" (default) or "aws"
223 @property
224 def full_image(self) -> str:
225 """Return the full image reference with tag."""
226 return f"{self.image}:{self.image_tag}"
228 @property
229 def dev_ports(self) -> list[int]:
230 """Return deduplicated, sorted list of dev container ports to expose."""
231 return sorted(set(DEFAULT_DEV_PORTS + self.extra_ports))
233 @property
234 def models_to_pull(self) -> list[str]:
235 """Return the full deduped list of Ollama model tags to pull.
237 The 4 slots in order, followed by any ``extra_models``. Duplicates
238 are removed while preserving first-occurrence order.
239 """
240 ordered = [
241 self.primary_chat_model,
242 self.secondary_chat_model,
243 self.primary_coding_model,
244 self.secondary_coding_model,
245 *self.extra_models,
246 ]
247 seen: set[str] = set()
248 deduped: list[str] = []
249 for model in ordered:
250 if model and model not in seen:
251 seen.add(model)
252 deduped.append(model)
253 return deduped
256def load_config(
257 project_override: str | None = None,
258 project_dir: Path | None = None,
259) -> AiShellConfig:
260 """Load configuration from all sources.
262 Priority: CLI overrides > env vars > project toml > global toml > defaults.
263 """
264 config = AiShellConfig()
266 if project_dir:
267 config.project_dir = project_dir
269 # Load global config (~/.augint/ canonical, ~/.ai-shell.yaml legacy fallback)
270 home = Path.home()
271 for candidate in (
272 home / ".augint" / ".ai-shell.yaml",
273 home / ".ai-shell.yaml",
274 home / ".ai-shell.yml",
275 home / ".ai-shell.toml",
276 home / ".config" / "ai-shell" / "config.yaml",
277 home / ".config" / "ai-shell" / "config.yml",
278 home / ".config" / "ai-shell" / "config.toml",
279 ):
280 if candidate.exists():
281 _apply_config(config, candidate)
282 break
284 # Load project config (first match wins)
285 for name in (".ai-shell.yaml", ".ai-shell.yml", ".ai-shell.toml", "ai-shell.toml"):
286 candidate = config.project_dir / name
287 if candidate.exists():
288 _apply_config(config, candidate)
289 break
291 # Apply environment variable overrides
292 _apply_env_vars(config)
294 # Apply CLI overrides
295 if project_override:
296 config.project_name = project_override
298 # Auto-derive project name from CWD if not set
299 if not config.project_name:
300 from ai_shell.defaults import sanitize_project_name
302 config.project_name = sanitize_project_name(config.project_dir)
304 return config
307def _load_config_file(path: Path) -> dict:
308 """Load a YAML or TOML config file and return the parsed dict."""
309 suffix = path.suffix.lower()
310 if suffix in (".yaml", ".yml"):
311 with open(path, encoding="utf-8") as f:
312 return yaml.safe_load(f) or {}
313 with open(path, "rb") as f:
314 return tomllib.load(f)
317_LEGACY_LLM_KEY_HINT = {
318 "primary_model": (
319 "renamed to `primary_coding_model` (coding) or `primary_chat_model` "
320 "(chat). The new config uses 4 role-specific slots; pick the one "
321 "that matches your intent. See the generated .ai-shell.yaml for the "
322 "full layout."
323 ),
324 "fallback_model": (
325 "removed. The previous `fallback_model` was role-ambiguous. Use "
326 "`secondary_chat_model` and `secondary_coding_model` instead "
327 "(both default to the best uncensored variants). See the generated "
328 ".ai-shell.yaml for the full layout."
329 ),
330}
333def _reject_legacy_llm_keys(llm_section: dict, path: Path) -> None:
334 """Raise on deprecated `primary_model` / `fallback_model` keys.
336 These were removed when the llm config split into 4 role-specific slots
337 (primary/secondary x chat/coding). Silently aliasing them would corrupt
338 intent — e.g. the old `fallback_model` meant different things to chat and
339 coding users. Fail loudly with migration guidance.
340 """
341 bad = [k for k in _LEGACY_LLM_KEY_HINT if k in llm_section]
342 if not bad:
343 return
344 lines = [f"\nDeprecated llm key(s) found in {path}:"]
345 for key in bad:
346 lines.append(f" - `{key}`: {_LEGACY_LLM_KEY_HINT[key]}")
347 raise ValueError("\n".join(lines))
350def _apply_voice_agent_config(va: VoiceAgentConfig, data: dict) -> None:
351 """Merge a parsed ``voice_agent:`` section into a VoiceAgentConfig.
353 Only keys present in *data* override defaults; everything else keeps
354 the dataclass default. Nested sections are merged field-by-field so
355 partial user configs work.
356 """
357 if "port" in data:
358 va.port = int(data["port"])
359 if "domain" in data:
360 va.domain = str(data["domain"])
361 if "profile" in data:
362 va.profile = str(data["profile"])
363 if "profiles" in data and isinstance(data["profiles"], dict):
364 for name, entry in data["profiles"].items():
365 profile = va.profiles.get(name, VoiceAgentModelProfile())
366 if isinstance(entry, dict):
367 if "primary" in entry:
368 profile.primary = str(entry["primary"])
369 if "secondary" in entry:
370 profile.secondary = str(entry["secondary"])
371 va.profiles[name] = profile
372 if "vad" in data and isinstance(data["vad"], dict):
373 vad = data["vad"]
374 if "silence_timeout_ms" in vad:
375 va.vad.silence_timeout_ms = int(vad["silence_timeout_ms"])
376 if "barge_in" in vad:
377 va.vad.barge_in = bool(vad["barge_in"])
378 if "filesystem" in data and isinstance(data["filesystem"], dict):
379 fs = data["filesystem"]
380 if "root" in fs:
381 va.filesystem.root = str(fs["root"])
382 if "read" in fs:
383 va.filesystem.read = [str(p) for p in fs["read"]]
384 if "write" in fs:
385 va.filesystem.write = [str(p) for p in fs["write"]]
386 if "deny_glob" in fs:
387 va.filesystem.deny_glob = [str(p) for p in fs["deny_glob"]]
388 if "memory" in data and isinstance(data["memory"], dict):
389 mem = data["memory"]
390 if "enabled" in mem:
391 va.memory.enabled = bool(mem["enabled"])
392 if "summarize_after_turns" in mem:
393 va.memory.summarize_after_turns = int(mem["summarize_after_turns"])
394 if "auth" in data and isinstance(data["auth"], dict):
395 auth = data["auth"]
396 if "username" in auth:
397 va.auth.username = str(auth["username"])
398 if "password_bcrypt" in auth:
399 va.auth.password_bcrypt = str(auth["password_bcrypt"])
400 if "session_secret" in auth:
401 va.auth.session_secret = str(auth["session_secret"])
402 if "providers" in data and isinstance(data["providers"], dict):
403 providers = data["providers"]
404 if "default" in providers:
405 va.providers.default = str(providers["default"])
406 if "available" in providers:
407 va.providers.available = [str(p) for p in providers["available"]]
408 if "tools" in data and isinstance(data["tools"], dict):
409 tools = data["tools"]
410 for tool_name in ("filesystem", "web_search", "github"):
411 entry = tools.get(tool_name)
412 if isinstance(entry, dict):
413 tool = getattr(va.tools, tool_name)
414 if "enabled" in entry:
415 tool.enabled = bool(entry["enabled"])
416 if "provider" in entry:
417 tool.provider = str(entry["provider"])
418 if "wake_word" in data and isinstance(data["wake_word"], dict):
419 wake = data["wake_word"]
420 if "enabled" in wake:
421 va.wake_word.enabled = bool(wake["enabled"])
422 if "name" in wake:
423 va.wake_word.name = str(wake["name"])
426def _apply_config(config: AiShellConfig, path: Path) -> None:
427 """Apply settings from a YAML or TOML config file."""
428 try:
429 data = _load_config_file(path)
430 except (OSError, tomllib.TOMLDecodeError, yaml.YAMLError) as e:
431 logger.warning("Failed to load config from %s: %s", path, e)
432 return
434 logger.debug("Loading config from %s", path)
436 # [container] section
437 container = data.get("container", {})
438 if "image" in container:
439 config.image = container["image"]
440 if "image_tag" in container:
441 config.image_tag = container["image_tag"]
442 if "extra_env" in container:
443 config.extra_env.update(container["extra_env"])
444 if "extra_volumes" in container:
445 config.extra_volumes.extend(container["extra_volumes"])
446 if "ports" in container:
447 config.extra_ports.extend(int(p) for p in container["ports"])
448 if "node_modules_paths" in container:
449 config.node_modules_paths.extend(str(p) for p in container["node_modules_paths"])
450 if "isolate_home_paths" in container:
451 config.isolate_home_paths.extend(str(p) for p in container["isolate_home_paths"])
453 # [llm] section
454 llm = data.get("llm", {})
455 _reject_legacy_llm_keys(llm, path)
456 if "primary_chat_model" in llm:
457 config.primary_chat_model = llm["primary_chat_model"]
458 if "secondary_chat_model" in llm:
459 config.secondary_chat_model = llm["secondary_chat_model"]
460 if "primary_coding_model" in llm:
461 config.primary_coding_model = llm["primary_coding_model"]
462 if "secondary_coding_model" in llm:
463 config.secondary_coding_model = llm["secondary_coding_model"]
464 if "extra_models" in llm:
465 config.extra_models = [str(m) for m in llm["extra_models"]]
466 if "context_size" in llm:
467 config.context_size = int(llm["context_size"])
468 if "ollama_port" in llm:
469 config.ollama_port = int(llm["ollama_port"])
470 if "webui_port" in llm:
471 config.webui_port = int(llm["webui_port"])
472 if "kokoro_port" in llm:
473 config.kokoro_port = int(llm["kokoro_port"])
474 if "kokoro_voice" in llm:
475 config.kokoro_voice = str(llm["kokoro_voice"])
476 if "n8n_port" in llm:
477 config.n8n_port = int(llm["n8n_port"])
478 if "whisper_port" in llm:
479 config.whisper_port = int(llm["whisper_port"])
480 if "whisper_model" in llm:
481 config.whisper_model = str(llm["whisper_model"])
482 if "comfyui_port" in llm:
483 config.comfyui_port = int(llm["comfyui_port"])
485 # [voice_agent] section (top-level, not under llm)
486 if "voice_agent" in data:
487 _apply_voice_agent_config(config.voice_agent, data["voice_agent"])
489 # [aws] section
490 aws = data.get("aws", {})
491 if "ai_profile" in aws:
492 config.ai_profile = aws["ai_profile"]
493 if "region" in aws:
494 config.aws_region = aws["region"]
495 if "bedrock_profile" in aws:
496 config.bedrock_profile = aws["bedrock_profile"]
497 if "bedrock_region" in aws:
498 config.bedrock_region = aws["bedrock_region"]
499 if "bedrock_model" in aws:
500 config.bedrock_model = aws["bedrock_model"]
502 # [openai] section
503 openai = data.get("openai", {})
504 if "profile" in openai:
505 config.openai_profile = openai["profile"]
507 # [claude] section
508 claude_sec = data.get("claude", {})
509 if "provider" in claude_sec:
510 config.claude_provider = claude_sec["provider"]
511 if "local_chrome" in claude_sec:
512 config.local_chrome = bool(claude_sec["local_chrome"])
513 if "skip_updates" in container:
514 config.skip_updates = bool(container["skip_updates"])
515 if "image_pull_cache_ttl" in container:
516 config.image_pull_cache_ttl = int(container["image_pull_cache_ttl"])
517 if "bedrock_check_cache_ttl" in aws:
518 config.bedrock_check_cache_ttl = int(aws["bedrock_check_cache_ttl"])
521_LEGACY_ENV_VARS = {
522 "AI_SHELL_PRIMARY_MODEL": ("AI_SHELL_PRIMARY_CODING_MODEL or AI_SHELL_PRIMARY_CHAT_MODEL"),
523 "AI_SHELL_FALLBACK_MODEL": ("AI_SHELL_SECONDARY_CHAT_MODEL or AI_SHELL_SECONDARY_CODING_MODEL"),
524}
527def _apply_env_vars(config: AiShellConfig) -> None:
528 """Apply AI_SHELL_* environment variable overrides."""
529 bad_env = [k for k in _LEGACY_ENV_VARS if os.environ.get(k) is not None]
530 if bad_env:
531 lines = ["\nDeprecated AI_SHELL_* env var(s) set:"]
532 for key in bad_env:
533 lines.append(f" - {key}: use {_LEGACY_ENV_VARS[key]} instead")
534 raise ValueError("\n".join(lines))
536 env_map: dict[str, tuple[str, type]] = {
537 "AI_SHELL_IMAGE": ("image", str),
538 "AI_SHELL_IMAGE_TAG": ("image_tag", str),
539 "AI_SHELL_PROJECT": ("project_name", str),
540 "AI_SHELL_PRIMARY_CHAT_MODEL": ("primary_chat_model", str),
541 "AI_SHELL_SECONDARY_CHAT_MODEL": ("secondary_chat_model", str),
542 "AI_SHELL_PRIMARY_CODING_MODEL": ("primary_coding_model", str),
543 "AI_SHELL_SECONDARY_CODING_MODEL": ("secondary_coding_model", str),
544 "AI_SHELL_CONTEXT_SIZE": ("context_size", int),
545 "AI_SHELL_OLLAMA_PORT": ("ollama_port", int),
546 "AI_SHELL_WEBUI_PORT": ("webui_port", int),
547 "AI_SHELL_KOKORO_PORT": ("kokoro_port", int),
548 "AI_SHELL_KOKORO_VOICE": ("kokoro_voice", str),
549 "AI_SHELL_N8N_PORT": ("n8n_port", int),
550 "AI_SHELL_WHISPER_PORT": ("whisper_port", int),
551 "AI_SHELL_WHISPER_MODEL": ("whisper_model", str),
552 "AI_SHELL_COMFYUI_PORT": ("comfyui_port", int),
553 "AI_SHELL_AI_PROFILE": ("ai_profile", str),
554 "AI_SHELL_AWS_REGION": ("aws_region", str),
555 "AI_SHELL_BEDROCK_PROFILE": ("bedrock_profile", str),
556 "AI_SHELL_BEDROCK_REGION": ("bedrock_region", str),
557 "AI_SHELL_BEDROCK_MODEL": ("bedrock_model", str),
558 "AI_SHELL_OPENAI_PROFILE": ("openai_profile", str),
559 "AI_SHELL_CLAUDE_PROVIDER": ("claude_provider", str),
560 "AI_SHELL_LOCAL_CHROME": ("local_chrome", bool),
561 "AI_SHELL_SKIP_UPDATES": ("skip_updates", bool),
562 "AI_SHELL_IMAGE_PULL_CACHE_TTL": ("image_pull_cache_ttl", int),
563 "AI_SHELL_BEDROCK_CHECK_CACHE_TTL": ("bedrock_check_cache_ttl", int),
564 }
566 for env_key, (attr, type_fn) in env_map.items():
567 value = os.environ.get(env_key)
568 if value is not None:
569 if type_fn is bool:
570 coerced = value.lower() not in ("0", "false", "no", "")
571 else:
572 coerced = type_fn(value)
573 setattr(config, attr, coerced)
574 logger.debug("Config override from env: %s=%s", env_key, value)
576 # AI_SHELL_PORTS is comma-separated, extends extra_ports
577 ports_value = os.environ.get("AI_SHELL_PORTS")
578 if ports_value:
579 config.extra_ports.extend(int(p.strip()) for p in ports_value.split(",") if p.strip())
581 # AI_SHELL_ISOLATE_HOME_PATHS is comma-separated, extends isolate_home_paths
582 isolate_value = os.environ.get("AI_SHELL_ISOLATE_HOME_PATHS")
583 if isolate_value:
584 config.isolate_home_paths.extend(p.strip() for p in isolate_value.split(",") if p.strip())
586 # Nested voice_agent overrides (flat env vars map to nested fields)
587 voice_agent_port = os.environ.get("AI_SHELL_VOICE_AGENT_PORT")
588 if voice_agent_port is not None:
589 config.voice_agent.port = int(voice_agent_port)
590 voice_agent_domain = os.environ.get("AI_SHELL_VOICE_AGENT_DOMAIN")
591 if voice_agent_domain is not None:
592 config.voice_agent.domain = voice_agent_domain
593 voice_agent_profile = os.environ.get("AI_SHELL_VOICE_AGENT_PROFILE")
594 if voice_agent_profile is not None:
595 config.voice_agent.profile = voice_agent_profile