tactus 0.23.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 CHANGED
@@ -5,7 +5,7 @@ Tactus provides a declarative workflow engine for AI agents with pluggable
5
5
  backends for storage, HITL, and chat recording.
6
6
  """
7
7
 
8
- __version__ = "0.23.0"
8
+ __version__ = "0.25.0"
9
9
 
10
10
  # Core exports
11
11
  from tactus.core.runtime import TactusRuntime
tactus/cli/app.py CHANGED
@@ -41,6 +41,30 @@ app = typer.Typer(
41
41
  )
42
42
 
43
43
 
44
+ @app.callback(invoke_without_command=True)
45
+ def main_callback(
46
+ ctx: typer.Context,
47
+ version: bool = typer.Option(
48
+ False,
49
+ "--version",
50
+ "-V",
51
+ help="Show version and exit",
52
+ is_eager=True,
53
+ ),
54
+ ):
55
+ """Tactus CLI callback for global options."""
56
+ if version:
57
+ from tactus import __version__
58
+
59
+ console.print(f"Tactus version: [bold]{__version__}[/bold]")
60
+ raise typer.Exit()
61
+
62
+ # If no subcommand was invoked and version flag not set, show help
63
+ if ctx.invoked_subcommand is None:
64
+ console.print(ctx.get_help())
65
+ raise typer.Exit()
66
+
67
+
44
68
  def load_tactus_config():
45
69
  """
46
70
  Load Tactus configuration from standard config locations.
@@ -1155,6 +1179,7 @@ def test(
1155
1179
  ("aws", "access_key_id"): "AWS_ACCESS_KEY_ID",
1156
1180
  ("aws", "secret_access_key"): "AWS_SECRET_ACCESS_KEY",
1157
1181
  ("aws", "default_region"): "AWS_DEFAULT_REGION",
1182
+ ("aws", "profile"): "AWS_PROFILE",
1158
1183
  }
1159
1184
 
1160
1185
  for config_key, env_key in env_mappings.items():
@@ -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. Environment variables (lowest priority)
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
- # 3. User config (~/.tactus/config.yml, XDG, etc.)
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
- # 4. Project config (.tactus/config.yml in cwd)
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
- # 5. Parent directory configs (walk up from procedure directory)
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
- # 6. Local directory config (.tactus/config.yml in procedure's directory)
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": "openai_api_key",
198
- "GOOGLE_API_KEY": "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
- model_for_litellm = self.model.replace(":", "/") if ":" in self.model else self.model
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tactus
3
- Version: 0.23.0
3
+ Version: 0.25.0
4
4
  Summary: Tactus: Lua-based DSL for agentic workflows
5
5
  Project-URL: Homepage, https://github.com/AnthusAI/Tactus
6
6
  Project-URL: Documentation, https://github.com/AnthusAI/Tactus/tree/main/docs
@@ -1,4 +1,4 @@
1
- tactus/__init__.py,sha256=bYU1q6ssy4qyaU4ywptrmHurU9Y5gaCei8LiigteA2I,1245
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=2BpV4LHoXg9d03NvgqAgPT9om-68XRL5yK3BzBI-b9A,77927
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=at6oA2vqNBMR4kVkdRbPL04x6_wRvki3pLfQAoU8KXU,11917
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=lWb71_EyM_VrgFE4XQ3LPW3Sl5rIir0ndAZHZoh23U4,37671
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=Wp2-lpbYp8u-xPDUjGVNX08Rvgx-9umwxrVlWfAce0c,97575
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.23.0.dist-info/METADATA,sha256=Kvj_FpO-1s2v2VwKCLr8OKvaKO51OO4SRGe5KWxhLUI,55250
146
- tactus-0.23.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
147
- tactus-0.23.0.dist-info/entry_points.txt,sha256=vWseqty8m3z-Worje0IYxlioMjPDCoSsm0AtY4GghBY,47
148
- tactus-0.23.0.dist-info/licenses/LICENSE,sha256=ivohBcAIYnaLPQ-lKEeCXSMvQUVISpQfKyxHBHoa4GA,1066
149
- tactus-0.23.0.dist-info/RECORD,,
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,,