tactus 0.34.1__py3-none-any.whl → 0.35.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. tactus/__init__.py +1 -1
  2. tactus/adapters/broker_log.py +17 -14
  3. tactus/adapters/channels/__init__.py +17 -15
  4. tactus/adapters/channels/base.py +16 -7
  5. tactus/adapters/channels/broker.py +43 -13
  6. tactus/adapters/channels/cli.py +19 -15
  7. tactus/adapters/channels/host.py +15 -6
  8. tactus/adapters/channels/ipc.py +82 -31
  9. tactus/adapters/channels/sse.py +41 -23
  10. tactus/adapters/cli_hitl.py +19 -19
  11. tactus/adapters/cli_log.py +4 -4
  12. tactus/adapters/control_loop.py +138 -99
  13. tactus/adapters/cost_collector_log.py +9 -9
  14. tactus/adapters/file_storage.py +56 -52
  15. tactus/adapters/http_callback_log.py +23 -13
  16. tactus/adapters/ide_log.py +17 -9
  17. tactus/adapters/lua_tools.py +4 -5
  18. tactus/adapters/mcp.py +16 -19
  19. tactus/adapters/mcp_manager.py +46 -30
  20. tactus/adapters/memory.py +9 -9
  21. tactus/adapters/plugins.py +42 -42
  22. tactus/broker/client.py +75 -78
  23. tactus/broker/protocol.py +57 -57
  24. tactus/broker/server.py +252 -197
  25. tactus/cli/app.py +3 -1
  26. tactus/cli/control.py +2 -2
  27. tactus/core/config_manager.py +181 -135
  28. tactus/core/dependencies/registry.py +66 -48
  29. tactus/core/dsl_stubs.py +222 -163
  30. tactus/core/exceptions.py +10 -1
  31. tactus/core/execution_context.py +152 -112
  32. tactus/core/lua_sandbox.py +72 -64
  33. tactus/core/message_history_manager.py +138 -43
  34. tactus/core/mocking.py +41 -27
  35. tactus/core/output_validator.py +49 -44
  36. tactus/core/registry.py +94 -80
  37. tactus/core/runtime.py +211 -176
  38. tactus/core/template_resolver.py +16 -16
  39. tactus/core/yaml_parser.py +55 -45
  40. tactus/docs/extractor.py +7 -6
  41. tactus/ide/server.py +119 -78
  42. tactus/primitives/control.py +10 -6
  43. tactus/primitives/file.py +48 -46
  44. tactus/primitives/handles.py +47 -35
  45. tactus/primitives/host.py +29 -27
  46. tactus/primitives/human.py +154 -137
  47. tactus/primitives/json.py +22 -23
  48. tactus/primitives/log.py +26 -26
  49. tactus/primitives/message_history.py +285 -31
  50. tactus/primitives/model.py +15 -9
  51. tactus/primitives/procedure.py +86 -64
  52. tactus/primitives/procedure_callable.py +58 -51
  53. tactus/primitives/retry.py +31 -29
  54. tactus/primitives/session.py +42 -29
  55. tactus/primitives/state.py +54 -43
  56. tactus/primitives/step.py +9 -13
  57. tactus/primitives/system.py +34 -21
  58. tactus/primitives/tool.py +44 -31
  59. tactus/primitives/tool_handle.py +76 -54
  60. tactus/primitives/toolset.py +25 -22
  61. tactus/sandbox/config.py +4 -4
  62. tactus/sandbox/container_runner.py +161 -107
  63. tactus/sandbox/docker_manager.py +20 -20
  64. tactus/sandbox/entrypoint.py +16 -14
  65. tactus/sandbox/protocol.py +15 -15
  66. tactus/stdlib/classify/llm.py +1 -3
  67. tactus/stdlib/core/validation.py +0 -3
  68. tactus/testing/pydantic_eval_runner.py +1 -1
  69. tactus/utils/asyncio_helpers.py +27 -0
  70. tactus/utils/cost_calculator.py +7 -7
  71. tactus/utils/model_pricing.py +11 -12
  72. tactus/utils/safe_file_library.py +156 -132
  73. tactus/utils/safe_libraries.py +27 -27
  74. tactus/validation/error_listener.py +18 -5
  75. tactus/validation/semantic_visitor.py +392 -333
  76. tactus/validation/validator.py +89 -49
  77. {tactus-0.34.1.dist-info → tactus-0.35.0.dist-info}/METADATA +12 -3
  78. {tactus-0.34.1.dist-info → tactus-0.35.0.dist-info}/RECORD +81 -80
  79. {tactus-0.34.1.dist-info → tactus-0.35.0.dist-info}/WHEEL +0 -0
  80. {tactus-0.34.1.dist-info → tactus-0.35.0.dist-info}/entry_points.txt +0 -0
  81. {tactus-0.34.1.dist-info → tactus-0.35.0.dist-info}/licenses/LICENSE +0 -0
