tactus 0.24.0__py3-none-any.whl → 0.25.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.
- tactus/__init__.py +1 -1
- tactus/cli/app.py +1 -0
- tactus/core/config_manager.py +467 -14
- tactus/dspy/agent.py +3 -1
- tactus/ide/server.py +35 -0
- {tactus-0.24.0.dist-info → tactus-0.25.0.dist-info}/METADATA +1 -1
- {tactus-0.24.0.dist-info → tactus-0.25.0.dist-info}/RECORD +10 -10
- {tactus-0.24.0.dist-info → tactus-0.25.0.dist-info}/WHEEL +0 -0
- {tactus-0.24.0.dist-info → tactus-0.25.0.dist-info}/entry_points.txt +0 -0
- {tactus-0.24.0.dist-info → tactus-0.25.0.dist-info}/licenses/LICENSE +0 -0
tactus/__init__.py
CHANGED
tactus/cli/app.py
CHANGED
|
@@ -1179,6 +1179,7 @@ def test(
|
|
|
1179
1179
|
("aws", "access_key_id"): "AWS_ACCESS_KEY_ID",
|
|
1180
1180
|
("aws", "secret_access_key"): "AWS_SECRET_ACCESS_KEY",
|
|
1181
1181
|
("aws", "default_region"): "AWS_DEFAULT_REGION",
|
|
1182
|
+
("aws", "profile"): "AWS_PROFILE",
|
|
1182
1183
|
}
|
|
1183
1184
|
|
|
1184
1185
|
for config_key, env_key in env_mappings.items():
|
tactus/core/config_manager.py
CHANGED
|
@@ -8,12 +8,60 @@ import logging
|
|
|
8
8
|
import os
|
|
9
9
|
import yaml
|
|
10
10
|
from pathlib import Path
|
|
11
|
-
from typing import Dict, Any, Optional, List
|
|
11
|
+
from typing import Dict, Any, Optional, List, Tuple
|
|
12
12
|
from copy import deepcopy
|
|
13
|
+
from dataclasses import dataclass, field
|
|
13
14
|
|
|
14
15
|
logger = logging.getLogger(__name__)
|
|
15
16
|
|
|
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
|
+
|
|
17
65
|
class ConfigManager:
|
|
18
66
|
"""
|
|
19
67
|
Manages configuration loading and merging from multiple sources.
|
|
@@ -32,11 +80,15 @@ class ConfigManager:
|
|
|
32
80
|
def __init__(self):
|
|
33
81
|
"""Initialize configuration manager."""
|
|
34
82
|
self.loaded_configs = [] # Track loaded configs for debugging
|
|
83
|
+
self.env_var_mapping = {} # Track which env var each config key came from
|
|
35
84
|
|
|
36
85
|
def load_cascade(self, procedure_path: Path) -> Dict[str, Any]:
|
|
37
86
|
"""
|
|
38
87
|
Load and merge all configuration sources in priority order.
|
|
39
88
|
|
|
89
|
+
Priority order (lowest to highest):
|
|
90
|
+
System → User → Project → Parent → Local → Environment → Sidecar → CLI
|
|
91
|
+
|
|
40
92
|
Args:
|
|
41
93
|
procedure_path: Path to the .tac procedure file
|
|
42
94
|
|
|
@@ -45,13 +97,7 @@ class ConfigManager:
|
|
|
45
97
|
"""
|
|
46
98
|
configs = []
|
|
47
99
|
|
|
48
|
-
# 1.
|
|
49
|
-
env_config = self._load_from_environment()
|
|
50
|
-
if env_config:
|
|
51
|
-
configs.append(("environment", env_config))
|
|
52
|
-
logger.debug("Loaded config from environment variables")
|
|
53
|
-
|
|
54
|
-
# 2. System config (lowest precedence among config files)
|
|
100
|
+
# 1. System config (lowest precedence)
|
|
55
101
|
for system_path in self._get_system_config_paths():
|
|
56
102
|
if system_path.exists():
|
|
57
103
|
system_config = self._load_yaml_file(system_path)
|
|
@@ -59,7 +105,7 @@ class ConfigManager:
|
|
|
59
105
|
configs.append((f"system:{system_path}", system_config))
|
|
60
106
|
logger.debug(f"Loaded system config: {system_path}")
|
|
61
107
|
|
|
62
|
-
#
|
|
108
|
+
# 2. User config (~/.tactus/config.yml, XDG, etc.)
|
|
63
109
|
for user_path in self._get_user_config_paths():
|
|
64
110
|
if user_path.exists():
|
|
65
111
|
user_config = self._load_yaml_file(user_path)
|
|
@@ -67,7 +113,7 @@ class ConfigManager:
|
|
|
67
113
|
configs.append((f"user:{user_path}", user_config))
|
|
68
114
|
logger.debug(f"Loaded user config: {user_path}")
|
|
69
115
|
|
|
70
|
-
#
|
|
116
|
+
# 3. Project config (.tactus/config.yml in cwd)
|
|
71
117
|
root_config_path = Path.cwd() / ".tactus" / "config.yml"
|
|
72
118
|
if root_config_path.exists():
|
|
73
119
|
root_config = self._load_yaml_file(root_config_path)
|
|
@@ -75,7 +121,7 @@ class ConfigManager:
|
|
|
75
121
|
configs.append(("root", root_config))
|
|
76
122
|
logger.debug(f"Loaded root config: {root_config_path}")
|
|
77
123
|
|
|
78
|
-
#
|
|
124
|
+
# 4. Parent directory configs (walk up from procedure directory)
|
|
79
125
|
procedure_dir = procedure_path.parent.resolve()
|
|
80
126
|
parent_configs = self._find_directory_configs(procedure_dir)
|
|
81
127
|
for config_path in parent_configs:
|
|
@@ -84,7 +130,7 @@ class ConfigManager:
|
|
|
84
130
|
configs.append((f"parent:{config_path}", config))
|
|
85
131
|
logger.debug(f"Loaded parent config: {config_path}")
|
|
86
132
|
|
|
87
|
-
#
|
|
133
|
+
# 5. Local directory config (.tactus/config.yml in procedure's directory)
|
|
88
134
|
local_config_path = procedure_dir / ".tactus" / "config.yml"
|
|
89
135
|
if local_config_path.exists() and local_config_path not in parent_configs:
|
|
90
136
|
local_config = self._load_yaml_file(local_config_path)
|
|
@@ -92,6 +138,12 @@ class ConfigManager:
|
|
|
92
138
|
configs.append(("local", local_config))
|
|
93
139
|
logger.debug(f"Loaded local config: {local_config_path}")
|
|
94
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
|
+
|
|
95
147
|
# 7. Sidecar config (highest priority, except CLI args)
|
|
96
148
|
sidecar_path = self._find_sidecar_config(procedure_path)
|
|
97
149
|
if sidecar_path:
|
|
@@ -187,18 +239,22 @@ class ConfigManager:
|
|
|
187
239
|
"""
|
|
188
240
|
Load configuration from environment variables.
|
|
189
241
|
|
|
242
|
+
Also populates self.env_var_mapping to track which env var each config key came from.
|
|
243
|
+
|
|
190
244
|
Returns:
|
|
191
245
|
Configuration dictionary from environment
|
|
192
246
|
"""
|
|
193
247
|
config = {}
|
|
194
248
|
|
|
195
249
|
# Load known config keys from environment
|
|
250
|
+
# NOTE: Keys must match the config file structure (nested under provider name)
|
|
196
251
|
env_mappings = {
|
|
197
|
-
"OPENAI_API_KEY": "
|
|
198
|
-
"GOOGLE_API_KEY": "
|
|
252
|
+
"OPENAI_API_KEY": ("openai", "api_key"),
|
|
253
|
+
"GOOGLE_API_KEY": ("google", "api_key"),
|
|
199
254
|
"AWS_ACCESS_KEY_ID": ("aws", "access_key_id"),
|
|
200
255
|
"AWS_SECRET_ACCESS_KEY": ("aws", "secret_access_key"),
|
|
201
256
|
"AWS_DEFAULT_REGION": ("aws", "default_region"),
|
|
257
|
+
"AWS_PROFILE": ("aws", "profile"),
|
|
202
258
|
"TOOL_PATHS": "tool_paths",
|
|
203
259
|
# Sandbox configuration
|
|
204
260
|
"TACTUS_SANDBOX_ENABLED": ("sandbox", "enabled"),
|
|
@@ -220,16 +276,21 @@ class ConfigManager:
|
|
|
220
276
|
if config_key[0] not in config:
|
|
221
277
|
config[config_key[0]] = {}
|
|
222
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
|
|
223
282
|
elif config_key == "tool_paths":
|
|
224
283
|
# Parse JSON list
|
|
225
284
|
import json
|
|
226
285
|
|
|
227
286
|
try:
|
|
228
287
|
config[config_key] = json.loads(value)
|
|
288
|
+
self.env_var_mapping[config_key] = env_key
|
|
229
289
|
except json.JSONDecodeError:
|
|
230
290
|
logger.warning(f"Failed to parse TOOL_PATHS as JSON: {value}")
|
|
231
291
|
else:
|
|
232
292
|
config[config_key] = value
|
|
293
|
+
self.env_var_mapping[config_key] = env_key
|
|
233
294
|
|
|
234
295
|
return config
|
|
235
296
|
|
|
@@ -335,3 +396,395 @@ class ConfigManager:
|
|
|
335
396
|
result[key] = deepcopy(value)
|
|
336
397
|
|
|
337
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
|
tactus/dspy/agent.py
CHANGED
|
@@ -724,7 +724,9 @@ class DSPyAgentHandle:
|
|
|
724
724
|
|
|
725
725
|
if get_current_lm() is None and self.model:
|
|
726
726
|
# Convert model format from "provider:model" to "provider/model" for LiteLLM
|
|
727
|
-
|
|
727
|
+
# Only replace the FIRST colon (provider separator), not all colons
|
|
728
|
+
# Bedrock model IDs like "us.anthropic.claude-haiku-4-5-20251001-v1:0" have a version suffix
|
|
729
|
+
model_for_litellm = self.model.replace(":", "/", 1) if ":" in self.model else self.model
|
|
728
730
|
logger.info(f"Auto-configuring DSPy LM with model: {model_for_litellm}")
|
|
729
731
|
|
|
730
732
|
# Build kwargs for configure_lm
|
tactus/ide/server.py
CHANGED
|
@@ -197,6 +197,24 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
|
|
|
197
197
|
return jsonify({"cwd": WORKSPACE_ROOT})
|
|
198
198
|
return jsonify({"cwd": str(Path.cwd())})
|
|
199
199
|
|
|
200
|
+
@app.route("/api/about", methods=["GET"])
|
|
201
|
+
def get_about_info():
|
|
202
|
+
"""Get application version and metadata."""
|
|
203
|
+
from tactus import __version__
|
|
204
|
+
|
|
205
|
+
return jsonify(
|
|
206
|
+
{
|
|
207
|
+
"version": __version__,
|
|
208
|
+
"name": "Tactus IDE",
|
|
209
|
+
"description": "A Lua-based DSL for agentic workflows",
|
|
210
|
+
"author": "Ryan Porter",
|
|
211
|
+
"license": "MIT",
|
|
212
|
+
"repository": "https://github.com/AnthusAI/Tactus",
|
|
213
|
+
"documentation": "https://github.com/AnthusAI/Tactus/tree/main/docs",
|
|
214
|
+
"issues": "https://github.com/AnthusAI/Tactus/issues",
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
|
|
200
218
|
@app.route("/api/workspace", methods=["GET", "POST"])
|
|
201
219
|
def workspace_operations():
|
|
202
220
|
"""Handle workspace operations."""
|
|
@@ -2241,6 +2259,23 @@ def create_app(initial_workspace: Optional[str] = None, frontend_dist_dir: Optio
|
|
|
2241
2259
|
logger.error(f"Error handling LSP notification: {e}")
|
|
2242
2260
|
return jsonify({"error": str(e)}), 500
|
|
2243
2261
|
|
|
2262
|
+
# Register config API routes
|
|
2263
|
+
try:
|
|
2264
|
+
import sys
|
|
2265
|
+
|
|
2266
|
+
# Add tactus-ide/backend to path for imports
|
|
2267
|
+
# Path from tactus/ide/server.py -> project root -> tactus-ide/backend
|
|
2268
|
+
backend_dir = Path(__file__).parent.parent.parent / "tactus-ide" / "backend"
|
|
2269
|
+
if backend_dir.exists():
|
|
2270
|
+
sys.path.insert(0, str(backend_dir))
|
|
2271
|
+
from config_server import register_config_routes
|
|
2272
|
+
|
|
2273
|
+
register_config_routes(app)
|
|
2274
|
+
else:
|
|
2275
|
+
logger.warning(f"Config server backend directory not found: {backend_dir}")
|
|
2276
|
+
except ImportError as e:
|
|
2277
|
+
logger.warning(f"Could not register config routes: {e}")
|
|
2278
|
+
|
|
2244
2279
|
# Serve frontend if dist directory is provided
|
|
2245
2280
|
if frontend_dist_dir:
|
|
2246
2281
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
tactus/__init__.py,sha256=
|
|
1
|
+
tactus/__init__.py,sha256=j3GKQg-FI6kEa8_EqgcxzyTLusQ6dIuqe0LHH9DOYgI,1245
|
|
2
2
|
tactus/adapters/__init__.py,sha256=lU8uUxuryFRIpVrn_KeVK7aUhsvOT1tYsuE3FOOIFpI,289
|
|
3
3
|
tactus/adapters/cli_hitl.py,sha256=l8jKU3y99g8z2vS11td0JXLVG77SF01nO-Ss4pRFXO0,6962
|
|
4
4
|
tactus/adapters/cli_log.py,sha256=JKD693goi_wT_Kei4mTc2KJ-0QfgFZTpV3Prb8zfNZo,9779
|
|
@@ -14,10 +14,10 @@ tactus/backends/http_backend.py,sha256=D2N91I5bnjhHMLG84-U-BRS-mIuwoQq72Feffi7At
|
|
|
14
14
|
tactus/backends/model_backend.py,sha256=P8dCUpDxJmA8_EO1snZuXyIyUZ_BlqReeC6zenO7Kv0,763
|
|
15
15
|
tactus/backends/pytorch_backend.py,sha256=I7H7UTa_Scx9_FtmPWn-G4noadaNVEQj-9Kjtjpgl6E,3305
|
|
16
16
|
tactus/cli/__init__.py,sha256=kVhdCkwWEPdt3vn9si-iKvh6M9817aOH6rLSsNzRuyg,80
|
|
17
|
-
tactus/cli/app.py,sha256=
|
|
17
|
+
tactus/cli/app.py,sha256=aBpZ4IUka-y1HV4oHyBvydnX8XI5JRo6WwMgLji2Df4,78595
|
|
18
18
|
tactus/cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
19
|
tactus/core/__init__.py,sha256=TK5rWr3HmOO_igFa5ESGp6teWwS58vnvQhIWqkcgqwk,880
|
|
20
|
-
tactus/core/config_manager.py,sha256=
|
|
20
|
+
tactus/core/config_manager.py,sha256=DXWQs__rD4fXo4KPJb24hyXGdUBiiEv1mV_h4yu0vKU,31575
|
|
21
21
|
tactus/core/dsl_stubs.py,sha256=naF27E1-zzDtuuYAKo1uThwQfLgaQOP35vBlGs6QsyY,66871
|
|
22
22
|
tactus/core/exceptions.py,sha256=HruvJQ4yIi78hOvvHviNZolcraMwXrlGQzxHqYFQapA,1468
|
|
23
23
|
tactus/core/execution_context.py,sha256=DJuy3sU2fS6n3Rp_GK7nIOFOLob23uTenZaIXXDm7Sk,16656
|
|
@@ -34,7 +34,7 @@ tactus/core/dependencies/registry.py,sha256=bgRdqJJTUrnQlu0wvjv2In1EPq7prZq-b9eB
|
|
|
34
34
|
tactus/docker/Dockerfile,sha256=Lo1erljpHSw9O-uOj0mn47gtg6abqzBvhbChAb-GQhI,1720
|
|
35
35
|
tactus/docker/entrypoint.sh,sha256=-qYq5RQIGS6KirJ6NO0XiHZf_oAdfmk5HzHOBsiqxRM,1870
|
|
36
36
|
tactus/dspy/__init__.py,sha256=beUkvMUFdPvZE9-bEOfRo2TH-FoCvPT_L9_dpJPW324,1226
|
|
37
|
-
tactus/dspy/agent.py,sha256=
|
|
37
|
+
tactus/dspy/agent.py,sha256=sIvi4ySumUXL9bPnMNLhFODOhYbEMlElnqHv57IaFrs,37859
|
|
38
38
|
tactus/dspy/config.py,sha256=oXwYzfVCTBHRnbH_vvm591FhE0zKep7wgmujLSXKf_c,6013
|
|
39
39
|
tactus/dspy/history.py,sha256=0yGi3P5ruRUPoRyaCWsUDeuEYYsfproc_7pMVZuhmUo,5980
|
|
40
40
|
tactus/dspy/module.py,sha256=sJdFS-5A4SpuiMLjbwiZJCvg3pTtEx8x8MRVaqjCQ2I,15423
|
|
@@ -42,7 +42,7 @@ tactus/dspy/prediction.py,sha256=AFtkmKQafOcA4Pzdty0dvZ62lmfS3wgIj4Bc3Awhb2c,722
|
|
|
42
42
|
tactus/dspy/signature.py,sha256=jdLHBa5BOEBwXTfmLui6fjViEDQDhdUzQm2__STHquU,6053
|
|
43
43
|
tactus/ide/__init__.py,sha256=1fSC0xWP-Lq5wl4FgDq7SMnkvZ0DxXupreTl3ZRX1zw,143
|
|
44
44
|
tactus/ide/coding_assistant.py,sha256=GgmspWIn9IPgBK0ZYapeISIOrcDfRyK7yyPDPV85r8g,12184
|
|
45
|
-
tactus/ide/server.py,sha256=
|
|
45
|
+
tactus/ide/server.py,sha256=KALEdXcoUHLjJPWFrbyMbPkQeoHKfhSu7J22HeXx7yA,98912
|
|
46
46
|
tactus/primitives/__init__.py,sha256=2NHEGBCuasVJlD5bHE6OSYivZ3WEp90DJDTvoKehVzg,1712
|
|
47
47
|
tactus/primitives/control.py,sha256=PjRt_Pegcj2L1Uy-IUBQKTYFRMXy7b9q1z2kzJNH8qw,4683
|
|
48
48
|
tactus/primitives/file.py,sha256=-kz0RCst_i_3V860-LtGntYpE0Mm371U_KGHqELbMx0,7186
|
|
@@ -142,8 +142,8 @@ tactus/validation/generated/LuaParserVisitor.py,sha256=ageKSmHPxnO3jBS2fBtkmYBOd
|
|
|
142
142
|
tactus/validation/generated/__init__.py,sha256=5gWlwRI0UvmHw2fnBpj_IG6N8oZeabr5tbj1AODDvjc,196
|
|
143
143
|
tactus/validation/grammar/LuaLexer.g4,sha256=t2MXiTCr127RWAyQGvamkcU_m4veqPzSuHUtAKwalw4,2771
|
|
144
144
|
tactus/validation/grammar/LuaParser.g4,sha256=ceZenb90BdiZmVdOxMGj9qJk3QbbWVZe5HUqPgoePfY,3202
|
|
145
|
-
tactus-0.
|
|
146
|
-
tactus-0.
|
|
147
|
-
tactus-0.
|
|
148
|
-
tactus-0.
|
|
149
|
-
tactus-0.
|
|
145
|
+
tactus-0.25.0.dist-info/METADATA,sha256=WGpOx64_mmNzszKF7PRCYrzCZqwUSkwn9HlC98xUj4E,55250
|
|
146
|
+
tactus-0.25.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
147
|
+
tactus-0.25.0.dist-info/entry_points.txt,sha256=vWseqty8m3z-Worje0IYxlioMjPDCoSsm0AtY4GghBY,47
|
|
148
|
+
tactus-0.25.0.dist-info/licenses/LICENSE,sha256=ivohBcAIYnaLPQ-lKEeCXSMvQUVISpQfKyxHBHoa4GA,1066
|
|
149
|
+
tactus-0.25.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|