tactus 0.31.2__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 (160) hide show
  1. tactus/__init__.py +49 -0
  2. tactus/adapters/__init__.py +9 -0
  3. tactus/adapters/broker_log.py +76 -0
  4. tactus/adapters/cli_hitl.py +189 -0
  5. tactus/adapters/cli_log.py +223 -0
  6. tactus/adapters/cost_collector_log.py +56 -0
  7. tactus/adapters/file_storage.py +367 -0
  8. tactus/adapters/http_callback_log.py +109 -0
  9. tactus/adapters/ide_log.py +71 -0
  10. tactus/adapters/lua_tools.py +336 -0
  11. tactus/adapters/mcp.py +289 -0
  12. tactus/adapters/mcp_manager.py +196 -0
  13. tactus/adapters/memory.py +53 -0
  14. tactus/adapters/plugins.py +419 -0
  15. tactus/backends/http_backend.py +58 -0
  16. tactus/backends/model_backend.py +35 -0
  17. tactus/backends/pytorch_backend.py +110 -0
  18. tactus/broker/__init__.py +12 -0
  19. tactus/broker/client.py +247 -0
  20. tactus/broker/protocol.py +183 -0
  21. tactus/broker/server.py +1123 -0
  22. tactus/broker/stdio.py +12 -0
  23. tactus/cli/__init__.py +7 -0
  24. tactus/cli/app.py +2245 -0
  25. tactus/cli/commands/__init__.py +0 -0
  26. tactus/core/__init__.py +32 -0
  27. tactus/core/config_manager.py +790 -0
  28. tactus/core/dependencies/__init__.py +14 -0
  29. tactus/core/dependencies/registry.py +180 -0
  30. tactus/core/dsl_stubs.py +2117 -0
  31. tactus/core/exceptions.py +66 -0
  32. tactus/core/execution_context.py +480 -0
  33. tactus/core/lua_sandbox.py +508 -0
  34. tactus/core/message_history_manager.py +236 -0
  35. tactus/core/mocking.py +286 -0
  36. tactus/core/output_validator.py +291 -0
  37. tactus/core/registry.py +499 -0
  38. tactus/core/runtime.py +2907 -0
  39. tactus/core/template_resolver.py +142 -0
  40. tactus/core/yaml_parser.py +301 -0
  41. tactus/docker/Dockerfile +61 -0
  42. tactus/docker/entrypoint.sh +69 -0
  43. tactus/dspy/__init__.py +39 -0
  44. tactus/dspy/agent.py +1144 -0
  45. tactus/dspy/broker_lm.py +181 -0
  46. tactus/dspy/config.py +212 -0
  47. tactus/dspy/history.py +196 -0
  48. tactus/dspy/module.py +405 -0
  49. tactus/dspy/prediction.py +318 -0
  50. tactus/dspy/signature.py +185 -0
  51. tactus/formatting/__init__.py +7 -0
  52. tactus/formatting/formatter.py +437 -0
  53. tactus/ide/__init__.py +9 -0
  54. tactus/ide/coding_assistant.py +343 -0
  55. tactus/ide/server.py +2223 -0
  56. tactus/primitives/__init__.py +49 -0
  57. tactus/primitives/control.py +168 -0
  58. tactus/primitives/file.py +229 -0
  59. tactus/primitives/handles.py +378 -0
  60. tactus/primitives/host.py +94 -0
  61. tactus/primitives/human.py +342 -0
  62. tactus/primitives/json.py +189 -0
  63. tactus/primitives/log.py +187 -0
  64. tactus/primitives/message_history.py +157 -0
  65. tactus/primitives/model.py +163 -0
  66. tactus/primitives/procedure.py +564 -0
  67. tactus/primitives/procedure_callable.py +318 -0
  68. tactus/primitives/retry.py +155 -0
  69. tactus/primitives/session.py +152 -0
  70. tactus/primitives/state.py +182 -0
  71. tactus/primitives/step.py +209 -0
  72. tactus/primitives/system.py +93 -0
  73. tactus/primitives/tool.py +375 -0
  74. tactus/primitives/tool_handle.py +279 -0
  75. tactus/primitives/toolset.py +229 -0
  76. tactus/protocols/__init__.py +38 -0
  77. tactus/protocols/chat_recorder.py +81 -0
  78. tactus/protocols/config.py +97 -0
  79. tactus/protocols/cost.py +31 -0
  80. tactus/protocols/hitl.py +71 -0
  81. tactus/protocols/log_handler.py +27 -0
  82. tactus/protocols/models.py +355 -0
  83. tactus/protocols/result.py +33 -0
  84. tactus/protocols/storage.py +90 -0
  85. tactus/providers/__init__.py +13 -0
  86. tactus/providers/base.py +92 -0
  87. tactus/providers/bedrock.py +117 -0
  88. tactus/providers/google.py +105 -0
  89. tactus/providers/openai.py +98 -0
  90. tactus/sandbox/__init__.py +63 -0
  91. tactus/sandbox/config.py +171 -0
  92. tactus/sandbox/container_runner.py +1099 -0
  93. tactus/sandbox/docker_manager.py +433 -0
  94. tactus/sandbox/entrypoint.py +227 -0
  95. tactus/sandbox/protocol.py +213 -0
  96. tactus/stdlib/__init__.py +10 -0
  97. tactus/stdlib/io/__init__.py +13 -0
  98. tactus/stdlib/io/csv.py +88 -0
  99. tactus/stdlib/io/excel.py +136 -0
  100. tactus/stdlib/io/file.py +90 -0
  101. tactus/stdlib/io/fs.py +154 -0
  102. tactus/stdlib/io/hdf5.py +121 -0
  103. tactus/stdlib/io/json.py +109 -0
  104. tactus/stdlib/io/parquet.py +83 -0
  105. tactus/stdlib/io/tsv.py +88 -0
  106. tactus/stdlib/loader.py +274 -0
  107. tactus/stdlib/tac/tactus/tools/done.tac +33 -0
  108. tactus/stdlib/tac/tactus/tools/log.tac +50 -0
  109. tactus/testing/README.md +273 -0
  110. tactus/testing/__init__.py +61 -0
  111. tactus/testing/behave_integration.py +380 -0
  112. tactus/testing/context.py +486 -0
  113. tactus/testing/eval_models.py +114 -0
  114. tactus/testing/evaluation_runner.py +222 -0
  115. tactus/testing/evaluators.py +634 -0
  116. tactus/testing/events.py +94 -0
  117. tactus/testing/gherkin_parser.py +134 -0
  118. tactus/testing/mock_agent.py +315 -0
  119. tactus/testing/mock_dependencies.py +234 -0
  120. tactus/testing/mock_hitl.py +171 -0
  121. tactus/testing/mock_registry.py +168 -0
  122. tactus/testing/mock_tools.py +133 -0
  123. tactus/testing/models.py +115 -0
  124. tactus/testing/pydantic_eval_runner.py +508 -0
  125. tactus/testing/steps/__init__.py +13 -0
  126. tactus/testing/steps/builtin.py +902 -0
  127. tactus/testing/steps/custom.py +69 -0
  128. tactus/testing/steps/registry.py +68 -0
  129. tactus/testing/test_runner.py +489 -0
  130. tactus/tracing/__init__.py +5 -0
  131. tactus/tracing/trace_manager.py +417 -0
  132. tactus/utils/__init__.py +1 -0
  133. tactus/utils/cost_calculator.py +72 -0
  134. tactus/utils/model_pricing.py +132 -0
  135. tactus/utils/safe_file_library.py +502 -0
  136. tactus/utils/safe_libraries.py +234 -0
  137. tactus/validation/LuaLexerBase.py +66 -0
  138. tactus/validation/LuaParserBase.py +23 -0
  139. tactus/validation/README.md +224 -0
  140. tactus/validation/__init__.py +7 -0
  141. tactus/validation/error_listener.py +21 -0
  142. tactus/validation/generated/LuaLexer.interp +231 -0
  143. tactus/validation/generated/LuaLexer.py +5548 -0
  144. tactus/validation/generated/LuaLexer.tokens +124 -0
  145. tactus/validation/generated/LuaLexerBase.py +66 -0
  146. tactus/validation/generated/LuaParser.interp +173 -0
  147. tactus/validation/generated/LuaParser.py +6439 -0
  148. tactus/validation/generated/LuaParser.tokens +124 -0
  149. tactus/validation/generated/LuaParserBase.py +23 -0
  150. tactus/validation/generated/LuaParserVisitor.py +118 -0
  151. tactus/validation/generated/__init__.py +7 -0
  152. tactus/validation/grammar/LuaLexer.g4 +123 -0
  153. tactus/validation/grammar/LuaParser.g4 +178 -0
  154. tactus/validation/semantic_visitor.py +817 -0
  155. tactus/validation/validator.py +157 -0
  156. tactus-0.31.2.dist-info/METADATA +1809 -0
  157. tactus-0.31.2.dist-info/RECORD +160 -0
  158. tactus-0.31.2.dist-info/WHEEL +4 -0
  159. tactus-0.31.2.dist-info/entry_points.txt +2 -0
  160. tactus-0.31.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,790 @@