@@ -4,13 +4,13 @@ Configuration Manager for Tactus.
4
4
  Implements cascading configuration from multiple sources with clear priority ordering.
5
5
  """
6
6
 
7
- import logging
8
- import os
9
- import yaml
10
7
  from pathlib import Path
11
- from typing import Dict, Any, Optional, List, Tuple
12
8
  from copy import deepcopy
13
9
  from dataclasses import dataclass, field
10
+ import logging
11
+ import os
12
+ from typing import Any, Optional
13
+ import yaml
14
14
 
15
15
  logger = logging.getLogger(__name__)
16
16
 
@@ -39,7 +39,7 @@ class ConfigValue:
39
39
  overridden_by: Optional[str] = None
40
40
  """If overridden, what source did the override? None if this is the final value."""
41
41
 
42
- override_chain: List[Tuple[str, Any]] = field(default_factory=list)
42
+ override_chain: list[tuple[str, Any]] = field(default_factory=list)
43
43
  """History of overrides: [(source, value), ...] in chronological order"""
44
44
 
45
45
  is_env_override: bool = False
@@ -48,7 +48,7 @@ class ConfigValue:
48
48
  original_env_var: Optional[str] = None
49
49
  """Original environment variable name if value came from env (e.g., 'OPENAI_API_KEY')"""
50
50
 
51
- def to_dict(self) -> Dict[str, Any]:
51
+ def to_dict(self) -> dict[str, Any]:
52
52
  """Convert to dictionary for JSON serialization."""
53
53
  return {
54
54
  "value": self.value,
@@ -82,7 +82,7 @@ class ConfigManager:
82
82
  self.loaded_configs = [] # Track loaded configs for debugging
83
83
  self.env_var_mapping = {} # Track which env var each config key came from
84
84
 
85
- def load_cascade(self, procedure_path: Path) -> Dict[str, Any]:
85
+ def load_cascade(self, procedure_path: Path) -> dict[str, Any]:
86
86
  """
87
87
  Load and merge all configuration sources in priority order.
88
88
 
@@ -95,53 +95,53 @@ class ConfigManager:
95
95
  Returns:
96
96
  Merged configuration dictionary
