tactus 0.38.0__py3-none-any.whl → 0.40.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.38.0"
8
+ __version__ = "0.40.0"
9
9
 
10
10
  # Core exports
11
11
  from tactus.core.runtime import TactusRuntime
tactus/cli/app.py CHANGED
@@ -505,6 +505,12 @@ def run(
505
505
  real: Optional[list[str]] = typer.Option(
506
506
  None, "--real", help="Use real implementation for specific tool(s)"
507
507
  ),
508
+ auto_deps: bool = typer.Option(
509
+ False, "--auto-deps", help="Automatically run dependency tasks without prompting"
510
+ ),
511
+ no_deps: bool = typer.Option(
512
+ False, "--no-deps", help="Fail fast if dependency tasks are required"
513
+ ),
508
514
  sandbox: Optional[bool] = typer.Option(
509
515
  None,
510
516
  "--sandbox/--no-sandbox",
@@ -568,6 +574,15 @@ def run(
568
574
  console.print(f"[red]Error:[/red] Workflow file not found: {workflow_file}")
569
575
  raise typer.Exit(1)
570
576
 
577
+ if not isinstance(auto_deps, bool):
578
+ auto_deps = False
579
+ if not isinstance(no_deps, bool):
580
+ no_deps = False
581
+
582
+ if auto_deps and no_deps:
583
+ console.print("[red]Error:[/red] --auto-deps and --no-deps cannot be combined")
584
+ raise typer.Exit(1)
585
+
571
586
  # Determine format based on extension
572
587
  file_format = "lua" if workflow_file.suffix in [".tac", ".lua"] else "yaml"
573
588
 
@@ -808,6 +823,15 @@ def run(
808
823
  tool_paths=tool_paths,
809
824
  source_file_path=str(workflow_file),
810
825
  )
826
+ runtime.dependency_mode = "auto" if auto_deps else "none" if no_deps else "prompt"
827
+
828
+ def _dependency_prompt_handler(plan, label: str) -> bool:
829
+ pending = [task.kind for task in plan.tasks if task.status != "complete"]
830
+ pending_summary = ", ".join(pending) if pending else "none"
831
+ console.print(f"[yellow]Dependencies required for {label}: {pending_summary}[/yellow]")
832
+ return typer.confirm("Run dependencies now?", default=False)
833
+
834
+ runtime.dependency_prompt_handler = _dependency_prompt_handler
811
835
 
812
836
  # Always create a mock manager so Mocks {} blocks can register tool mocks.
813
837
  from tactus.core.mocking import MockManager, set_current_mock_manager
tactus/core/dsl_stubs.py CHANGED
@@ -413,6 +413,17 @@ def create_dsl_stubs(
413
413
  # Task "name" { ... }
414
414
  if isinstance(name_or_config, str):
415
415
  task_name = name_or_config
416
+ if config is None:
417
+
418
+ def _curried(task_config=None):
419
+ if task_config is None or not hasattr(task_config, "items"):
420
+ raise TypeError(
421
+ f"Task '{task_name}' requires a configuration table. "
422
+ 'Use: Task "name" { ... } or name = Task { ... }.'
423
+ )
424
+ return _task(task_name, task_config)
425
+
426
+ return _curried
416
427
  task_config = config or {}
417
428
  if hasattr(task_config, "__setitem__"):
418
429
  try:
@@ -450,7 +461,13 @@ def create_dsl_stubs(
450
461
  if hasattr(task_config, "items"):
451
462
  child_tasks = {}
452
463
  for key, value in task_config.items():
453
- if isinstance(key, str) and hasattr(value, "items"):
464
+ if not (isinstance(key, str) and hasattr(value, "items")):
465
+ continue
466
+ try:
467
+ marker = value["__tactus_task_config"]
468
+ except Exception:
469
+ marker = False
470
+ if marker:
454
471
  child_tasks[key] = value
455
472
  if child_tasks:
456
473
  try:
tactus/core/runtime.py CHANGED
@@ -14,7 +14,7 @@ import logging
14
14
  import time
15
15
  import uuid
16
16
  from pathlib import Path
17
- from typing import Any, Dict, List, Optional
17
+ from typing import Any, Dict, List, Optional, TYPE_CHECKING
18
18
 
19
19
  from tactus.core.registry import ProcedureRegistry, RegistryBuilder, TaskDeclaration
20
20
  from tactus.core.dsl_stubs import create_dsl_stubs, lua_table_to_dict
@@ -28,6 +28,9 @@ from tactus.protocols.storage import StorageBackend
28
28
  from tactus.protocols.hitl import HITLHandler
29
29
  from tactus.protocols.chat_recorder import ChatRecorder
30
30
 
31
+ if TYPE_CHECKING:
32
+ from biblicus.corpus import Corpus
33
+
31
34
  # For backwards compatibility with YAML
32
35
  try:
33
36
  from tactus.core.yaml_parser import ProcedureYAMLParser, ProcedureConfigError
@@ -142,6 +145,8 @@ class TactusRuntime:
142
145
  self.tool_paths = tool_paths or []
143
146
  self.recursion_depth = recursion_depth
144
147
  self.external_config = external_config or {}
148
+ self.dependency_mode = self.external_config.get("dependency_mode", "prompt")
149
+ self.dependency_prompt_handler = None
145
150
  self.run_id = run_id
146
151
  self.source_file_path = source_file_path
147
152
 
@@ -2499,6 +2504,7 @@ class TactusRuntime:
2499
2504
  """
2500
2505
  if not self.task_name and self.registry:
2501
2506
  explicit_tasks = getattr(self.registry, "tasks", {}) or {}
2507
+ named_procedures = getattr(self.registry, "named_procedures", {}) or {}
2502
2508
 
2503
2509
  def _flatten_tasks(task_map: dict, prefix: str = "") -> list[str]:
2504
2510
  names: list[str] = []
@@ -2528,10 +2534,12 @@ class TactusRuntime:
2528
2534
  implicit_tasks.append(f"{task}:{retriever_name}")
2529
2535
 
2530
2536
  if explicit_tasks:
2531
- if len(explicit_tasks) == 1:
2532
- self.task_name = next(iter(explicit_tasks.keys()))
2533
- elif "run" in explicit_tasks:
2537
+ if "run" in explicit_tasks:
2534
2538
  self.task_name = "run"
2539
+ elif "main" in named_procedures:
2540
+ self.task_name = None
2541
+ elif len(explicit_tasks) == 1:
2542
+ self.task_name = next(iter(explicit_tasks.keys()))
2535
2543
  else:
2536
2544
  from tactus.core.exceptions import TaskSelectionRequired
2537
2545
 
@@ -2549,6 +2557,7 @@ class TactusRuntime:
2549
2557
  return self._execute_task(self.task_name)
2550
2558
 
2551
2559
  if self.registry:
2560
+ self._execute_run_dependencies()
2552
2561
  # Check for named 'main' procedure first
2553
2562
  if "main" in self.registry.named_procedures:
2554
2563
  logger.info("Executing named 'main' procedure")
@@ -2636,10 +2645,169 @@ class TactusRuntime:
2636
2645
  logger.error(f"Legacy procedure execution failed: {e}")
2637
2646
  raise
2638
2647
 
2639
- def _execute_task(self, task_name: str) -> Any:
2648
+ def _parse_task_dependencies(self, task_payload: dict[str, Any]) -> list[str]:
2649
+ depends_on = task_payload.get("depends_on")
2650
+ if isinstance(depends_on, str):
2651
+ return [depends_on]
2652
+ if isinstance(depends_on, list):
2653
+ return [item for item in depends_on if isinstance(item, str) and item.strip()]
2654
+ return []
2655
+
2656
+ def _parse_task_provides(self, task_payload: dict[str, Any]) -> list[dict[str, Any]]:
2657
+ provides = task_payload.get("provides")
2658
+ if isinstance(provides, dict):
2659
+ return [provides]
2660
+ if isinstance(provides, list):
2661
+ return [item for item in provides if isinstance(item, dict)]
2662
+ return []
2663
+
2664
+ def _iter_task_declarations(self) -> list[tuple[str, TaskDeclaration]]:
2665
+ if not self.registry:
2666
+ return []
2667
+ tasks = getattr(self.registry, "tasks", None) or {}
2668
+ if not tasks:
2669
+ return []
2670
+
2671
+ def walk(task: TaskDeclaration, prefix: str) -> list[tuple[str, TaskDeclaration]]:
2672
+ full_name = f"{prefix}:{task.name}" if prefix else task.name
2673
+ pairs = [(full_name, task)]
2674
+ for child in task.children.values():
2675
+ pairs.extend(walk(child, full_name))
2676
+ return pairs
2677
+
2678
+ pairs: list[tuple[str, TaskDeclaration]] = []
2679
+ for task in tasks.values():
2680
+ pairs.extend(walk(task, ""))
2681
+ return pairs
2682
+
2683
+ def _find_task_providing(self, *, kind: str, corpus_name: Optional[str]) -> Optional[str]:
2684
+ if not self.registry:
2685
+ return None
2686
+ matches: list[str] = []
2687
+ normalize_task_kind = self._normalize_task_kind
2688
+
2689
+ for full_name, task in self._iter_task_declarations():
2690
+ payload = task.model_dump()
2691
+ for provides in self._parse_task_provides(payload):
2692
+ provided_kind = provides.get("kind")
2693
+ provided_corpus = provides.get("corpus")
2694
+ if not isinstance(provided_kind, str):
2695
+ continue
2696
+ if normalize_task_kind(provided_kind) != kind:
2697
+ continue
2698
+ if provided_corpus is None or provided_corpus == corpus_name:
2699
+ matches.append(full_name)
2700
+ if matches:
2701
+ return matches[0]
2702
+ if kind == "load" and corpus_name and len(self.registry.corpora) == 1:
2703
+ tasks = getattr(self.registry, "tasks", None) or {}
2704
+ if "load" in tasks:
2705
+ return "load"
2706
+ return None
2707
+
2708
+ @staticmethod
2709
+ def _normalize_task_kind(value: str) -> str:
2710
+ if not isinstance(value, str):
2711
+ return ""
2712
+ cleaned = value.strip().lower()
2713
+ aliases = {
2714
+ "fetch": "load",
2715
+ "sync": "load",
2716
+ "build": "index",
2717
+ "run": "query",
2718
+ }
2719
+ return aliases.get(cleaned, cleaned)
2720
+
2721
+ def _open_or_init_corpus(self, corpus_root: Path) -> "Corpus":
2722
+ from biblicus.corpus import Corpus
2723
+
2724
+ corpus = Corpus(corpus_root.resolve())
2725
+ try:
2726
+ corpus.load_catalog()
2727
+ except FileNotFoundError:
2728
+ corpus = Corpus.init(corpus_root, force=False)
2729
+ return corpus
2730
+
2731
+ def _corpus_pipeline_config(self, corpus_decl: Any) -> Optional[dict]:
2732
+ config = corpus_decl.config if isinstance(corpus_decl.config, dict) else {}
2733
+ corpus_configuration = config.get("configuration", {}) if isinstance(config, dict) else {}
2734
+ pipeline = (
2735
+ corpus_configuration.get("pipeline", {})
2736
+ if isinstance(corpus_configuration, dict)
2737
+ else {}
2738
+ )
2739
+ if not isinstance(pipeline, dict):
2740
+ return None
2741
+ if "extract" in pipeline and isinstance(pipeline.get("extract"), dict):
2742
+ return pipeline.get("extract")
2743
+ if "steps" in pipeline:
2744
+ return pipeline
2745
+ return None
2746
+
2747
+ def _retriever_index_config(self, retriever_decl: Any) -> dict:
2748
+ config = retriever_decl.config if isinstance(retriever_decl.config, dict) else {}
2749
+ configuration = config.get("configuration", {}) if isinstance(config, dict) else {}
2750
+ pipeline = configuration.get("pipeline", {}) if isinstance(configuration, dict) else {}
2751
+ if not pipeline and isinstance(config.get("pipeline"), dict):
2752
+ pipeline = config.get("pipeline") or {}
2753
+ index_config = pipeline.get("index", {}) if isinstance(pipeline, dict) else {}
2754
+ if isinstance(index_config, dict):
2755
+ return index_config
2756
+ return {}
2757
+
2758
+ def _prompt_dependency_plan(self, *, plan: Any, label: str) -> bool:
2759
+ prompt_handler = getattr(self, "dependency_prompt_handler", None)
2760
+ if callable(prompt_handler):
2761
+ return bool(prompt_handler(plan, label))
2762
+ pending = [task.kind for task in plan.tasks if task.status != "complete"]
2763
+ prompt = f"Run dependencies for {label}? ({', '.join(pending)}) [y/N]: "
2764
+ response = input(prompt).strip().lower()
2765
+ return response in {"y", "yes"}
2766
+
2767
+ def _execute_dependency_plan(
2768
+ self,
2769
+ *,
2770
+ plan: Any,
2771
+ corpus: Any,
2772
+ label: str,
2773
+ load_task_name: Optional[str] = None,
2774
+ extract_task_name: Optional[str] = None,
2775
+ index_task_name: Optional[str] = None,
2776
+ ) -> list[Any]:
2777
+ mode = self.dependency_mode
2778
+ if plan.status == "complete":
2779
+ return []
2780
+ if plan.status == "blocked":
2781
+ raise RuntimeError(plan.root.reason or f"Dependencies blocked for {label}")
2782
+ if mode == "none":
2783
+ raise RuntimeError(f"Dependencies missing for {label}")
2784
+ if mode not in {"prompt", "auto"}:
2785
+ raise RuntimeError(f"Unsupported dependency mode: {mode}")
2786
+ if mode == "prompt" and not self._prompt_dependency_plan(plan=plan, label=label):
2787
+ raise RuntimeError(f"Dependencies declined for {label}")
2788
+
2789
+ from biblicus.workflow import build_default_handler_registry
2790
+
2791
+ handler_registry = build_default_handler_registry(corpus)
2792
+ if load_task_name:
2793
+ handler_registry["load"] = lambda _: self._execute_task(load_task_name)
2794
+ if extract_task_name:
2795
+ handler_registry["extract"] = lambda _: self._execute_task(extract_task_name)
2796
+ if index_task_name:
2797
+ handler_registry["index"] = lambda _: self._execute_task(index_task_name)
2798
+ return plan.execute(mode="auto", handler_registry=handler_registry)
2799
+
2800
+ def _execute_task(self, task_name: str, *, _stack: Optional[list[str]] = None) -> Any:
2640
2801
  if not self.registry:
2641
2802
  raise RuntimeError("No registry available for task execution")
2642
2803
 
2804
+ stack = list(_stack or [])
2805
+ if task_name in stack:
2806
+ raise RuntimeError(
2807
+ f"Task dependency cycle detected: {' -> '.join(stack + [task_name])}"
2808
+ )
2809
+ stack.append(task_name)
2810
+
2643
2811
  task = self._resolve_task(task_name)
2644
2812
  if task is None:
2645
2813
  # Allow run fallback to main procedure
@@ -2647,6 +2815,10 @@ class TactusRuntime:
2647
2815
  self.task_name = None
2648
2816
  return self._execute_workflow()
2649
2817
 
2818
+ corpus_tasks = self._resolve_corpus_task_targets(task_name)
2819
+ if corpus_tasks:
2820
+ return self._execute_corpus_tasks(task_name, corpus_tasks)
2821
+
2650
2822
  retriever_tasks = self._resolve_retriever_task_targets(task_name)
2651
2823
  if retriever_tasks:
2652
2824
  return self._execute_retriever_tasks(task_name, retriever_tasks)
@@ -2654,6 +2826,10 @@ class TactusRuntime:
2654
2826
  raise RuntimeError(f"Task '{task_name}' not found")
2655
2827
 
2656
2828
  task_payload = task.model_dump()
2829
+ if task_name == "run":
2830
+ self._execute_run_dependencies()
2831
+ for dependency in self._parse_task_dependencies(task_payload):
2832
+ self._execute_task(dependency, _stack=stack)
2657
2833
  entry = task_payload.get("entry")
2658
2834
  if entry is None:
2659
2835
  if task.children:
@@ -2667,20 +2843,74 @@ class TactusRuntime:
2667
2843
  raise RuntimeError(f"Task '{task_name}' entry must be a function")
2668
2844
  return entry()
2669
2845
 
2670
- def _execute_retriever_tasks(self, task_name: str, retriever_names: list[str]) -> Any:
2671
- if not retriever_names:
2672
- raise RuntimeError(f"No retrievers available for task '{task_name}'")
2846
+ def _execute_run_dependencies(self) -> None:
2847
+ if not self.registry:
2848
+ return
2849
+ retrievers = getattr(self.registry, "retrievers", None) or {}
2850
+ if not retrievers:
2851
+ return
2852
+ for retriever_name in retrievers:
2853
+ self._ensure_retriever_dependencies(retriever_name)
2673
2854
 
2855
+ def _execute_corpus_tasks(self, task_name: str, corpus_names: list[str]) -> Any:
2856
+ if not corpus_names:
2857
+ raise RuntimeError(f"No corpora available for task '{task_name}'")
2674
2858
  base_task = task_name.split(":")[0]
2675
- if base_task == "index":
2859
+ if base_task == "extract":
2676
2860
  results = []
2677
- for retriever_name in retriever_names:
2678
- results.append(self._execute_retriever_index(retriever_name))
2861
+ for corpus_name in corpus_names:
2862
+ results.append(self._execute_corpus_extract(corpus_name))
2679
2863
  return results[0] if len(results) == 1 else results
2864
+ raise RuntimeError(f"Corpus task '{base_task}' is not supported")
2680
2865
 
2681
- raise RuntimeError(f"Retriever task '{base_task}' is not supported")
2866
+ def _execute_corpus_extract(self, corpus_name: str) -> Any:
2867
+ if not self.registry:
2868
+ raise RuntimeError("No registry available for corpus execution")
2869
+ corpus_decl = self.registry.corpora.get(corpus_name)
2870
+ if corpus_decl is None:
2871
+ raise RuntimeError(f"Corpus '{corpus_name}' not found")
2872
+ corpus_root = corpus_decl.config.get("corpus_root") or corpus_decl.config.get("root")
2873
+ if not corpus_root:
2874
+ raise RuntimeError(f"Corpus '{corpus_name}' is missing a root path")
2682
2875
 
2683
- def _execute_retriever_index(self, retriever_name: str) -> Any:
2876
+ pipeline_config = self._corpus_pipeline_config(corpus_decl)
2877
+ load_task_name = self._find_task_providing(kind="load", corpus_name=corpus_name)
2878
+ extract_task_name = self._find_task_providing(kind="extract", corpus_name=corpus_name)
2879
+
2880
+ try:
2881
+ from biblicus.workflow import build_plan_for_extract
2882
+ except Exception as exc:
2883
+ raise RuntimeError(f"Biblicus workflow unavailable: {exc}") from exc
2884
+
2885
+ corpus = self._open_or_init_corpus(Path(corpus_root))
2886
+ plan = build_plan_for_extract(
2887
+ corpus,
2888
+ pipeline_config=pipeline_config,
2889
+ load_handler_available=bool(load_task_name),
2890
+ )
2891
+ results = self._execute_dependency_plan(
2892
+ plan=plan,
2893
+ corpus=corpus,
2894
+ label=f"corpus '{corpus_name}'",
2895
+ load_task_name=load_task_name,
2896
+ extract_task_name=extract_task_name,
2897
+ )
2898
+ return results[-1] if results else {"status": "up-to-date"}
2899
+
2900
+ def _ensure_retriever_dependencies(self, retriever_name: str) -> None:
2901
+ plan, corpus, handlers = self._build_retriever_index_plan(retriever_name)
2902
+ self._execute_dependency_plan(
2903
+ plan=plan,
2904
+ corpus=corpus,
2905
+ label=f"retriever '{retriever_name}'",
2906
+ load_task_name=handlers.get("load"),
2907
+ extract_task_name=handlers.get("extract"),
2908
+ index_task_name=handlers.get("index"),
2909
+ )
2910
+
2911
+ def _build_retriever_index_plan(
2912
+ self, retriever_name: str
2913
+ ) -> tuple[Any, Any, dict[str, Optional[str]]]:
2684
2914
  if not self.registry:
2685
2915
  raise RuntimeError("No registry available for retriever execution")
2686
2916
 
@@ -2699,63 +2929,78 @@ class TactusRuntime:
2699
2929
  if not corpus_root:
2700
2930
  raise RuntimeError(f"Corpus '{retriever.corpus}' is missing a root path")
2701
2931
 
2702
- extraction_pipeline = {}
2703
- corpus_configuration = (
2704
- corpus_decl.config.get("configuration", {})
2705
- if isinstance(corpus_decl.config, dict)
2706
- else {}
2707
- )
2708
- if isinstance(corpus_configuration, dict):
2709
- pipeline = corpus_configuration.get("pipeline", {}) or {}
2710
- if isinstance(pipeline, dict):
2711
- extraction_pipeline = pipeline.get("extract", {}) or {}
2712
- if isinstance(extraction_pipeline, list):
2713
- extraction_pipeline = {}
2714
-
2715
- retriever_id = retriever.config.get("retriever_id") or retriever.config.get(
2716
- "retriever_type"
2932
+ from tactus.core.retriever_tasks import resolve_retriever_id
2933
+
2934
+ retriever_id = resolve_retriever_id(
2935
+ retriever.config if isinstance(retriever.config, dict) else {}
2717
2936
  )
2718
2937
  if not retriever_id:
2719
2938
  raise RuntimeError(
2720
- f"Retriever '{retriever_name}' is missing retriever_id; cannot build snapshot"
2939
+ f"Retriever '{retriever_name}' is missing retriever_id; cannot build plan"
2721
2940
  )
2722
2941
 
2723
- configuration = retriever.config.get("configuration", {})
2724
- pipeline = configuration.get("pipeline", {}) if isinstance(configuration, dict) else {}
2725
- if not pipeline and isinstance(retriever.config.get("pipeline"), dict):
2726
- pipeline = retriever.config.get("pipeline") or {}
2727
- index_config = pipeline.get("index", {}) if isinstance(pipeline, dict) else {}
2728
- if isinstance(index_config, list):
2729
- index_config = {}
2942
+ pipeline_config = self._corpus_pipeline_config(corpus_decl)
2943
+ index_config = self._retriever_index_config(retriever)
2944
+
2945
+ load_task_name = self._find_task_providing(kind="load", corpus_name=retriever.corpus)
2946
+ extract_task_name = self._find_task_providing(kind="extract", corpus_name=retriever.corpus)
2947
+ index_task_name = None
2948
+ for task_name, task in self._iter_task_declarations():
2949
+ for provides in self._parse_task_provides(task.model_dump()):
2950
+ if provides.get("kind") != "index":
2951
+ continue
2952
+ provided_retriever = provides.get("retriever")
2953
+ if provided_retriever in {retriever_name, retriever_id}:
2954
+ index_task_name = task_name
2955
+ break
2956
+ if index_task_name:
2957
+ break
2730
2958
 
2731
2959
  try:
2732
- from biblicus.corpus import Corpus
2733
- from biblicus.extraction import build_extraction_snapshot
2734
- from biblicus.retrievers import get_retriever
2960
+ from biblicus.workflow import build_plan_for_index
2735
2961
  except Exception as exc:
2736
- raise RuntimeError(f"Biblicus retrieval retriever unavailable: {exc}") from exc
2737
-
2738
- corpus = Corpus(Path(corpus_root))
2739
- retriever_impl = get_retriever(retriever_id)
2740
- configuration_name = retriever_name
2741
-
2742
- if extraction_pipeline and isinstance(extraction_pipeline, dict):
2743
- extraction_manifest = build_extraction_snapshot(
2744
- corpus,
2745
- extractor_id="pipeline",
2746
- configuration_name=f"{retriever.corpus or retriever_name}-extract",
2747
- configuration=extraction_pipeline,
2748
- )
2749
- if isinstance(index_config, dict) and "extraction_snapshot" not in index_config:
2750
- index_config["extraction_snapshot"] = (
2751
- f"{extraction_manifest.configuration.extractor_id}:"
2752
- f"{extraction_manifest.snapshot_id}"
2753
- )
2754
- snapshot = retriever_impl.build_snapshot(
2755
- corpus, configuration_name=configuration_name, configuration=index_config
2962
+ raise RuntimeError(f"Biblicus workflow unavailable: {exc}") from exc
2963
+
2964
+ corpus = self._open_or_init_corpus(Path(corpus_root))
2965
+ plan = build_plan_for_index(
2966
+ corpus,
2967
+ retriever_id,
2968
+ pipeline_config=pipeline_config,
2969
+ index_config=index_config,
2970
+ load_handler_available=bool(load_task_name),
2756
2971
  )
2757
2972
 
2758
- return snapshot.model_dump()
2973
+ handlers = {
2974
+ "load": load_task_name,
2975
+ "extract": extract_task_name,
2976
+ "index": index_task_name,
2977
+ }
2978
+ return plan, corpus, handlers
2979
+
2980
+ def _execute_retriever_tasks(self, task_name: str, retriever_names: list[str]) -> Any:
2981
+ if not retriever_names:
2982
+ raise RuntimeError(f"No retrievers available for task '{task_name}'")
2983
+
2984
+ base_task = task_name.split(":")[0]
2985
+ if base_task == "index":
2986
+ results = []
2987
+ for retriever_name in retriever_names:
2988
+ results.append(self._execute_retriever_index(retriever_name))
2989
+ return results[0] if len(results) == 1 else results
2990
+
2991
+ raise RuntimeError(f"Retriever task '{base_task}' is not supported")
2992
+
2993
+ def _execute_retriever_index(self, retriever_name: str) -> Any:
2994
+ plan, corpus, handlers = self._build_retriever_index_plan(retriever_name)
2995
+ results = self._execute_dependency_plan(
2996
+ plan=plan,
2997
+ corpus=corpus,
2998
+ label=f"retriever '{retriever_name}'",
2999
+ load_task_name=handlers.get("load"),
3000
+ extract_task_name=handlers.get("extract"),
3001
+ index_task_name=handlers.get("index"),
3002
+ )
3003
+ return results[-1] if results else {"status": "up-to-date"}
2759
3004
 
2760
3005
  def _resolve_task(self, task_name: str) -> Optional[TaskDeclaration]:
2761
3006
  if not self.registry:
@@ -2803,6 +3048,21 @@ class TactusRuntime:
2803
3048
  return [name for name in targets if name == target]
2804
3049
  return targets
2805
3050
 
3051
+ def _resolve_corpus_task_targets(self, task_name: str) -> list[str]:
3052
+ if not self.registry or not self.registry.corpora:
3053
+ return []
3054
+ segments = [segment for segment in task_name.split(":") if segment]
3055
+ if not segments:
3056
+ return []
3057
+ task = segments[0]
3058
+ target = segments[1] if len(segments) > 1 else None
3059
+ if task != "extract":
3060
+ return []
3061
+ targets = list(self.registry.corpora.keys())
3062
+ if target:
3063
+ return [name for name in targets if name == target]
3064
+ return targets
3065
+
2806
3066
  def _expand_inline_task_children(self, registry: ProcedureRegistry) -> None:
2807
3067
  if not registry or not registry.tasks:
2808
3068
  return
@@ -2892,7 +3152,11 @@ class TactusRuntime:
2892
3152
  r"^\s*[A-Za-z_][A-Za-z0-9_]*\s*=\s*(?:"
2893
3153
  r"Agent|Toolset|Tool|Model|Module|Signature|LM|Dependency|Prompt|"
2894
3154
  r"Task|TaskFunction|Context|Corpus|Retriever|Compactor|function|"
2895
- r"[A-Za-z_][A-Za-z0-9_]*Retriever|[A-Za-z_][A-Za-z0-9_]*\.Retriever"
3155
+ r"[A-Za-z_][A-Za-z0-9_]*Retriever|"
3156
+ r"[A-Za-z_][A-Za-z0-9_]*\.Retriever|"
3157
+ r"[A-Za-z_][A-Za-z0-9_]*\.Corpus|"
3158
+ r"[A-Za-z_][A-Za-z0-9_]*\.Context|"
3159
+ r"[A-Za-z_][A-Za-z0-9_]*\.Compactor"
2896
3160
  r")\b"
2897
3161
  )
2898
3162
  # Match function definitions: function name() or local function name()
@@ -3167,6 +3431,9 @@ class TactusRuntime:
3167
3431
  except LuaSandboxError as e:
3168
3432
  raise TactusRuntimeError(f"Failed to parse DSL: {e}")
3169
3433
 
3434
+ lua_globals = sandbox.lua.globals()
3435
+ self._register_assignment_tasks(builder, lua_globals)
3436
+
3170
3437
  self._expand_inline_task_children(builder.registry)
3171
3438
 
3172
3439
  # Execute IncludeTasks files to register additional tasks
@@ -3269,7 +3536,6 @@ class TactusRuntime:
3269
3536
  # The script mode transformation (in _maybe_transform_script_mode_source)
3270
3537
  # is designed to skip files with named function definitions to avoid wrapping
3271
3538
  # them incorrectly.
3272
- lua_globals = sandbox.lua.globals()
3273
3539
  if "main" in lua_globals:
3274
3540
  main_func = lua_globals["main"]
3275
3541
  # Check if it's a function and not already registered
@@ -3297,6 +3563,49 @@ class TactusRuntime:
3297
3563
  logger.debug(f"Registry after parsing: lua_tools={list(result.registry.lua_tools.keys())}")
3298
3564
  return result.registry
3299
3565
 
3566
+ def _register_assignment_tasks(self, builder: RegistryBuilder, lua_globals: Any) -> None:
3567
+ if not lua_globals:
3568
+ return
3569
+
3570
+ for key, value in lua_globals.items():
3571
+ if not isinstance(key, str):
3572
+ continue
3573
+ if not hasattr(value, "items"):
3574
+ continue
3575
+ try:
3576
+ marker = value["__tactus_task_config"]
3577
+ except Exception:
3578
+ continue
3579
+ if not marker:
3580
+ continue
3581
+ task_config = lua_table_to_dict(value)
3582
+ if not isinstance(task_config, dict):
3583
+ continue
3584
+ if key in builder.registry.tasks:
3585
+ continue
3586
+ if "entry" in task_config and not callable(task_config["entry"]):
3587
+ raise TactusRuntimeError(f"Task '{key}' entry must be a function")
3588
+ builder.register_task(key, task_config)
3589
+
3590
+ child_sources = task_config.get("__tactus_child_tasks")
3591
+ if isinstance(child_sources, dict):
3592
+ child_iter = child_sources.items()
3593
+ else:
3594
+ child_iter = value.items()
3595
+ for child_key, child_value in child_iter:
3596
+ child_name = child_key if isinstance(child_key, str) else None
3597
+ if not hasattr(child_value, "items"):
3598
+ continue
3599
+ child_config = lua_table_to_dict(child_value)
3600
+ if not child_name and isinstance(child_config, dict):
3601
+ child_name = child_config.get("__task_name")
3602
+ if child_name and isinstance(child_config, dict):
3603
+ if "entry" in child_config and not callable(child_config["entry"]):
3604
+ raise TactusRuntimeError(
3605
+ f"Task '{key}:{child_name}' entry must be a function"
3606
+ )
3607
+ builder.register_task(child_name, child_config, parent=key)
3608
+
3300
3609
  def _registry_to_config(self, registry: ProcedureRegistry) -> dict[str, Any]:
3301
3610
  """
3302
3611
  Convert registry to config dict format.
@@ -0,0 +1,3 @@
1
+ """
2
+ Local plugin tools for Tactus.
3
+ """
tactus/plugins/noaa.py ADDED
@@ -0,0 +1,76 @@
1
+ """
2
+ NOAA AFD helper tools for Tactus demos.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any, Dict
11
+
12
+
13
+ def fetch_noaa_afd(
14
+ *,
15
+ wfo: str,
16
+ max_items: int = 5,
17
+ output_root: str = "tests/fixtures/noaa_afd",
18
+ corpus_root: str = "tests/fixtures/noaa_afd_corpus",
19
+ force: bool = True,
20
+ ) -> Dict[str, Any]:
21
+ """
22
+ Fetch NOAA AFD fixtures and import them into a Biblicus corpus (no index).
23
+
24
+ :param wfo: Weather Forecast Office code (e.g., MFL).
25
+ :type wfo: str
26
+ :param max_items: Maximum number of items to fetch.
27
+ :type max_items: int
28
+ :param output_root: Directory for raw fixture output.
29
+ :type output_root: str
30
+ :param corpus_root: Directory for Biblicus corpus output.
31
+ :type corpus_root: str
32
+ :param force: Whether to recreate the corpus directory.
33
+ :type force: bool
34
+ :return: Summary of the fetch/import operation.
35
+ :rtype: dict[str, Any]
36
+ """
37
+ repo_root = Path(__file__).resolve().parents[2]
38
+ fetch_script = repo_root / "scripts" / "fetch_noaa_afd_corpus.py"
39
+ prepare_script = repo_root / "scripts" / "prepare_noaa_afd_biblicus_corpus.py"
40
+
41
+ output_root_path = Path(output_root)
42
+ corpus_root_path = Path(corpus_root) / wfo.upper()
43
+
44
+ fetch_args = [
45
+ sys.executable,
46
+ str(fetch_script),
47
+ "--wfo",
48
+ wfo,
49
+ "--max-items",
50
+ str(max_items),
51
+ "--output",
52
+ str(output_root_path),
53
+ ]
54
+ subprocess.run(fetch_args, check=True)
55
+
56
+ prepare_args = [
57
+ sys.executable,
58
+ str(prepare_script),
59
+ "--wfo",
60
+ wfo,
61
+ "--corpus",
62
+ str(corpus_root_path),
63
+ "--no-index",
64
+ ]
65
+ if force:
66
+ prepare_args.append("--force")
67
+ subprocess.run(prepare_args, check=True)
68
+
69
+ return {
70
+ "status": "ok",
71
+ "wfo": wfo,
72
+ "max_items": max_items,
73
+ "output_root": str(output_root_path),
74
+ "corpus_root": str(corpus_root_path),
75
+ "indexed": False,
76
+ }
@@ -62,10 +62,18 @@ def _normalize_client_config(client: Any) -> Any:
62
62
 
63
63
  payload = dict(client)
64
64
  model = payload.get("model")
65
- provider = payload.get("provider")
66
65
  if not model:
67
66
  raise ValueError("client.model is required")
68
67
 
68
+ provider, model = _resolve_provider_and_model(payload.get("provider"), model)
69
+ payload["provider"] = provider
70
+ payload["model"] = model
71
+
72
+ biblicus = _require_biblicus_text()
73
+ return biblicus["LlmClientConfig"](**payload)
74
+
75
+
76
+ def _resolve_provider_and_model(provider: str | None, model: Any) -> tuple[str, Any]:
69
77
  if provider is None and isinstance(model, str) and "/" in model:
70
78
  provider, model = model.split("/", 1)
71
79
  elif provider is not None and isinstance(model, str) and model.startswith(f"{provider}/"):
@@ -74,11 +82,7 @@ def _normalize_client_config(client: Any) -> Any:
74
82
  if provider is None:
75
83
  raise ValueError("client.provider is required when model lacks a provider prefix")
76
84
 
77
- payload["provider"] = provider
78
- payload["model"] = model
79
-
80
- biblicus = _require_biblicus_text()
81
- return biblicus["LlmClientConfig"](**payload)
85
+ return provider, model
82
86
 
83
87
 
84
88
  def _prepare_request(request: Dict[str, Any]) -> Dict[str, Any]:
@@ -97,11 +101,7 @@ def _prepare_request(request: Dict[str, Any]) -> Dict[str, Any]:
97
101
 
98
102
 
99
103
  def _maybe_mock(tool_name: str, payload: Dict[str, Any]) -> Dict[str, Any] | None:
100
- try:
101
- from tactus.core.mocking import get_current_mock_manager
102
- except Exception:
103
- return None
104
- mock_manager = get_current_mock_manager()
104
+ mock_manager = _get_mock_manager()
105
105
  if mock_manager is None:
106
106
  return None
107
107
  mock_result = mock_manager.get_mock_response(tool_name, payload)
@@ -111,54 +111,73 @@ def _maybe_mock(tool_name: str, payload: Dict[str, Any]) -> Dict[str, Any] | Non
111
111
  return mock_result
112
112
 
113
113
 
114
- def extract(request: Dict[str, Any]) -> Dict[str, Any]:
114
+ def _get_mock_manager() -> Any | None:
115
+ try:
116
+ from tactus.core.mocking import get_current_mock_manager
117
+ except Exception:
118
+ return None
119
+ return get_current_mock_manager()
120
+
121
+
122
+ def _run_text_tool(
123
+ request: Dict[str, Any],
124
+ *,
125
+ tool_name: str,
126
+ request_model_key: str,
127
+ apply_key: str,
128
+ ) -> Dict[str, Any]:
115
129
  payload = _prepare_request(request)
116
- mock_result = _maybe_mock("biblicus.text.extract", payload)
130
+ mock_result = _maybe_mock(tool_name, payload)
117
131
  if mock_result is not None:
118
132
  return mock_result
119
133
  biblicus = _require_biblicus_text()
120
- result = biblicus["apply_text_extract"](biblicus["TextExtractRequest"](**payload))
134
+ result = biblicus[apply_key](biblicus[request_model_key](**payload))
121
135
  return result.model_dump()
122
136
 
123
137
 
138
+ def extract(request: Dict[str, Any]) -> Dict[str, Any]:
139
+ return _run_text_tool(
140
+ request,
141
+ tool_name="biblicus.text.extract",
142
+ request_model_key="TextExtractRequest",
143
+ apply_key="apply_text_extract",
144
+ )
145
+
146
+
124
147
  def slice(request: Dict[str, Any]) -> Dict[str, Any]:
125
- payload = _prepare_request(request)
126
- mock_result = _maybe_mock("biblicus.text.slice", payload)
127
- if mock_result is not None:
128
- return mock_result
129
- biblicus = _require_biblicus_text()
130
- result = biblicus["apply_text_slice"](biblicus["TextSliceRequest"](**payload))
131
- return result.model_dump()
148
+ return _run_text_tool(
149
+ request,
150
+ tool_name="biblicus.text.slice",
151
+ request_model_key="TextSliceRequest",
152
+ apply_key="apply_text_slice",
153
+ )
132
154
 
133
155
 
134
156
  def annotate(request: Dict[str, Any]) -> Dict[str, Any]:
135
- payload = _prepare_request(request)
136
- mock_result = _maybe_mock("biblicus.text.annotate", payload)
137
- if mock_result is not None:
138
- return mock_result
139
- biblicus = _require_biblicus_text()
140
- result = biblicus["apply_text_annotate"](biblicus["TextAnnotateRequest"](**payload))
141
- return result.model_dump()
157
+ return _run_text_tool(
158
+ request,
159
+ tool_name="biblicus.text.annotate",
160
+ request_model_key="TextAnnotateRequest",
161
+ apply_key="apply_text_annotate",
162
+ )
142
163
 
143
164
 
144
165
  def redact(request: Dict[str, Any]) -> Dict[str, Any]:
145
- payload = _prepare_request(request)
146
- mock_result = _maybe_mock("biblicus.text.redact", payload)
147
- if mock_result is not None:
148
- return mock_result
149
- biblicus = _require_biblicus_text()
150
- result = biblicus["apply_text_redact"](biblicus["TextRedactRequest"](**payload))
151
- return result.model_dump()
166
+ return _run_text_tool(
167
+ request,
168
+ tool_name="biblicus.text.redact",
169
+ request_model_key="TextRedactRequest",
170
+ apply_key="apply_text_redact",
171
+ )
152
172
 
153
173
 
154
174
  def link(request: Dict[str, Any]) -> Dict[str, Any]:
155
- payload = _prepare_request(request)
156
- mock_result = _maybe_mock("biblicus.text.link", payload)
157
- if mock_result is not None:
158
- return mock_result
159
- biblicus = _require_biblicus_text()
160
- result = biblicus["apply_text_link"](biblicus["TextLinkRequest"](**payload))
161
- return result.model_dump()
175
+ return _run_text_tool(
176
+ request,
177
+ tool_name="biblicus.text.link",
178
+ request_model_key="TextLinkRequest",
179
+ apply_key="apply_text_link",
180
+ )
162
181
 
163
182
 
164
183
  def strip_span_tags(marked_up_text: str) -> str:
@@ -131,29 +131,9 @@ class TactusDSLVisitor(LuaParserVisitor):
131
131
  assignment_target_name = assignment_target_node.NAME().getText()
132
132
  self._track_retriever_alias(assignment_target_name, expression_list)
133
133
 
134
- # Check if this is a DSL setting assignment
135
- setting_handlers_by_name = {
136
- "default_provider": self.builder.set_default_provider,
137
- "default_model": self.builder.set_default_model,
138
- "return_prompt": self.builder.set_return_prompt,
139
- "error_prompt": self.builder.set_error_prompt,
140
- "status_prompt": self.builder.set_status_prompt,
141
- "async": self.builder.set_async,
142
- "max_depth": self.builder.set_max_depth,
143
- "max_turns": self.builder.set_max_turns,
144
- }
145
- if assignment_target_name in setting_handlers_by_name:
146
- # Get the value from explist
147
- if expression_list.exp() and len(expression_list.exp()) > 0:
148
- first_expression = expression_list.exp()[0]
149
- literal_value = self._extract_literal_value(first_expression)
150
- # Process the assignment like a function call
151
- setting_handlers_by_name[assignment_target_name](literal_value)
152
- else:
153
- # Check for assignment-based DSL declarations
154
- # e.g., greeter = Agent {...}, done = Tool {...}
155
- if expression_list.exp() and len(expression_list.exp()) > 0:
156
- first_expression = expression_list.exp()[0]
134
+ if not self._apply_setting_assignment(assignment_target_name, expression_list):
135
+ first_expression = self._extract_first_expression(expression_list)
136
+ if first_expression:
157
137
  self._check_assignment_based_declaration(
158
138
  assignment_target_name, first_expression
159
139
  )
@@ -219,105 +199,116 @@ class TactusDSLVisitor(LuaParserVisitor):
219
199
  )
220
200
  return
221
201
 
222
- if function_name == "Agent" and not is_chained_method_call:
223
- # Extract config from Agent {...}
224
- declaration_config = self._extract_single_table_arg(function_call)
225
- # Filter out None values from tools list (variable refs can't be resolved)
226
- if declaration_config and "tools" in declaration_config:
227
- tool_name_list = declaration_config["tools"]
228
- if isinstance(tool_name_list, list):
229
- declaration_config["tools"] = [
230
- tool_name for tool_name in tool_name_list if tool_name is not None
231
- ]
232
- self.builder.register_agent(
233
- assignment_target_name,
234
- declaration_config if declaration_config else {},
235
- None,
236
- )
237
- elif function_name == "Tool":
238
- # Extract config from Tool {...}
239
- declaration_config = self._extract_single_table_arg(function_call)
240
- if (
241
- declaration_config
242
- and isinstance(declaration_config, dict)
243
- and isinstance(declaration_config.get("name"), str)
244
- and declaration_config.get("name") != assignment_target_name
245
- ):
246
- self._record_error(
247
- message=(
248
- f"Tool name mismatch: '{assignment_target_name} = Tool {{ name = \"{declaration_config.get('name')}\" }}'. "
249
- f"Remove the 'name' field or set it to '{assignment_target_name}'."
250
- ),
251
- declaration="Tool",
252
- )
253
- self.builder.register_tool(
254
- assignment_target_name,
255
- declaration_config if declaration_config else {},
256
- None,
257
- )
258
- elif function_name == "Toolset":
259
- # Extract config from Toolset {...}
260
- declaration_config = self._extract_single_table_arg(function_call)
261
- self.builder.register_toolset(
262
- assignment_target_name,
263
- declaration_config if declaration_config else {},
264
- )
265
- elif function_name == "Context":
266
- declaration_config = self._extract_single_table_arg(function_call)
267
- self.builder.register_context(
268
- assignment_target_name,
269
- declaration_config if declaration_config else {},
270
- )
271
- elif function_name == "Corpus":
272
- declaration_config = self._extract_single_table_arg(function_call)
273
- self.builder.register_corpus(
274
- assignment_target_name,
275
- declaration_config if declaration_config else {},
276
- )
277
- elif function_name == "Retriever":
278
- declaration_config = self._extract_single_table_arg(function_call)
202
+ if function_name == "Agent" and is_chained_method_call:
203
+ return
204
+ handled = self._register_assignment_based_declaration(
205
+ assignment_target_name, function_name, function_call
206
+ )
207
+ if handled:
208
+ return
209
+ declaration_config = self._extract_single_table_arg(function_call)
210
+ if isinstance(declaration_config, dict) and "corpus" in declaration_config:
279
211
  retriever_id = self._resolve_retriever_id_from_call(function_call)
280
- if (
281
- isinstance(declaration_config, dict)
282
- and retriever_id
283
- and "retriever_id" not in declaration_config
284
- ):
212
+ if retriever_id and "retriever_id" not in declaration_config:
285
213
  declaration_config["retriever_id"] = retriever_id
286
214
  self.builder.register_retriever(
287
215
  assignment_target_name,
288
- declaration_config if declaration_config else {},
289
- )
290
- elif function_name == "Compactor":
291
- declaration_config = self._extract_single_table_arg(function_call)
292
- self.builder.register_compactor(
293
- assignment_target_name,
294
- declaration_config if declaration_config else {},
295
- )
296
- elif function_name == "Procedure":
297
- # New assignment syntax: main = Procedure { function(input) ... }
298
- # Register as a named procedure
299
- self.builder.register_named_procedure(
300
- assignment_target_name,
301
- None, # Function not available during validation
302
- {}, # Input schema will be extracted from top-level input {}
303
- {}, # Output schema will be extracted from top-level output {}
304
- {}, # State schema
216
+ declaration_config,
305
217
  )
306
- elif function_name == "Task":
307
- # Assignment-based task declaration: name = Task { ... }
308
- self._processed_task_calls.add(id(function_call))
309
- self._register_task_declaration(assignment_target_name, function_call)
310
- else:
311
- # Heuristic: treat any assignment-based call with a 'corpus' field as a retriever.
312
- declaration_config = self._extract_single_table_arg(function_call)
313
- if isinstance(declaration_config, dict) and "corpus" in declaration_config:
314
- retriever_id = self._resolve_retriever_id_from_call(function_call)
315
- if retriever_id and "retriever_id" not in declaration_config:
316
- declaration_config["retriever_id"] = retriever_id
317
- self.builder.register_retriever(
318
- assignment_target_name,
319
- declaration_config,
320
- )
218
+
219
+ def _apply_setting_assignment(self, assignment_target_name: str, expression_list) -> bool:
220
+ setting_handlers_by_name = {
221
+ "default_provider": self.builder.set_default_provider,
222
+ "default_model": self.builder.set_default_model,
223
+ "return_prompt": self.builder.set_return_prompt,
224
+ "error_prompt": self.builder.set_error_prompt,
225
+ "status_prompt": self.builder.set_status_prompt,
226
+ "async": self.builder.set_async,
227
+ "max_depth": self.builder.set_max_depth,
228
+ "max_turns": self.builder.set_max_turns,
229
+ }
230
+ if assignment_target_name not in setting_handlers_by_name:
231
+ return False
232
+ first_expression = self._extract_first_expression(expression_list)
233
+ if not first_expression:
234
+ return True
235
+ literal_value = self._extract_literal_value(first_expression)
236
+ setting_handlers_by_name[assignment_target_name](literal_value)
237
+ return True
238
+
239
+ def _extract_first_expression(self, expression_list):
240
+ exp_list = getattr(expression_list, "exp", None)
241
+ if not callable(exp_list):
242
+ return None
243
+ expressions = exp_list()
244
+ if expressions:
245
+ return expressions[0]
246
+ return None
247
+
248
+ def _register_assignment_based_declaration(
249
+ self,
250
+ assignment_target_name: str,
251
+ function_name: str,
252
+ function_call,
253
+ ) -> bool:
254
+ declaration_config = self._extract_single_table_arg(function_call)
255
+ normalized_config = declaration_config if declaration_config else {}
256
+ if function_name == "Agent":
257
+ if "tools" in normalized_config:
258
+ tool_name_list = normalized_config["tools"]
259
+ if isinstance(tool_name_list, list):
260
+ normalized_config["tools"] = [
261
+ tool_name for tool_name in tool_name_list if tool_name is not None
262
+ ]
263
+ self.builder.register_agent(assignment_target_name, normalized_config, None)
264
+ return True
265
+ if function_name == "Tool":
266
+ if (
267
+ isinstance(normalized_config, dict)
268
+ and isinstance(normalized_config.get("name"), str)
269
+ and normalized_config.get("name") != assignment_target_name
270
+ ):
271
+ self._record_error(
272
+ message=(
273
+ f"Tool name mismatch: '{assignment_target_name} = Tool {{ name = \"{normalized_config.get('name')}\" }}'. "
274
+ f"Remove the 'name' field or set it to '{assignment_target_name}'."
275
+ ),
276
+ declaration="Tool",
277
+ )
278
+ self.builder.register_tool(assignment_target_name, normalized_config, None)
279
+ return True
280
+ if function_name == "Toolset":
281
+ self.builder.register_toolset(assignment_target_name, normalized_config)
282
+ return True
283
+ if function_name == "Context":
284
+ self.builder.register_context(assignment_target_name, normalized_config)
285
+ return True
286
+ if function_name == "Corpus":
287
+ self.builder.register_corpus(assignment_target_name, normalized_config)
288
+ return True
289
+ if function_name == "Retriever":
290
+ retriever_id = self._resolve_retriever_id_from_call(function_call)
291
+ if retriever_id and "retriever_id" not in normalized_config:
292
+ normalized_config["retriever_id"] = retriever_id
293
+ self.builder.register_retriever(assignment_target_name, normalized_config)
294
+ return True
295
+ if function_name == "Compactor":
296
+ self.builder.register_compactor(assignment_target_name, normalized_config)
297
+ return True
298
+ if function_name == "Procedure":
299
+ self.builder.register_named_procedure(
300
+ assignment_target_name,
301
+ None,
302
+ {},
303
+ {},
304
+ {},
305
+ )
306
+ return True
307
+ if function_name == "Task":
308
+ self._processed_task_calls.add(id(function_call))
309
+ self._register_task_declaration(assignment_target_name, function_call)
310
+ return True
311
+ return False
321
312
 
322
313
  def _extract_single_table_arg(self, function_call) -> dict:
323
314
  """Extract a single table argument from a function call like Agent {...}."""
@@ -1122,23 +1113,23 @@ class TactusDSLVisitor(LuaParserVisitor):
1122
1113
  return token_text[2:-2]
1123
1114
  elif token_text.startswith('"') and token_text.endswith('"'):
1124
1115
  # Double-quoted string
1125
- content = token_text[1:-1]
1126
- content = content.replace("\\n", "\n")
1127
- content = content.replace("\\t", "\t")
1128
- content = content.replace('\\"', '"')
1129
- content = content.replace("\\\\", "\\")
1130
- return content
1116
+ return self._unescape_basic_string(token_text[1:-1], '"')
1131
1117
  elif token_text.startswith("'") and token_text.endswith("'"):
1132
1118
  # Single-quoted string
1133
- content = token_text[1:-1]
1134
- content = content.replace("\\n", "\n")
1135
- content = content.replace("\\t", "\t")
1136
- content = content.replace("\\'", "'")
1137
- content = content.replace("\\\\", "\\")
1138
- return content
1119
+ return self._unescape_basic_string(token_text[1:-1], "'")
1139
1120
 
1140
1121
  return token_text
1141
1122
 
1123
+ def _unescape_basic_string(self, content: str, quote_char: str) -> str:
1124
+ content = content.replace("\\n", "\n")
1125
+ content = content.replace("\\t", "\t")
1126
+ if quote_char == '"':
1127
+ content = content.replace('\\"', '"')
1128
+ elif quote_char == "'":
1129
+ content = content.replace("\\'", "'")
1130
+ content = content.replace("\\\\", "\\")
1131
+ return content
1132
+
1142
1133
  def _parse_table_constructor(self, ctx: LuaParser.TableconstructorContext) -> Any:
1143
1134
  """Parse Lua table constructor to Python dict."""
1144
1135
  parsed_table = {}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tactus
3
- Version: 0.38.0
3
+ Version: 0.40.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=g5cqIhFTf2v9_3EjAdhFDjXJWMvCrtSaYVs7B7LpU7M,1245
1
+ tactus/__init__.py,sha256=kZIgMAqEdibTGcY5NARvHI3BnbR8fsjBkiGdbDZ2JBg,1245
2
2
  tactus/adapters/__init__.py,sha256=47Y8kGBR4QGxqEGvjA1mneOSACb2L7oELnj6P2uI7uk,759
3
3
  tactus/adapters/broker_log.py,sha256=9ZR-rJdyW6bMNZx3OfXoQEnDxcAzNsiJ8aPxZGqJYrM,6019
4
4
  tactus/adapters/cli_hitl.py,sha256=Nrfoi35Ei9fTMReLG2QxKkhKyIvl3pYcAUdQCAUOZDk,17361
@@ -29,7 +29,7 @@ tactus/broker/protocol.py,sha256=v4DFSVoecerqxbqK-vbRfYEAD10tk-QXNH_d9PFgkWg,534
29
29
  tactus/broker/server.py,sha256=s0_Uokovf5s-IR8Ieb3r1h9dnt4eO_PT0aycwuHwhks,56236
30
30
  tactus/broker/stdio.py,sha256=JXkEz-PCU3IQXNkt16YJtYmwkR43eS6CfjxAHc-YCfQ,439
31
31
  tactus/cli/__init__.py,sha256=kVhdCkwWEPdt3vn9si-iKvh6M9817aOH6rLSsNzRuyg,80
32
- tactus/cli/app.py,sha256=-w6iY0rjbTn3TCV9vTIrUp7wV2WdHmeWa16ZgD7yEC4,102181
32
+ tactus/cli/app.py,sha256=I_89_mis8JFnfJ6yLNFA_PmQrmd79Ci3XZ0Pc0-6MDU,103242
33
33
  tactus/cli/control.py,sha256=jCKKy8f6x8wV5im-MwxOtgz85oYLTHhPKXx-3FtRwoU,13364
34
34
  tactus/cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
35
35
  tactus/core/__init__.py,sha256=TK5rWr3HmOO_igFa5ESGp6teWwS58vnvQhIWqkcgqwk,880
@@ -37,7 +37,7 @@ tactus/core/compaction.py,sha256=mVwFsEb9FEc-bMPFcKgXyAO-pVnAXeQGZka7RWtjVsY,397
37
37
  tactus/core/config_manager.py,sha256=kxz853j4Nx97SBh8-fAar_OfmfWZvvcfafLyxjTQG1A,35131
38
38
  tactus/core/context_assembler.py,sha256=tbd-XACBvAFkBlPrzAAZ_L-2JGnO4meS-GL4ilE7XHw,2406
39
39
  tactus/core/context_models.py,sha256=YHKjHKEzLk3fgpkmXYQ9RWDPKRNStt92ve40xcMv3p0,975
40
- tactus/core/dsl_stubs.py,sha256=3yg7yWcflDPOOzbLG7VmHh7yZPXf1G_q6sdYt5DWPOw,109777
40
+ tactus/core/dsl_stubs.py,sha256=-bTopHqUq6B_CSsnVkeNK7uy-WbJD8n3B4LOCpXGcXM,110492
41
41
  tactus/core/exceptions.py,sha256=r-4IrZw_WrioBkpMR42Q3LNwEqimJ6jxXgfOo2wANTM,1962
42
42
  tactus/core/execution_context.py,sha256=OgTe9E0xc3nTQbCTEaaBfI11dVA1-J0eFz0eQR4XMZY,29402
43
43
  tactus/core/lua_sandbox.py,sha256=Ln2P1gdxVl396HLvEw7FmDKV3eVdVdbDzYHMbDSEciY,19106
@@ -47,7 +47,7 @@ tactus/core/output_validator.py,sha256=LcSjgAiDRvzsj2uWasQihengQRt7R-ZYaPiLQPbZy
47
47
  tactus/core/registry.py,sha256=z5HFoi1zb81-TJ_6qeTWSte4J0Zw-NYmN19VjjBjJE8,26577
48
48
  tactus/core/retrieval.py,sha256=AMDa4X4YWaSDNdf3T3hRUzgM4W1l_sGqxAX5Jki5PH0,12083
49
49
  tactus/core/retriever_tasks.py,sha256=i2wsoZN9cZOypkdPAJKqlrRP_jwtqpHVCFoxPL0Iirk,931
50
- tactus/core/runtime.py,sha256=kwDM88Ell2RkOkEbwmI5v92fQOLeruod-dpbCTq6hM0,155443
50
+ tactus/core/runtime.py,sha256=_egKpvp1qSB0q1zTdcjXOeaLZIEB2oQWoiFxih9K_yw,168471
51
51
  tactus/core/template_resolver.py,sha256=r97KzFNaK4nFSoWtIFZeSKyuUWgbp-ay1_BGrb-BgUY,4179
52
52
  tactus/core/yaml_parser.py,sha256=JD7Nehaxw3uP1KV_uTU_xiXTbEWqoKOceU5tAJ4lcH8,13985
53
53
  tactus/core/dependencies/__init__.py,sha256=28-TM7_i-JqTD3hvkq1kMzr__A8VjfIKXymdW9mn5NM,362
@@ -76,6 +76,8 @@ tactus/ide/__init__.py,sha256=1fSC0xWP-Lq5wl4FgDq7SMnkvZ0DxXupreTl3ZRX1zw,143
76
76
  tactus/ide/coding_assistant.py,sha256=i2cfT6uMSM7TEFw-9p9Ed_BWSbAMqgokoaFdbjKAQcg,12187
77
77
  tactus/ide/config_server.py,sha256=U8OWxi5l24GH1lUHIAQ8WB8j0cJ5ofLX9iVecW1O2vc,18862
78
78
  tactus/ide/server.py,sha256=nE_UDiXJZN7G-RzPD-guZ_4qPxPl722qcrv4UY6bjII,111151
79
+ tactus/plugins/__init__.py,sha256=MOSTLvPnXeDHKNcGzTv26bRxsK62pVRmTjEwbuoaWHk,39
80
+ tactus/plugins/noaa.py,sha256=BTBxmbI55AFgDNB4ZhNR7NaKqJECRvRJRua4gahaSvU,2022
79
81
  tactus/primitives/__init__.py,sha256=x6bGwoa9DizKUwqsg7SqURfJxisEdctTCv1XnSAZxIk,1709
80
82
  tactus/primitives/control.py,sha256=jw-7ggHtNLfFL5aTUUs6Fo5y4xsxEG8OIRe0RyIjVnc,4783
81
83
  tactus/primitives/file.py,sha256=GFHmXOADRllfJw6gHpIdVMmZ_ZS7DVgresQ0F71nqJE,7458
@@ -122,7 +124,7 @@ tactus/stdlib/README.md,sha256=AqKYj7JxtphfKJlhq_sNezIUPfblzzubzdxE3PheiQE,2576
122
124
  tactus/stdlib/__init__.py,sha256=NkRsL413VXr0rLAadbb3meP5TelwcrEFVJd1u39XCbk,1047
123
125
  tactus/stdlib/loader.py,sha256=qjVnz5mn3Uu7g1O4vjSREHkR-YdRoON1vqJQq-oiFIE,8679
124
126
  tactus/stdlib/biblicus/__init__.py,sha256=Y6Nb-wp33KeDtkjBccZrGYlyR98yhZBm1RfmiKIJHm8,50
125
- tactus/stdlib/biblicus/text.py,sha256=KMbMlaGQsrAwXgCShoLs54xYDoLm-s0Ymob5zZOhOeY,6285
127
+ tactus/stdlib/biblicus/text.py,sha256=aL7FF5MmX0aXskGwSTD970kGP4C0bAGHm0vJe7MThPU,6255
126
128
  tactus/stdlib/classify/__init__.py,sha256=51Lqge0g0Q6GWXkmw42HwuqkkDCsww9VBcoreYId374,5623
127
129
  tactus/stdlib/classify/classify.spec.tac,sha256=0yuRD_2dbPKTuhyqwk3vtsj_R3kwGoSEiEF4OY-ARqA,6475
128
130
  tactus/stdlib/classify/classify.tac,sha256=KvOXLihspPK1_g2GcT9wnLkynDubglp1S_JUfZlo-88,6850
@@ -210,7 +212,7 @@ tactus/validation/LuaParserBase.py,sha256=o3klCIY0ANkVCU0VHml0IOYE4CdEledeoyoIAP
210
212
  tactus/validation/README.md,sha256=AS6vr4blY7IKWRsj4wuvWBHVMTc5fto7IgNmv-Rjkdo,5366
211
213
  tactus/validation/__init__.py,sha256=rnap-YvNievWigYYUewuXBcLtAdjZ8YpeJDYS1T7XZM,153
212
214
  tactus/validation/error_listener.py,sha256=MPkvsVbojwYjNA8MpapG_GNtR6ZDyb3cTt7aLwekCtM,1010
213
- tactus/validation/semantic_visitor.py,sha256=frs7zc66rQkAKtPwshMSXYxXyqyYxEV9TokOiRY5lxo,57135
215
+ tactus/validation/semantic_visitor.py,sha256=YLX54qXpGNrJSANr15UfbGmxT017vGE10dBtBZTphIQ,55149
214
216
  tactus/validation/validator.py,sha256=JSQHmI3EkNjJsPxrztTszJGuNuHyyaDdRsV62I4P0QU,11911
215
217
  tactus/validation/generated/LuaLexer.interp,sha256=B-Xb6HNXS7YYYQB_cvsWzf8OQLFnEhZHDN5vCOyP3yw,20444
216
218
  tactus/validation/generated/LuaLexer.py,sha256=6B-HNB_vAp3bA5iACLvMWw0R4KFENsuiG7bccysxbRQ,67252
@@ -224,8 +226,8 @@ tactus/validation/generated/LuaParserVisitor.py,sha256=ageKSmHPxnO3jBS2fBtkmYBOd
224
226
  tactus/validation/generated/__init__.py,sha256=5gWlwRI0UvmHw2fnBpj_IG6N8oZeabr5tbj1AODDvjc,196
225
227
  tactus/validation/grammar/LuaLexer.g4,sha256=t2MXiTCr127RWAyQGvamkcU_m4veqPzSuHUtAKwalw4,2771
226
228
  tactus/validation/grammar/LuaParser.g4,sha256=ceZenb90BdiZmVdOxMGj9qJk3QbbWVZe5HUqPgoePfY,3202
227
- tactus-0.38.0.dist-info/METADATA,sha256=aWNJ9IXllKc79gX9ed4X-MSGqjGSiEtGT8xTdYHQzjA,60383
228
- tactus-0.38.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
229
- tactus-0.38.0.dist-info/entry_points.txt,sha256=vWseqty8m3z-Worje0IYxlioMjPDCoSsm0AtY4GghBY,47
230
- tactus-0.38.0.dist-info/licenses/LICENSE,sha256=ivohBcAIYnaLPQ-lKEeCXSMvQUVISpQfKyxHBHoa4GA,1066
231
- tactus-0.38.0.dist-info/RECORD,,
229
+ tactus-0.40.0.dist-info/METADATA,sha256=ZMyLh83LARAXlKw67who3Yfbgkyw5_4ZCJ0PAO3gSTI,60383
230
+ tactus-0.40.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
231
+ tactus-0.40.0.dist-info/entry_points.txt,sha256=vWseqty8m3z-Worje0IYxlioMjPDCoSsm0AtY4GghBY,47
232
+ tactus-0.40.0.dist-info/licenses/LICENSE,sha256=ivohBcAIYnaLPQ-lKEeCXSMvQUVISpQfKyxHBHoa4GA,1066
233
+ tactus-0.40.0.dist-info/RECORD,,