1
+ """
2
+ Configuration Manager for Tactus.
3
+
4
+ Implements cascading configuration from multiple sources with clear priority ordering.
5
+ """
6
+
7
+ import logging
8
+ import os
9
+ import yaml
10
+ from pathlib import Path
11
+ from typing import Dict, Any, Optional, List, Tuple
12
+ from copy import deepcopy
13
+ from dataclasses import dataclass, field
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass
19
+ class ConfigValue:
20
+ """
21
+ Represents a configuration value with source tracking metadata.
22
+
23
+ This class wraps config values with information about where they came from
24
+ and how they were overridden through the cascade system.
25
+ """
26
+
27
+ value: Any
28
+ """The actual configuration value"""
29
+
30
+ source: str
31
+ """Source identifier (e.g., 'user:/path/to/config.yml', 'environment:OPENAI_API_KEY')"""
32
+
33
+ source_type: str
34
+ """Normalized source type: 'system', 'user', 'project', 'parent', 'local', 'sidecar', 'environment'"""
35
+
36
+ path: str
37
+ """Config path (e.g., 'aws.region', 'ide.theme')"""
38
+
39
+ overridden_by: Optional[str] = None
40
+ """If overridden, what source did the override? None if this is the final value."""
41
+
42
+ override_chain: List[Tuple[str, Any]] = field(default_factory=list)
43
+ """History of overrides: [(source, value), ...] in chronological order"""
44
+
45
+ is_env_override: bool = False
46
+ """True if currently overridden by an environment variable"""
47
+
48
+ original_env_var: Optional[str] = None
49
+ """Original environment variable name if value came from env (e.g., 'OPENAI_API_KEY')"""
50
+
51
+ def to_dict(self) -> Dict[str, Any]:
52
+ """Convert to dictionary for JSON serialization."""
53
+ return {
54
+ "value": self.value,
55
+ "source": self.source,
56
+ "source_type": self.source_type,
57
+ "path": self.path,
58
+ "overridden_by": self.overridden_by,
59
+ "override_chain": self.override_chain,
60
+ "is_env_override": self.is_env_override,
61
+ "original_env_var": self.original_env_var,
62
+ }
63
+
64
+
65
+ class ConfigManager:
66
+ """
67
+ Manages configuration loading and merging from multiple sources.
68
+
69
+ Priority order (highest to lowest):
70
+ 1. CLI arguments (handled by caller)
71
+ 2. Sidecar config (procedure.tac.yml)
72
+ 3. Local directory config (.tactus/config.yml in procedure's directory)
73
+ 4. Parent directory configs (walk up tree)
74
+ 5. Project config (.tactus/config.yml in cwd)
75
+ 6. User config (~/.tactus/config.yml, or XDG config dir)
76
+ 7. System config (/etc/tactus/config.yml, etc.)
77
+ 8. Environment variables (fallback)
78
+ """
79
+
80
+ def __init__(self):
81
+ """Initialize configuration manager."""
82
+ self.loaded_configs = [] # Track loaded configs for debugging
83
+ self.env_var_mapping = {} # Track which env var each config key came from
84
+
85
+ def load_cascade(self, procedure_path: Path) -> Dict[str, Any]:
86
+ """
87
+ Load and merge all configuration sources in priority order.
88
+
89
+ Priority order (lowest to highest):
90
+ System → User → Project → Parent → Local → Environment → Sidecar → CLI
91
+
92
+ Args:
93
+ procedure_path: Path to the .tac procedure file
94
+
95
+ Returns:
96
+ Merged configuration dictionary
97
+ """
98
+ configs = []
99
+
100
+ # 1. System config (lowest precedence)
101
+ for system_path in self._get_system_config_paths():
102
+ if system_path.exists():
103
+ system_config = self._load_yaml_file(system_path)
104
+ if system_config:
105
+ configs.append((f"system:{system_path}", system_config))
106
+ logger.debug(f"Loaded system config: {system_path}")
107
+
108
+ # 2. User config (~/.tactus/config.yml, XDG, etc.)
109
+ for user_path in self._get_user_config_paths():
110
+ if user_path.exists():
111
+ user_config = self._load_yaml_file(user_path)
112
+ if user_config:
113
+ configs.append((f"user:{user_path}", user_config))
114
+ logger.debug(f"Loaded user config: {user_path}")
115
+
116
+ # 3. Project config (.tactus/config.yml in cwd)
117
+ root_config_path = Path.cwd() / ".tactus" / "config.yml"
118
+ if root_config_path.exists():
119
+ root_config = self._load_yaml_file(root_config_path)
120
+ if root_config:
121
+ configs.append(("root", root_config))
122
+ logger.debug(f"Loaded root config: {root_config_path}")
123
+
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}")
132
+
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:
136
+ local_config = self._load_yaml_file(local_config_path)
137
+ if local_config:
138
+ configs.append(("local", local_config))
139
+ logger.debug(f"Loaded local config: {local_config_path}")
140
+
141
+ # 6. Environment variables (override config files)
142
+ env_config = self._load_from_environment()
143
+ if env_config:
144
+ configs.append(("environment", env_config))
145
+ logger.debug("Loaded config from environment variables")
146
+
147
+ # 7. Sidecar config (highest priority, except CLI args)
148
+ sidecar_path = self._find_sidecar_config(procedure_path)
149
+ if sidecar_path:
150
+ sidecar_config = self._load_yaml_file(sidecar_path)
151
+ if sidecar_config:
152
+ configs.append(("sidecar", sidecar_config))
153
+ logger.debug(f"Loaded sidecar config: {sidecar_path}")
154
+
155
+ # Store for debugging
156
+ self.loaded_configs = configs
157
+
158
+ # Merge all configs (later configs override earlier ones)
159
+ merged = self._merge_configs([c[1] for c in configs])
160
+
161
+ logger.debug(f"Merged configuration from {len(configs)} source(s)")
162
+ return merged
163
+
164
+ def _find_sidecar_config(self, tac_path: Path) -> Optional[Path]:
165
+ """
166
+ Find sidecar configuration file for a .tac procedure.
167
+
168
+ Search order:
169
+ 1. {procedure}.tac.yml (exact match with .tac extension)
170
+ 2. {procedure}.yml (without .tac)
171
+
172
+ Args:
173
+ tac_path: Path to the .tac file
174
+
175
+ Returns:
176
+ Path to sidecar config if found, None otherwise
177
+ """
178
+ # Try .tac.yml first (preferred)
179
+ sidecar_with_tac = tac_path.parent / f"{tac_path.name}.yml"
180
+ if sidecar_with_tac.exists():
181
+ return sidecar_with_tac
182
+
183
+ # Try .yml (replace .tac extension)
184
+ if tac_path.suffix == ".tac":
185
+ sidecar_without_tac = tac_path.with_suffix(".yml")
186
+ if sidecar_without_tac.exists():
187
+ return sidecar_without_tac
188
+
189
+ return None
190
+
191
+ def _find_directory_configs(self, start_path: Path) -> List[Path]:
192
+ """
193
+ Walk up directory tree to find all .tactus/config.yml files.
194
+
195
+ Args:
196
+ start_path: Starting directory path
197
+
198
+ Returns:
199
+ List of config file paths (from root to start_path)
200
+ """
201
+ configs = []
202
+ current = start_path.resolve()
203
+ cwd = Path.cwd().resolve()
204
+
205
+ # Walk up until we reach cwd or root
206
+ while current != current.parent:
207
+ # Skip if we've reached cwd (handled separately as root config)
208
+ if current == cwd:
209
+ break
210
+
211
+ config_path = current / ".tactus" / "config.yml"
212
+ if config_path.exists():
213
+ configs.append(config_path)
214
+
215
+ current = current.parent
216
+
217
+ # Return in order from root to start_path (so later ones override)
218
+ return list(reversed(configs))
219
+
220
+ def _load_yaml_file(self, path: Path) -> Optional[Dict[str, Any]]:
221
+ """
222
+ Load YAML configuration file.
223
+
224
+ Args:
225
+ path: Path to YAML file
226
+
227
+ Returns:
228
+ Configuration dictionary or None if loading fails
229
+ """
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}")
236
+ return None
237
+
238
+ def _load_from_environment(self) -> Dict[str, Any]:
239
+ """
240
+ Load configuration from environment variables.
241
+
242
+ Also populates self.env_var_mapping to track which env var each config key came from.
243
+
244
+ Returns:
245
+ Configuration dictionary from environment
246
+ """
247
+ config = {}
248
+
249
+ # Load known config keys from environment
250
+ # NOTE: Keys must match the config file structure (nested under provider name)
251
+ env_mappings = {
252
+ "OPENAI_API_KEY": ("openai", "api_key"),
253
+ "GOOGLE_API_KEY": ("google", "api_key"),
254
+ "AWS_ACCESS_KEY_ID": ("aws", "access_key_id"),
255
+ "AWS_SECRET_ACCESS_KEY": ("aws", "secret_access_key"),
256
+ "AWS_DEFAULT_REGION": ("aws", "default_region"),
257
+ "AWS_PROFILE": ("aws", "profile"),
258
+ "TOOL_PATHS": "tool_paths",
259
+ # Sandbox configuration
260
+ "TACTUS_SANDBOX_ENABLED": ("sandbox", "enabled"),
261
+ "TACTUS_SANDBOX_IMAGE": ("sandbox", "image"),
262
+ }
263
+
264
+ # Boolean env vars that need special parsing
265
+ boolean_env_keys = {"TACTUS_SANDBOX_ENABLED"}
266
+
267
+ for env_key, config_key in env_mappings.items():
268
+ value = os.environ.get(env_key)
269
+ if value:
270
+ # Parse boolean values
271
+ if env_key in boolean_env_keys:
272
+ value = value.lower() in ("true", "1", "yes", "on")
273
+
274
+ if isinstance(config_key, tuple):
275
+ # Nested key (e.g., aws.access_key_id, sandbox.enabled)
276
+ if config_key[0] not in config:
277
+ config[config_key[0]] = {}
278
+ config[config_key[0]][config_key[1]] = value
279
+ # Track env var name for this nested key
280
+ path = f"{config_key[0]}.{config_key[1]}"
281
+ self.env_var_mapping[path] = env_key
282
+ elif config_key == "tool_paths":
283
+ # Parse JSON list
284
+ import json
285
+
286
+ try:
287
+ config[config_key] = json.loads(value)
288
+ self.env_var_mapping[config_key] = env_key
289
+ except json.JSONDecodeError:
290
+ logger.warning(f"Failed to parse TOOL_PATHS as JSON: {value}")
291
+ else:
292
+ config[config_key] = value
293
+ self.env_var_mapping[config_key] = env_key
294
+
295
+ return config
296
+
297
+ def _get_system_config_paths(self) -> List[Path]:
298
+ """
299
+ Return system-wide config locations (lowest precedence).
300
+
301
+ These are optional; most users will rely on user-wide or project configs.
302
+ """
303
+ if os.name == "nt":
304
+ program_data = Path(os.environ.get("PROGRAMDATA", r"C:\ProgramData"))
305
+ return [program_data / "tactus" / "config.yml"]
306
+
307
+ return [
308
+ Path("/etc/tactus/config.yml"),
309
+ Path("/usr/local/etc/tactus/config.yml"),
310
+ ]
311
+
312
+ def _get_user_config_paths(self) -> List[Path]:
313
+ """
314
+ Return per-user config locations (lower precedence than project configs).
315
+
316
+ Order is from lower to higher precedence so later configs override earlier ones.
317
+ """
318
+ paths: List[Path] = []
319
+
320
+ xdg_home = os.environ.get("XDG_CONFIG_HOME")
321
+ if xdg_home:
322
+ paths.append(Path(xdg_home) / "tactus" / "config.yml")
323
+ else:
324
+ paths.append(Path.home() / ".config" / "tactus" / "config.yml")
325
+
326
+ # Legacy / explicit location (documented by this project)
327
+ paths.append(Path.home() / ".tactus" / "config.yml")
328
+
329
+ # Deduplicate while preserving order
330
+ seen = set()
331
+ unique: List[Path] = []
332
+ for p in paths:
333
+ if p not in seen:
334
+ unique.append(p)
335
+ seen.add(p)
336
+ return unique
337
+
338
+ def _merge_configs(self, configs: List[Dict[str, Any]]) -> Dict[str, Any]:
339
+ """
340
+ Deep merge multiple configuration dictionaries.
341
+
342
+ Later configs override earlier ones.
343
+ Lists are extended (combined) by default.
344
+ Dicts are deep merged.
345
+
346
+ Args:
347
+ configs: List of config dicts to merge (in priority order)
348
+
349
+ Returns:
350
+ Merged configuration dictionary
351
+ """
352
+ if not configs:
353
+ return {}
354
+
355
+ result = {}
356
+
357
+ for config in configs:
358
+ result = self._deep_merge(result, config)
359
+
360
+ return result
361
+
362
+ def _deep_merge(self, base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
363
+ """
364
+ Deep merge two dictionaries.
365
+
366
+ Args:
367
+ base: Base dictionary
368
+ override: Override dictionary (takes precedence)
369
+
370
+ Returns:
371
+ Merged dictionary
372
+ """
373
+ result = deepcopy(base)
374
+
375
+ for key, value in override.items():
376
+ if key in result:
377
+ base_value = result[key]
378
+
379
+ # If both are dicts, deep merge
380
+ if isinstance(base_value, dict) and isinstance(value, dict):
381
+ result[key] = self._deep_merge(base_value, value)
382
+
383
+ # If both are lists, extend (combine)
384
+ elif isinstance(base_value, list) and isinstance(value, list):
385
+ # Combine lists, removing duplicates while preserving order
386
+ combined = base_value.copy()
387
+ for item in value:
388
+ if item not in combined:
389
+ combined.append(item)
390
+ result[key] = combined
391
+
392
+ # Otherwise, override takes precedence
393
+ else:
394
+ result[key] = deepcopy(value)
395
+ else:
396
+ result[key] = deepcopy(value)
397
+
398
+ return result
399
+
400
+ def _deep_merge_with_tracking(
401
+ self,
402
+ base: Dict[str, Any],
403
+ override: Dict[str, Any],
404
+ base_source: str,
405
+ override_source: str,
406
+ path_prefix: str = "",
407
+ base_source_map: Optional[Dict[str, ConfigValue]] = None,
408
+ ) -> Tuple[Dict[str, Any], Dict[str, ConfigValue]]:
409
+ """
410
+ Deep merge with source tracking at every level.
411
+
412
+ This method performs the same merge logic as _deep_merge() but also
413
+ tracks where each value came from and builds a complete override chain.
414
+
415
+ Args:
416
+ base: Base dictionary
417
+ override: Override dictionary (takes precedence)
418
+ base_source: Source identifier for base config
419
+ override_source: Source identifier for override config
420
+ path_prefix: Current path prefix for nested keys
421
+ base_source_map: Existing source map from base (for nested merges)
422
+
423
+ Returns:
424
+ Tuple of (merged_dict, source_map)
425
+ - merged_dict: The merged configuration
426
+ - source_map: Dict mapping paths to ConfigValue objects
427
+ """
428
+ result = deepcopy(base)
429
+ source_map: Dict[str, ConfigValue] = base_source_map.copy() if base_source_map else {}
430
+
431
+ # Normalize source types
432
+ base_source_type = base_source.split(":")[0] if ":" in base_source else base_source
433
+ override_source_type = (
434
+ override_source.split(":")[0] if ":" in override_source else override_source
435
+ )
436
+
437
+ for key, value in override.items():
438
+ current_path = f"{path_prefix}.{key}" if path_prefix else key
439
+
440
+ if key in result:
441
+ base_value = result[key]
442
+
443
+ # If both are dicts, deep merge recursively with tracking
444
+ if isinstance(base_value, dict) and isinstance(value, dict):
445
+ # Get nested source map for base
446
+ nested_base_source_map = {
447
+ k: v for k, v in source_map.items() if k.startswith(current_path + ".")
448
+ }
449
+
450
+ # Ensure all base dict values are tracked before merge
451
+ # This handles cases where base dict has values that aren't yet tracked
452
+ # Use overwrite=False to preserve existing source info from earlier merges
453
+ self._track_nested_values(
454
+ base_value,
455
+ base_source,
456
+ base_source_type,
457
+ current_path,
458
+ nested_base_source_map,
459
+ overwrite=False,
460
+ )
461
+
462
+ merged_dict, nested_source_map = self._deep_merge_with_tracking(
463
+ base_value,
464
+ value,
465
+ base_source,
466
+ override_source,
467
+ current_path,
468
+ nested_base_source_map,
469
+ )
470
+ result[key] = merged_dict
471
+
472
+ # Update source map with nested results
473
+ source_map.update(nested_source_map)
474
+
475
+ # Track the dict itself
476
+ if current_path in source_map:
477
+ # Build override chain
478
+ override_chain = source_map[current_path].override_chain.copy()
479
+ override_chain.append((override_source, value))
480
+ else:
481
+ override_chain = [(base_source, base_value), (override_source, value)]
482
+
483
+ # Get env var name if from environment
484
+ env_var_name = None
485
+ if override_source_type == "environment":
486
+ env_var_name = self.env_var_mapping.get(current_path)
487
+
488
+ source_map[current_path] = ConfigValue(
489
+ value=merged_dict,
490
+ source=override_source,
491
+ source_type=override_source_type,
492
+ path=current_path,
493
+ overridden_by=None, # Final value
494
+ override_chain=override_chain,
495
+ is_env_override=(override_source_type == "environment"),
496
+ original_env_var=env_var_name,
497
+ )
498
+
499
+ # If both are lists, extend (combine)
500
+ elif isinstance(base_value, list) and isinstance(value, list):
501
+ # Combine lists, removing duplicates while preserving order
502
+ combined = base_value.copy()
503
+ for item in value:
504
+ if item not in combined:
505
+ combined.append(item)
506
+ result[key] = combined
507
+
508
+ # Track list override
509
+ if current_path in source_map:
510
+ override_chain = source_map[current_path].override_chain.copy()
511
+ override_chain.append((override_source, value))
512
+ else:
513
+ override_chain = [(base_source, base_value), (override_source, value)]
514
+
515
+ # Get env var name if from environment
516
+ env_var_name = None
517
+ if override_source_type == "environment":
518
+ env_var_name = self.env_var_mapping.get(current_path)
519
+
520
+ source_map[current_path] = ConfigValue(
521
+ value=combined,
522
+ source=override_source,
523
+ source_type=override_source_type,
524
+ path=current_path,
525
+ overridden_by=None,
526
+ override_chain=override_chain,
527
+ is_env_override=(override_source_type == "environment"),
528
+ original_env_var=env_var_name,
529
+ )
530
+
531
+ # Otherwise, override takes precedence
532
+ else:
533
+ result[key] = deepcopy(value)
534
+
535
+ # Track simple value override
536
+ if current_path in source_map:
537
+ override_chain = source_map[current_path].override_chain.copy()
538
+ override_chain.append((override_source, value))
539
+ else:
540
+ override_chain = [(base_source, base_value), (override_source, value)]
541
+
542
+ # Get env var name if from environment
543
+ env_var_name = None
544
+ if override_source_type == "environment":
545
+ env_var_name = self.env_var_mapping.get(current_path)
546
+
547
+ source_map[current_path] = ConfigValue(
548
+ value=value,
549
+ source=override_source,
550
+ source_type=override_source_type,
551
+ path=current_path,
552
+ overridden_by=None,
553
+ override_chain=override_chain,
554
+ is_env_override=(override_source_type == "environment"),
555
+ original_env_var=env_var_name,
556
+ )
557
+ else:
558
+ # New key, not an override
559
+ result[key] = deepcopy(value)
560
+
561
+ # Get env var name if from environment
562
+ env_var_name = None
563
+ if override_source_type == "environment":
564
+ env_var_name = self.env_var_mapping.get(current_path)
565
+
566
+ # Track as new value
567
+ source_map[current_path] = ConfigValue(
568
+ value=value,
569
+ source=override_source,
570
+ source_type=override_source_type,
571
+ path=current_path,
572
+ overridden_by=None,
573
+ override_chain=[(override_source, value)],
574
+ is_env_override=(override_source_type == "environment"),
575
+ original_env_var=env_var_name,
576
+ )
577
+
578
+ # For nested dicts/lists in new keys, track their children
579
+ if isinstance(value, dict):
580
+ self._track_nested_values(
581
+ value, override_source, override_source_type, current_path, source_map
582
+ )
583
+
584
+ return result, source_map
585
+
586
+ def _track_nested_values(
587
+ self,
588
+ obj: Any,
589
+ source: str,
590
+ source_type: str,
591
+ path_prefix: str,
592
+ source_map: Dict[str, ConfigValue],
593
+ overwrite: bool = True,
594
+ ) -> None:
595
+ """
596
+ Recursively track nested values in dicts and lists.
597
+
598
+ This is used to populate source_map for values that don't have overrides.
599
+
600
+ Args:
601
+ obj: The object to track values from
602
+ source: Source identifier (e.g., "user:/path" or "environment")
603
+ source_type: Normalized type (e.g., "user", "environment")
604
+ path_prefix: Current path prefix for nested keys
605
+ source_map: Dict to populate with ConfigValue entries
606
+ overwrite: If False, won't overwrite existing source_map entries
607
+ """
608
+ if isinstance(obj, dict):
609
+ for key, value in obj.items():
610
+ current_path = f"{path_prefix}.{key}" if path_prefix else key
611
+
612
+ # Skip if we shouldn't overwrite and already have source info
613
+ if not overwrite and current_path in source_map:
614
+ # Still recurse for nested structures
615
+ if isinstance(value, (dict, list)):
616
+ self._track_nested_values(
617
+ value, source, source_type, current_path, source_map, overwrite
618
+ )
619
+ continue
620
+
621
+ # For environment variables, look up the specific env var name
622
+ env_var_name = None
623
+ if source_type == "environment" and current_path in self.env_var_mapping:
624
+ env_var_name = self.env_var_mapping[current_path]
625
+ # Update source to include env var name
626
+ effective_source = f"environment:{env_var_name}"
627
+ else:
628
+ effective_source = source
629
+
630
+ source_map[current_path] = ConfigValue(
631
+ value=value,
632
+ source=effective_source,
633
+ source_type=source_type,
634
+ path=current_path,
635
+ overridden_by=None,
636
+ override_chain=[(effective_source, value)],
637
+ is_env_override=(source_type == "environment"),
638
+ original_env_var=env_var_name,
639
+ )
640
+ if isinstance(value, (dict, list)):
641
+ self._track_nested_values(
642
+ value, source, source_type, current_path, source_map, overwrite
643
+ )
644
+ elif isinstance(obj, list):
645
+ for i, item in enumerate(obj):
646
+ current_path = f"{path_prefix}[{i}]"
647
+ # Skip if we shouldn't overwrite and already have source info
648
+ if not overwrite and current_path in source_map:
649
+ # Still recurse for nested structures
650
+ if isinstance(item, (dict, list)):
651
+ self._track_nested_values(
652
+ item, source, source_type, current_path, source_map, overwrite
653
+ )
654
+ continue
655
+
656
+ source_map[current_path] = ConfigValue(
657
+ value=item,
658
+ source=source,
659
+ source_type=source_type,
660
+ path=current_path,
661
+ overridden_by=None,
662
+ override_chain=[(source, item)],
663
+ is_env_override=(source_type == "environment"),
664
+ original_env_var=None, # List items don't have individual env vars
665
+ )
666
+ if isinstance(item, (dict, list)):
667
+ self._track_nested_values(
668
+ item, source, source_type, current_path, source_map, overwrite
669
+ )
670
+
671
+ def _extract_env_var_name(self, source: str) -> Optional[str]:
672
+ """
673
+ Extract environment variable name from source string.
674
+
675
+ Args:
676
+ source: Source identifier like "environment:OPENAI_API_KEY"
677
+
678
+ Returns:
679
+ Environment variable name or None if not an env source
680
+ """
681
+ if source.startswith("environment:"):
682
+ return source.split(":", 1)[1]
683
+ return None
684
+
685
+ def load_cascade_with_sources(
686
+ self, procedure_path: Path
687
+ ) -> Tuple[Dict[str, Any], Dict[str, ConfigValue]]:
688
+ """
689
+ Load cascade and return both merged config and detailed source map.
690
+
691
+ This is the enhanced version of load_cascade() that provides complete
692
+ transparency into where each configuration value came from.
693
+
694
+ Priority order (lowest to highest):
695
+ System → User → Project → Parent → Local → Environment → Sidecar → CLI
696
+
697
+ Args:
698
+ procedure_path: Path to the .tac procedure file
699
+
700
+ Returns:
701
+ Tuple of (merged_config, source_map)
702
+ - merged_config: Traditional flat merged config (backward compatible)
703
+ - source_map: Dict mapping paths to ConfigValue objects with full metadata
704
+ """
705
+ configs = []
706
+
707
+ # 1. System config (lowest precedence)
708
+ for system_path in self._get_system_config_paths():
709
+ if system_path.exists():
710
+ system_config = self._load_yaml_file(system_path)
711
+ if system_config:
712
+ configs.append((f"system:{system_path}", system_config))
713
+ logger.debug(f"Loaded system config: {system_path}")
714
+
715
+ # 2. User config (~/.tactus/config.yml, XDG, etc.)
716
+ for user_path in self._get_user_config_paths():
717
+ if user_path.exists():
718
+ user_config = self._load_yaml_file(user_path)
719
+ if user_config:
720
+ configs.append((f"user:{user_path}", user_config))
721
+ logger.debug(f"Loaded user config: {user_path}")
722
+
723
+ # 3. Project config (.tactus/config.yml in cwd)
724
+ root_config_path = Path.cwd() / ".tactus" / "config.yml"
725
+ if root_config_path.exists():
726
+ root_config = self._load_yaml_file(root_config_path)
727
+ if root_config:
728
+ configs.append((f"project:{root_config_path}", root_config))
729
+ logger.debug(f"Loaded root config: {root_config_path}")
730
+
731
+ # 4. Parent directory configs (walk up from procedure directory)
732
+ procedure_dir = procedure_path.parent.resolve()
733
+ parent_configs = self._find_directory_configs(procedure_dir)
734
+ for config_path in parent_configs:
735
+ config = self._load_yaml_file(config_path)
736
+ if config:
737
+ configs.append((f"parent:{config_path}", config))
738
+ logger.debug(f"Loaded parent config: {config_path}")
739
+
740
+ # 5. Local directory config (.tactus/config.yml in procedure's directory)
741
+ local_config_path = procedure_dir / ".tactus" / "config.yml"
742
+ if (
743
+ local_config_path.exists()
744
+ and local_config_path not in [root_config_path] + parent_configs
745
+ ):
746
+ local_config = self._load_yaml_file(local_config_path)
747
+ if local_config:
748
+ configs.append((f"local:{local_config_path}", local_config))
749
+ logger.debug(f"Loaded local config: {local_config_path}")
750
+
751
+ # 6. Environment variables (override config files)
752
+ env_config = self._load_from_environment()
753
+ if env_config:
754
+ # We use "environment" as source, but individual var names are in self.env_var_mapping
755
+ configs.append(("environment", env_config))
756
+ logger.debug(
757
+ f"Loaded config from environment variables: {list(self.env_var_mapping.keys())}"
758
+ )
759
+
760
+ # 7. Sidecar config (highest priority, except CLI args)
761
+ sidecar_path = self._find_sidecar_config(procedure_path)
762
+ if sidecar_path:
763
+ sidecar_config = self._load_yaml_file(sidecar_path)
764
+ if sidecar_config:
765
+ configs.append((f"sidecar:{sidecar_path}", sidecar_config))
766
+ logger.info(f"Loaded sidecar config: {sidecar_path}")
767
+
768
+ # Store for debugging
769
+ self.loaded_configs = configs
770
+
771
+ # Merge all configs with source tracking
772
+ if not configs:
773
+ return {}, {}
774
+
775
+ # Start with first config
776
+ source, config = configs[0]
777
+ result = deepcopy(config)
778
+ source_map: Dict[str, ConfigValue] = {}
779
+
780
+ # Track initial values
781
+ self._track_nested_values(config, source, source.split(":")[0], "", source_map)
782
+
783
+ # Merge remaining configs with tracking
784
+ for source, config in configs[1:]:
785
+ result, source_map = self._deep_merge_with_tracking(
786
+ result, config, "merged", source, "", source_map
787
+ )
788
+
789
+ logger.info(f"Merged configuration from {len(configs)} source(s) with full tracking")
790
+ return result, source_map