97
97
  """
98
- configs = []
98
+ config_sources: list[tuple[str, dict[str, Any]]] = []
99
99
 
100
100
  # 1. System config (lowest precedence)
101
101
  for system_path in self._get_system_config_paths():
102
102
  if system_path.exists():
103
103
  system_config = self._load_yaml_file(system_path)
104
104
  if system_config:
105
- configs.append((f"system:{system_path}", system_config))
106
- logger.debug(f"Loaded system config: {system_path}")
105
+ config_sources.append((f"system:{system_path}", system_config))
106
+ logger.debug("Loaded system config: %s", system_path)
107
107
 
108
108
  # 2. User config (~/.tactus/config.yml, XDG, etc.)
109
109
  for user_path in self._get_user_config_paths():
110
110
  if user_path.exists():
111
111
  user_config = self._load_yaml_file(user_path)
112
112
  if user_config:
113
- configs.append((f"user:{user_path}", user_config))
114
- logger.debug(f"Loaded user config: {user_path}")
113
+ config_sources.append((f"user:{user_path}", user_config))
114
+ logger.debug("Loaded user config: %s", user_path)
115
115
 
116
116
  # 3. Project config (.tactus/config.yml in cwd)
117
117
  root_config_path = Path.cwd() / ".tactus" / "config.yml"
118
118
  if root_config_path.exists():
119
119
  root_config = self._load_yaml_file(root_config_path)
120
120
  if root_config:
121
- configs.append(("root", root_config))
122
- logger.debug(f"Loaded root config: {root_config_path}")
121
+ config_sources.append(("root", root_config))
122
+ logger.debug("Loaded root config: %s", root_config_path)
123
123
 
124
124
  # 4. Parent directory configs (walk up from procedure directory)
125
- procedure_dir = procedure_path.parent.resolve()
126
- parent_configs = self._find_directory_configs(procedure_dir)
127
- for config_path in parent_configs:
128
- config = self._load_yaml_file(config_path)
129
- if config:
130
- configs.append((f"parent:{config_path}", config))
131
- logger.debug(f"Loaded parent config: {config_path}")
125
+ procedure_directory = procedure_path.parent.resolve()
126
+ parent_config_paths = self._find_directory_configs(procedure_directory)
127
+ for config_path in parent_config_paths:
128
+ parent_config = self._load_yaml_file(config_path)
129
+ if parent_config:
130
+ config_sources.append((f"parent:{config_path}", parent_config))
131
+ logger.debug("Loaded parent config: %s", config_path)
132
132
 
133
133
  # 5. Local directory config (.tactus/config.yml in procedure's directory)
134
- local_config_path = procedure_dir / ".tactus" / "config.yml"
135
- if local_config_path.exists() and local_config_path not in parent_configs:
134
+ local_config_path = procedure_directory / ".tactus" / "config.yml"
135
+ if local_config_path.exists() and local_config_path not in parent_config_paths:
136
136
  local_config = self._load_yaml_file(local_config_path)
137
137
  if local_config:
138
- configs.append(("local", local_config))
139
- logger.debug(f"Loaded local config: {local_config_path}")
138
+ config_sources.append(("local", local_config))
139
+ logger.debug("Loaded local config: %s", local_config_path)
140
140
 
141
141
  # 6. Environment variables (override config files)
142
- env_config = self._load_from_environment()
143
- if env_config:
144
- configs.append(("environment", env_config))
142
+ environment_config = self._load_from_environment()
143
+ if environment_config:
144
+ config_sources.append(("environment", environment_config))
145
145
  logger.debug("Loaded config from environment variables")
146
146
 
147
147
  # 7. Sidecar config (highest priority, except CLI args)
@@ -149,16 +149,16 @@ class ConfigManager:
149
149
  if sidecar_path:
150
150
  sidecar_config = self._load_yaml_file(sidecar_path)
151
151
  if sidecar_config:
152
- configs.append(("sidecar", sidecar_config))
153
- logger.debug(f"Loaded sidecar config: {sidecar_path}")
152
+ config_sources.append(("sidecar", sidecar_config))
153
+ logger.debug("Loaded sidecar config: %s", sidecar_path)
154
154
 
155
155
  # Store for debugging
156
- self.loaded_configs = configs
156
+ self.loaded_configs = config_sources
157
157
 
158
158
  # Merge all configs (later configs override earlier ones)
159
- merged = self._merge_configs([c[1] for c in configs])
159
+ merged = self._merge_configs([config for _, config in config_sources])
160
160
 
161
- logger.debug(f"Merged configuration from {len(configs)} source(s)")
161
+ logger.debug("Merged configuration from %s source(s)", len(config_sources))
162
162
  return merged
163
163
 
164
164
  def _find_sidecar_config(self, tac_path: Path) -> Optional[Path]:
@@ -188,7 +188,7 @@ class ConfigManager:
188
188
 
189
189
  return None
190
190
 
191
- def _find_directory_configs(self, start_path: Path) -> List[Path]:
191
+ def _find_directory_configs(self, start_path: Path) -> list[Path]:
192
192
  """
193
193
  Walk up directory tree to find all .tactus/config.yml files.
194
194
 
@@ -217,7 +217,7 @@ class ConfigManager:
217
217
  # Return in order from root to start_path (so later ones override)
218
218
  return list(reversed(configs))
219
219
 
220
- def _load_yaml_file(self, path: Path) -> Optional[Dict[str, Any]]:
220
+ def _load_yaml_file(self, path: Path) -> Optional[dict[str, Any]]:
221
221
  """
222
222
  Load YAML configuration file.
223
223
 
@@ -228,14 +228,14 @@ class ConfigManager:
228
228
  Configuration dictionary or None if loading fails
229
229
  """
230
230
  try:
231
- with open(path, "r") as f:
232
- config = yaml.safe_load(f)
233
- return config if isinstance(config, dict) else {}
234
- except Exception as e:
235
- logger.warning(f"Failed to load config from {path}: {e}")
231
+ with open(path, "r") as file_handle:
232
+ loaded_config = yaml.safe_load(file_handle)
233
+ return loaded_config if isinstance(loaded_config, dict) else {}
234
+ except Exception as exception:
235
+ logger.warning("Failed to load config from %s: %s", path, exception)
236
236
  return None
237
237
 
238
- def _load_from_environment(self) -> Dict[str, Any]:
238
+ def _load_from_environment(self) -> dict[str, Any]:
239
239
  """
240
240
  Load configuration from environment variables.
241
241
 
@@ -244,11 +244,11 @@ class ConfigManager:
244
244
  Returns:
245
245
  Configuration dictionary from environment
246
246
  """
247
- config = {}
247
+ config: dict[str, Any] = {}
248
248
 
249
249
  # Load known config keys from environment
250
250
  # NOTE: Keys must match the config file structure (nested under provider name)
251
- env_mappings = {
251
+ env_var_to_config_path = {
252
252
  "OPENAI_API_KEY": ("openai", "api_key"),
253
253
  "GOOGLE_API_KEY": ("google", "api_key"),
254
254
  "AWS_ACCESS_KEY_ID": ("aws", "access_key_id"),
@@ -256,6 +256,7 @@ class ConfigManager:
256
256
  "AWS_DEFAULT_REGION": ("aws", "default_region"),
257
257
  "AWS_PROFILE": ("aws", "profile"),
258
258
  "TOOL_PATHS": "tool_paths",
259
+ "TACTUS_DEFAULT_PROVIDER": "default_provider",
259
260
  # Sandbox configuration
260
261
  "TACTUS_SANDBOX_ENABLED": ("sandbox", "enabled"),
261
262
  "TACTUS_SANDBOX_IMAGE": ("sandbox", "image"),
@@ -279,49 +280,49 @@ class ConfigManager:
279
280
  }
280
281
 
281
282
  # Boolean env vars that need special parsing
282
- boolean_env_keys = {
283
+ boolean_env_var_keys = {
283
284
  "TACTUS_SANDBOX_ENABLED",
284
285
  "TACTUS_NOTIFICATIONS_ENABLED",
285
286
  "TACTUS_CONTROL_ENABLED",
286
287
  "TACTUS_CONTROL_CLI_ENABLED",
287
288
  }
288
289
 
289
- for env_key, config_key in env_mappings.items():
290
- value = os.environ.get(env_key)
291
- if value:
290
+ for env_var_name, config_key in env_var_to_config_path.items():
291
+ env_var_value = os.environ.get(env_var_name)
292
+ if env_var_value:
292
293
  # Parse boolean values
293
- if env_key in boolean_env_keys:
294
- value = value.lower() in ("true", "1", "yes", "on")
294
+ if env_var_name in boolean_env_var_keys:
295
+ env_var_value = env_var_value.lower() in ("true", "1", "yes", "on")
295
296
 
296
297
  if isinstance(config_key, tuple):
297
298
  # Nested key - handle arbitrary depth
298
299
  # e.g., ("aws", "access_key_id") -> config["aws"]["access_key_id"]
299
300
  # e.g., ("notifications", "channels", "slack", "token")
300
- current = config
301
- for i, key in enumerate(config_key[:-1]):
302
- if key not in current:
303
- current[key] = {}
304
- current = current[key]
305
- current[config_key[-1]] = value
301
+ current_container = config
302
+ for key in config_key[:-1]:
303
+ if key not in current_container:
304
+ current_container[key] = {}
305
+ current_container = current_container[key]
306
+ current_container[config_key[-1]] = env_var_value
306
307
  # Track env var name for this nested key
307
- path = ".".join(config_key)
308
- self.env_var_mapping[path] = env_key
308
+ config_path = ".".join(config_key)
309
+ self.env_var_mapping[config_path] = env_var_name
309
310
  elif config_key == "tool_paths":
310
311
  # Parse JSON list
311
312
  import json
312
313
 
313
314
  try:
314
- config[config_key] = json.loads(value)
315
- self.env_var_mapping[config_key] = env_key
315
+ config[config_key] = json.loads(env_var_value)
316
+ self.env_var_mapping[config_key] = env_var_name
316
317
  except json.JSONDecodeError:
317
- logger.warning(f"Failed to parse TOOL_PATHS as JSON: {value}")
318
+ logger.warning("Failed to parse TOOL_PATHS as JSON: %s", env_var_value)
318
319
  else:
319
- config[config_key] = value
320
- self.env_var_mapping[config_key] = env_key
320
+ config[config_key] = env_var_value
321
+ self.env_var_mapping[config_key] = env_var_name
321
322
 
322
323
  return config
323
324
 
324
- def _get_system_config_paths(self) -> List[Path]:
325
+ def _get_system_config_paths(self) -> list[Path]:
325
326
  """
326
327
  Return system-wide config locations (lowest precedence).
327
328
 
@@ -336,13 +337,13 @@ class ConfigManager:
336
337
  Path("/usr/local/etc/tactus/config.yml"),
337
338
  ]
338
339
 
339
- def _get_user_config_paths(self) -> List[Path]:
340
+ def _get_user_config_paths(self) -> list[Path]:
340
341
  """
341
342
  Return per-user config locations (lower precedence than project configs).
342
343
 
343
344
  Order is from lower to higher precedence so later configs override earlier ones.
344
345
  """
345
- paths: List[Path] = []
346
+ paths: list[Path] = []
346
347
 
347
348
  xdg_home = os.environ.get("XDG_CONFIG_HOME")
348
349
  if xdg_home:
@@ -355,14 +356,14 @@ class ConfigManager:
355
356
 
356
357
  # Deduplicate while preserving order
357
358
  seen = set()
358
- unique: List[Path] = []
359
+ unique: list[Path] = []
359
360
  for p in paths:
360
361
  if p not in seen:
361
362
  unique.append(p)
362
363
  seen.add(p)
363
364
  return unique
364
365
 
365
- def _merge_configs(self, configs: List[Dict[str, Any]]) -> Dict[str, Any]:
366
+ def _merge_configs(self, configs: list[dict[str, Any]]) -> dict[str, Any]:
366
367
  """
367
368
  Deep merge multiple configuration dictionaries.
368
369
 
@@ -379,14 +380,14 @@ class ConfigManager:
379
380
  if not configs:
380
381
  return {}
381
382
 
382
- result = {}
383
+ merged_config: dict[str, Any] = {}
383
384
 
384
385
  for config in configs:
385
- result = self._deep_merge(result, config)
386
+ merged_config = self._deep_merge(merged_config, config)
386
387
 
387
- return result
388
+ return merged_config
388
389
 
389
- def _deep_merge(self, base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
390
+ def _deep_merge(self, base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
390
391
  """
391
392
  Deep merge two dictionaries.
392
393
 
@@ -397,15 +398,15 @@ class ConfigManager:
397
398
  Returns:
398
399
  Merged dictionary
399
400
  """
400
- result = deepcopy(base)
401
+ merged_result = deepcopy(base)
401
402
 
402
403
  for key, value in override.items():
403
- if key in result:
404
- base_value = result[key]
404
+ if key in merged_result:
405
+ base_value = merged_result[key]
405
406
 
406
407
  # If both are dicts, deep merge
407
408
  if isinstance(base_value, dict) and isinstance(value, dict):
408
- result[key] = self._deep_merge(base_value, value)
409
+ merged_result[key] = self._deep_merge(base_value, value)
409
410
 
410
411
  # If both are lists, extend (combine)
411
412
  elif isinstance(base_value, list) and isinstance(value, list):
@@ -414,25 +415,25 @@ class ConfigManager:
414
415
  for item in value:
415
416
  if item not in combined:
416
417
  combined.append(item)
417
- result[key] = combined
418
+ merged_result[key] = combined
418
419
 
419
420
  # Otherwise, override takes precedence
420
421
  else:
421
- result[key] = deepcopy(value)
422
+ merged_result[key] = deepcopy(value)
422
423
  else:
423
- result[key] = deepcopy(value)
424
+ merged_result[key] = deepcopy(value)
424
425
 
425
- return result
426
+ return merged_result
426
427
 
427
428
  def _deep_merge_with_tracking(
428
429
  self,
429
- base: Dict[str, Any],
430
- override: Dict[str, Any],
430
+ base: dict[str, Any],
431
+ override: dict[str, Any],
431
432
  base_source: str,
432
433
  override_source: str,
433
434
  path_prefix: str = "",
434
- base_source_map: Optional[Dict[str, ConfigValue]] = None,
435
- ) -> Tuple[Dict[str, Any], Dict[str, ConfigValue]]:
435
+ base_source_map: Optional[dict[str, ConfigValue]] = None,
436
+ ) -> tuple[dict[str, Any], dict[str, ConfigValue]]:
436
437
  """
437
438
  Deep merge with source tracking at every level.
438
439
 
@@ -452,8 +453,8 @@ class ConfigManager:
452
453
  - merged_dict: The merged configuration
453
454
  - source_map: Dict mapping paths to ConfigValue objects
454
455
  """
455
- result = deepcopy(base)
456
- source_map: Dict[str, ConfigValue] = base_source_map.copy() if base_source_map else {}
456
+ merged_result = deepcopy(base)
457
+ source_map: dict[str, ConfigValue] = base_source_map.copy() if base_source_map else {}
457
458
 
458
459
  # Normalize source types
459
460
  base_source_type = base_source.split(":")[0] if ":" in base_source else base_source
@@ -464,14 +465,16 @@ class ConfigManager:
464
465
  for key, value in override.items():
465
466
  current_path = f"{path_prefix}.{key}" if path_prefix else key
466
467
 
467
- if key in result:
468
- base_value = result[key]
468
+ if key in merged_result:
469
+ base_value = merged_result[key]
469
470
 
470
471
  # If both are dicts, deep merge recursively with tracking
471
472
  if isinstance(base_value, dict) and isinstance(value, dict):
472
473
  # Get nested source map for base
473
474
  nested_base_source_map = {
474
- k: v for k, v in source_map.items() if k.startswith(current_path + ".")
475
+ key_path: config_value
476
+ for key_path, config_value in source_map.items()
477
+ if key_path.startswith(current_path + ".")
475
478
  }
476
479
 
477
480
  # Ensure all base dict values are tracked before merge
@@ -494,7 +497,7 @@ class ConfigManager:
494
497
  current_path,
495
498
  nested_base_source_map,
496
499
  )
497
- result[key] = merged_dict
500
+ merged_result[key] = merged_dict
498
501
 
499
502
  # Update source map with nested results
500
503
  source_map.update(nested_source_map)
@@ -505,7 +508,10 @@ class ConfigManager:
505
508
  override_chain = source_map[current_path].override_chain.copy()
506
509
  override_chain.append((override_source, value))
507
510
  else:
508
- override_chain = [(base_source, base_value), (override_source, value)]
511
+ override_chain = [
512
+ (base_source, base_value),
513
+ (override_source, value),
514
+ ]
509
515
 
510
516
  # Get env var name if from environment
511
517
  env_var_name = None
@@ -530,14 +536,17 @@ class ConfigManager:
530
536
  for item in value:
531
537
  if item not in combined:
532
538
  combined.append(item)
533
- result[key] = combined
539
+ merged_result[key] = combined
534
540
 
535
541
  # Track list override
536
542
  if current_path in source_map:
537
543
  override_chain = source_map[current_path].override_chain.copy()
538
544
  override_chain.append((override_source, value))
539
545
  else:
540
- override_chain = [(base_source, base_value), (override_source, value)]
546
+ override_chain = [
547
+ (base_source, base_value),
548
+ (override_source, value),
549
+ ]
541
550
 
542
551
  # Get env var name if from environment
543
552
  env_var_name = None
@@ -557,14 +566,17 @@ class ConfigManager:
557
566
 
558
567
  # Otherwise, override takes precedence
559
568
  else:
560
- result[key] = deepcopy(value)
569
+ merged_result[key] = deepcopy(value)
561
570
 
562
571
  # Track simple value override
563
572
  if current_path in source_map:
564
573
  override_chain = source_map[current_path].override_chain.copy()
565
574
  override_chain.append((override_source, value))
566
575
  else:
567
- override_chain = [(base_source, base_value), (override_source, value)]
576
+ override_chain = [
577
+ (base_source, base_value),
578
+ (override_source, value),
579
+ ]
568
580
 
569
581
  # Get env var name if from environment
570
582
  env_var_name = None
@@ -583,7 +595,7 @@ class ConfigManager:
583
595
  )
584
596
  else:
585
597
  # New key, not an override
586
- result[key] = deepcopy(value)
598
+ merged_result[key] = deepcopy(value)
587
599
 
588
600
  # Get env var name if from environment
589
601
  env_var_name = None
@@ -605,10 +617,14 @@ class ConfigManager:
605
617
  # For nested dicts/lists in new keys, track their children
606
618
  if isinstance(value, dict):
607
619
  self._track_nested_values(
608
- value, override_source, override_source_type, current_path, source_map
620
+ value,
621
+ override_source,
622
+ override_source_type,
623
+ current_path,
624
+ source_map,
609
625
  )
610
626
 
611
- return result, source_map
627
+ return merged_result, source_map
612
628
 
613
629
  def _track_nested_values(
614
630
  self,
@@ -616,7 +632,7 @@ class ConfigManager:
616
632
  source: str,
617
633
  source_type: str,
618
634
  path_prefix: str,
619
- source_map: Dict[str, ConfigValue],
635
+ source_map: dict[str, ConfigValue],
620
636
  overwrite: bool = True,
621
637
  ) -> None:
622
638
  """
@@ -641,7 +657,12 @@ class ConfigManager:
641
657
  # Still recurse for nested structures
642
658
  if isinstance(value, (dict, list)):
643
659
  self._track_nested_values(
644
- value, source, source_type, current_path, source_map, overwrite
660
+ value,
661
+ source,
662
+ source_type,
663
+ current_path,
664
+ source_map,
665
+ overwrite,
645
666
  )
646
667
  continue
647
668
 
@@ -666,7 +687,12 @@ class ConfigManager:
666
687
  )
667
688
  if isinstance(value, (dict, list)):
668
689
  self._track_nested_values(
669
- value, source, source_type, current_path, source_map, overwrite
690
+ value,
691
+ source,
692
+ source_type,
693
+ current_path,
694
+ source_map,
695
+ overwrite,
670
696
  )
671
697
  elif isinstance(obj, list):
672
698
  for i, item in enumerate(obj):
@@ -676,7 +702,12 @@ class ConfigManager:
676
702
  # Still recurse for nested structures
677
703
  if isinstance(item, (dict, list)):
678
704
  self._track_nested_values(
679
- item, source, source_type, current_path, source_map, overwrite
705
+ item,
706
+ source,
707
+ source_type,
708
+ current_path,
709
+ source_map,
710
+ overwrite,
680
711
  )
681
712
  continue
682
713
 
@@ -692,7 +723,12 @@ class ConfigManager:
692
723
  )
693
724
  if isinstance(item, (dict, list)):
694
725
  self._track_nested_values(
695
- item, source, source_type, current_path, source_map, overwrite
726
+ item,
727
+ source,
728
+ source_type,
729
+ current_path,
730
+ source_map,
731
+ overwrite,
696
732
  )
697
733
 
698
734
  def _extract_env_var_name(self, source: str) -> Optional[str]:
@@ -711,7 +747,7 @@ class ConfigManager:
711
747
 
712
748
  def load_cascade_with_sources(
713
749
  self, procedure_path: Path
714
- ) -> Tuple[Dict[str, Any], Dict[str, ConfigValue]]:
750
+ ) -> tuple[dict[str, Any], dict[str, ConfigValue]]:
715
751
  """
716
752
  Load cascade and return both merged config and detailed source map.
717
753
 
@@ -729,59 +765,60 @@ class ConfigManager:
729
765
  - merged_config: Traditional flat merged config (backward compatible)
730
766
  - source_map: Dict mapping paths to ConfigValue objects with full metadata
731
767
  """
732
- configs = []
768
+ config_sources: list[tuple[str, dict[str, Any]]] = []
733
769
 
734
770
  # 1. System config (lowest precedence)
735
771
  for system_path in self._get_system_config_paths():
736
772
  if system_path.exists():
737
773
  system_config = self._load_yaml_file(system_path)
738
774
  if system_config:
739
- configs.append((f"system:{system_path}", system_config))
740
- logger.debug(f"Loaded system config: {system_path}")
775
+ config_sources.append((f"system:{system_path}", system_config))
776
+ logger.debug("Loaded system config: %s", system_path)
741
777
 
742
778
  # 2. User config (~/.tactus/config.yml, XDG, etc.)
743
779
  for user_path in self._get_user_config_paths():
744
780
  if user_path.exists():
745
781
  user_config = self._load_yaml_file(user_path)
746
782
  if user_config:
747
- configs.append((f"user:{user_path}", user_config))
748
- logger.debug(f"Loaded user config: {user_path}")
783
+ config_sources.append((f"user:{user_path}", user_config))
784
+ logger.debug("Loaded user config: %s", user_path)
749
785
 
750
786
  # 3. Project config (.tactus/config.yml in cwd)
751
787
  root_config_path = Path.cwd() / ".tactus" / "config.yml"
752
788
  if root_config_path.exists():
753
789
  root_config = self._load_yaml_file(root_config_path)
754
790
  if root_config:
755
- configs.append((f"project:{root_config_path}", root_config))
756
- logger.debug(f"Loaded root config: {root_config_path}")
791
+ config_sources.append((f"project:{root_config_path}", root_config))
792
+ logger.debug("Loaded root config: %s", root_config_path)
757
793
 
758
794
  # 4. Parent directory configs (walk up from procedure directory)
759
- procedure_dir = procedure_path.parent.resolve()
760
- parent_configs = self._find_directory_configs(procedure_dir)
761
- for config_path in parent_configs:
762
- config = self._load_yaml_file(config_path)
763
- if config:
764
- configs.append((f"parent:{config_path}", config))
765
- logger.debug(f"Loaded parent config: {config_path}")
795
+ procedure_directory = procedure_path.parent.resolve()
796
+ parent_config_paths = self._find_directory_configs(procedure_directory)
797
+ for config_path in parent_config_paths:
798
+ parent_config = self._load_yaml_file(config_path)
799
+ if parent_config:
800
+ config_sources.append((f"parent:{config_path}", parent_config))
801
+ logger.debug("Loaded parent config: %s", config_path)
766
802
 
767
803
  # 5. Local directory config (.tactus/config.yml in procedure's directory)
768
- local_config_path = procedure_dir / ".tactus" / "config.yml"
804
+ local_config_path = procedure_directory / ".tactus" / "config.yml"
769
805
  if (
770
806
  local_config_path.exists()
771
- and local_config_path not in [root_config_path] + parent_configs
807
+ and local_config_path not in [root_config_path] + parent_config_paths
772
808
  ):
773
809
  local_config = self._load_yaml_file(local_config_path)
774
810
  if local_config:
775
- configs.append((f"local:{local_config_path}", local_config))
776
- logger.debug(f"Loaded local config: {local_config_path}")
811
+ config_sources.append((f"local:{local_config_path}", local_config))
812
+ logger.debug("Loaded local config: %s", local_config_path)
777
813
 
778
814
  # 6. Environment variables (override config files)
779
- env_config = self._load_from_environment()
780
- if env_config:
815
+ environment_config = self._load_from_environment()
816
+ if environment_config:
781
817
  # We use "environment" as source, but individual var names are in self.env_var_mapping
782
- configs.append(("environment", env_config))
818
+ config_sources.append(("environment", environment_config))
783
819
  logger.debug(
784
- f"Loaded config from environment variables: {list(self.env_var_mapping.keys())}"
820
+ "Loaded config from environment variables: %s",
821
+ list(self.env_var_mapping.keys()),
785
822
  )
786
823
 
787
824
  # 7. Sidecar config (highest priority, except CLI args)
@@ -789,29 +826,38 @@ class ConfigManager:
789
826
  if sidecar_path:
790
827
  sidecar_config = self._load_yaml_file(sidecar_path)
791
828
  if sidecar_config:
792
- configs.append((f"sidecar:{sidecar_path}", sidecar_config))
793
- logger.info(f"Loaded sidecar config: {sidecar_path}")
829
+ config_sources.append((f"sidecar:{sidecar_path}", sidecar_config))
830
+ logger.info("Loaded sidecar config: %s", sidecar_path)
794
831
 
795
832
  # Store for debugging
796
- self.loaded_configs = configs
833
+ self.loaded_configs = config_sources
797
834
 
798
835
  # Merge all configs with source tracking
799
- if not configs:
836
+ if not config_sources:
800
837
  return {}, {}
801
838
 
802
839
  # Start with first config
803
- source, config = configs[0]
804
- result = deepcopy(config)
805
- source_map: Dict[str, ConfigValue] = {}
840
+ first_source, first_config = config_sources[0]
841
+ result = deepcopy(first_config)
842
+ source_map: dict[str, ConfigValue] = {}
806
843
 
807
844
  # Track initial values
808
- self._track_nested_values(config, source, source.split(":")[0], "", source_map)
845
+ self._track_nested_values(
846
+ first_config,
847
+ first_source,
848
+ first_source.split(":")[0],
849
+ "",
850
+ source_map,
851
+ )
809
852
 
810
853
  # Merge remaining configs with tracking
811
- for source, config in configs[1:]:
854
+ for source, config in config_sources[1:]:
812
855
  result, source_map = self._deep_merge_with_tracking(
813
856
  result, config, "merged", source, "", source_map
814
857
  )
815
858
 
816
- logger.info(f"Merged configuration from {len(configs)} source(s) with full tracking")
859
+ logger.info(
860
+ "Merged configuration from %s source(s) with full tracking",
861
+ len(config_sources),
862
+ )
817
863
  return result, source_map