tactus 0.37.0__py3-none-any.whl → 0.39.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. tactus/__init__.py +1 -1
  2. tactus/adapters/channels/base.py +2 -0
  3. tactus/cli/app.py +212 -57
  4. tactus/core/compaction.py +17 -0
  5. tactus/core/context_assembler.py +73 -0
  6. tactus/core/context_models.py +41 -0
  7. tactus/core/dsl_stubs.py +568 -17
  8. tactus/core/exceptions.py +8 -0
  9. tactus/core/execution_context.py +1 -1
  10. tactus/core/mocking.py +12 -0
  11. tactus/core/registry.py +142 -0
  12. tactus/core/retrieval.py +317 -0
  13. tactus/core/retriever_tasks.py +30 -0
  14. tactus/core/runtime.py +441 -75
  15. tactus/dspy/agent.py +143 -82
  16. tactus/dspy/config.py +16 -0
  17. tactus/dspy/module.py +12 -1
  18. tactus/ide/coding_assistant.py +2 -2
  19. tactus/plugins/__init__.py +3 -0
  20. tactus/plugins/noaa.py +76 -0
  21. tactus/primitives/handles.py +79 -7
  22. tactus/sandbox/config.py +1 -1
  23. tactus/sandbox/container_runner.py +2 -0
  24. tactus/sandbox/entrypoint.py +51 -8
  25. tactus/sandbox/protocol.py +5 -0
  26. tactus/stdlib/README.md +10 -1
  27. tactus/stdlib/biblicus/__init__.py +3 -0
  28. tactus/stdlib/biblicus/text.py +208 -0
  29. tactus/stdlib/tac/biblicus/text.tac +32 -0
  30. tactus/stdlib/tac/tactus/biblicus.spec.tac +179 -0
  31. tactus/stdlib/tac/tactus/corpora/base.tac +42 -0
  32. tactus/stdlib/tac/tactus/corpora/filesystem.tac +5 -0
  33. tactus/stdlib/tac/tactus/retrievers/base.tac +37 -0
  34. tactus/stdlib/tac/tactus/retrievers/embedding_index_file.tac +6 -0
  35. tactus/stdlib/tac/tactus/retrievers/embedding_index_inmemory.tac +6 -0
  36. tactus/stdlib/tac/tactus/retrievers/index.md +137 -0
  37. tactus/stdlib/tac/tactus/retrievers/init.tac +11 -0
  38. tactus/stdlib/tac/tactus/retrievers/sqlite_full_text_search.tac +6 -0
  39. tactus/stdlib/tac/tactus/retrievers/tf_vector.tac +6 -0
  40. tactus/testing/behave_integration.py +2 -0
  41. tactus/testing/context.py +4 -0
  42. tactus/validation/semantic_visitor.py +430 -88
  43. tactus/validation/validator.py +142 -2
  44. {tactus-0.37.0.dist-info → tactus-0.39.0.dist-info}/METADATA +3 -2
  45. {tactus-0.37.0.dist-info → tactus-0.39.0.dist-info}/RECORD +48 -28
  46. {tactus-0.37.0.dist-info → tactus-0.39.0.dist-info}/WHEEL +0 -0
  47. {tactus-0.37.0.dist-info → tactus-0.39.0.dist-info}/entry_points.txt +0 -0
  48. {tactus-0.37.0.dist-info → tactus-0.39.0.dist-info}/licenses/LICENSE +0 -0
tactus/core/runtime.py CHANGED
@@ -13,9 +13,10 @@ import io
13
13
  import logging
14
14
  import time
15
15
  import uuid
16
+ from pathlib import Path
16
17
  from typing import Any, Dict, List, Optional
17
18
 
18
- from tactus.core.registry import ProcedureRegistry, RegistryBuilder
19
+ from tactus.core.registry import ProcedureRegistry, RegistryBuilder, TaskDeclaration
19
20
  from tactus.core.dsl_stubs import create_dsl_stubs, lua_table_to_dict
20
21
  from tactus.core.template_resolver import TemplateResolver
21
22
  from tactus.core.message_history_manager import MessageHistoryManager
@@ -137,6 +138,7 @@ class TactusRuntime:
137
138
  self.openai_api_key = openai_api_key
138
139
  self.log_handler = log_handler
139
140
  self._injected_tool_primitive = tool_primitive
141
+ self.task_name: Optional[str] = None
140
142
  self.tool_paths = tool_paths or []
141
143
  self.recursion_depth = recursion_depth
142
144
  self.external_config = external_config or {}
@@ -191,7 +193,11 @@ class TactusRuntime:
191
193
  logger.info("TactusRuntime initialized for procedure %s", procedure_id)
192
194
 
193
195
  async def execute(
194
- self, source: str, context: Optional[Dict[str, Any]] = None, format: str = "yaml"
196
+ self,
197
+ source: str,
198
+ context: Optional[Dict[str, Any]] = None,
199
+ format: str = "yaml",
200
+ task_name: Optional[str] = None,
195
201
  ) -> dict[str, Any]:
196
202
  """
197
203
  Execute a workflow (Lua DSL or legacy YAML format).
@@ -215,6 +221,7 @@ class TactusRuntime:
215
221
  """
216
222
  chat_session_id = None
217
223
  self.context = context or {} # Store context for param merging
224
+ self.task_name = task_name
218
225
 
219
226
  try:
220
227
  # 0. Setup Lua sandbox FIRST (needed for both YAML and Lua DSL)
@@ -353,10 +360,31 @@ class TactusRuntime:
353
360
  raise TactusRuntimeError(
354
361
  f"External agent mocks for '{agent_name}' must be a list of turns"
355
362
  )
356
- self.registry.agent_mocks[agent_name] = AgentMockConfig(
363
+ target_name = agent_name
364
+ if (
365
+ agent_name not in self.registry.agents
366
+ and len(self.registry.agents) == 1
367
+ ):
368
+ target_name = next(iter(self.registry.agents))
369
+ self.registry.agent_mocks[target_name] = AgentMockConfig(
357
370
  temporal=temporal_turns
358
371
  )
359
372
 
373
+ # If agent mocks exist but use a non-matching name and there is only one agent,
374
+ # remap the mock to the sole agent name.
375
+ if self.registry and self.registry.agent_mocks and len(self.registry.agents) == 1:
376
+ sole_agent = next(iter(self.registry.agents))
377
+ if sole_agent not in self.registry.agent_mocks:
378
+ unmatched = [
379
+ name
380
+ for name in self.registry.agent_mocks
381
+ if name not in self.registry.agents
382
+ ]
383
+ if len(unmatched) == 1:
384
+ self.registry.agent_mocks[sole_agent] = self.registry.agent_mocks.pop(
385
+ unmatched[0]
386
+ )
387
+
360
388
  # If we're in mocked mode, ensure agents are mocked deterministically even if
361
389
  # the .tac file doesn't declare `Mocks { ... }` for them.
362
390
  if self.mock_all_agents and self.registry:
@@ -978,66 +1006,29 @@ class TactusRuntime:
978
1006
  )
979
1007
 
980
1008
  # 6. Register DSL-defined toolsets from registry (after individual tools are registered)
981
- # DEBUG: Write to stderr which should show up in logs
982
- import sys
983
-
984
- sys.stderr.write("\n\n=== DSL TOOLSET REGISTRATION START ===\n")
985
- sys.stderr.write(f"Has registry: {hasattr(self, 'registry')}\n")
986
- if hasattr(self, "registry") and self.registry:
987
- sys.stderr.write("Registry is not None: True\n")
988
- sys.stderr.write(f"Registry has toolsets attr: {hasattr(self.registry, 'toolsets')}\n")
989
- if hasattr(self.registry, "toolsets"):
990
- sys.stderr.write(f"Registry toolsets: {list(self.registry.toolsets.keys())}\n")
991
- sys.stderr.write(f"Registry toolsets count: {len(self.registry.toolsets)}\n")
992
- else:
993
- sys.stderr.write("Registry is None or doesn't exist\n")
994
- sys.stderr.flush()
995
-
996
- logger.info("=== DSL TOOLSET REGISTRATION START ===")
997
- logger.info(f"Has registry: {hasattr(self, 'registry')}")
998
- logger.info(
999
- f"Registry is not None: {self.registry is not None if hasattr(self, 'registry') else False}"
1000
- )
1001
- if hasattr(self, "registry") and self.registry:
1002
- logger.info(f"Registry has toolsets attr: {hasattr(self.registry, 'toolsets')}")
1003
- if hasattr(self.registry, "toolsets"):
1004
- logger.info(f"Registry toolsets: {list(self.registry.toolsets.keys())}")
1005
- logger.info(f"Registry toolsets count: {len(self.registry.toolsets)}")
1006
-
1007
1009
  if hasattr(self, "registry") and self.registry and hasattr(self.registry, "toolsets"):
1008
- sys.stderr.write(f"Processing {len(self.registry.toolsets)} DSL toolsets\n")
1009
- sys.stderr.flush()
1010
- logger.info(f"Processing {len(self.registry.toolsets)} DSL toolsets")
1010
+ logger.debug(
1011
+ "Registering %s DSL toolset(s): %s",
1012
+ len(self.registry.toolsets),
1013
+ list(self.registry.toolsets.keys()),
1014
+ )
1011
1015
  for name, definition in self.registry.toolsets.items():
1012
- sys.stderr.write(
1013
- f"Creating DSL toolset '{name}' with config keys: {list(definition.keys())}\n"
1014
- )
1015
- sys.stderr.flush()
1016
- logger.info(
1017
- f"Creating DSL toolset '{name}' with config keys: {list(definition.keys())}"
1018
- )
1019
1016
  try:
1017
+ logger.debug(
1018
+ "Creating DSL toolset %r with config keys: %s",
1019
+ name,
1020
+ list(definition.keys()),
1021
+ )
1020
1022
  toolset = await self._create_toolset_from_config(name, definition)
1021
1023
  if toolset:
1022
1024
  self.toolset_registry[name] = toolset
1023
- sys.stderr.write(f"✓ Registered DSL-defined toolset '{name}'\n")
1024
- sys.stderr.flush()
1025
- logger.info(f"✓ Registered DSL-defined toolset '{name}'")
1026
1025
  else:
1027
- sys.stderr.write(f" Toolset '{name}' creation returned None\n")
1028
- sys.stderr.flush()
1029
- logger.error(f"✗ Toolset '{name}' creation returned None")
1030
- except Exception as e:
1031
- sys.stderr.write(f"✗ Failed to create DSL toolset '{name}': {e}\n")
1032
- sys.stderr.flush()
1033
- logger.error(f"✗ Failed to create DSL toolset '{name}': {e}", exc_info=True)
1026
+ logger.error("DSL toolset %r creation returned None", name)
1027
+ except Exception as exc:
1028
+ # Toolset creation failures should not crash the whole runtime initialization.
1029
+ logger.error("Failed to create DSL toolset %r: %s", name, exc, exc_info=True)
1034
1030
  else:
1035
- sys.stderr.write("No DSL toolsets to register (registry.toolsets not available)\n")
1036
- sys.stderr.flush()
1037
- logger.warning("No DSL toolsets to register (registry.toolsets not available)")
1038
- sys.stderr.write("=== DSL TOOLSET REGISTRATION END ===\n")
1039
- sys.stderr.flush()
1040
- logger.info("=== DSL TOOLSET REGISTRATION END ===")
1031
+ logger.debug("No DSL toolsets to register")
1041
1032
 
1042
1033
  logger.info(
1043
1034
  f"Toolset registry initialized with {len(self.toolset_registry)} toolset(s): {list(self.toolset_registry.keys())}"
@@ -1797,8 +1788,6 @@ class TactusRuntime:
1797
1788
  Args:
1798
1789
  context: Procedure context with pre-loaded data
1799
1790
  """
1800
- import sys # For debug output
1801
-
1802
1791
  logger.info(
1803
1792
  f"_setup_agents called. Toolset registry has {len(self.toolset_registry)} toolsets: {list(self.toolset_registry.keys())}"
1804
1793
  )
@@ -1980,20 +1969,14 @@ class TactusRuntime:
1980
1969
  logger.info(f"Agent '{agent_name}' has NO tools (explicitly empty - passing None)")
1981
1970
  else:
1982
1971
  # Parse toolset expressions
1983
- sys.stderr.write(
1984
- f"\n[AGENT_SETUP] Agent '{agent_name}' raw tools config: {agent_tools_config}\n"
1985
- )
1986
- sys.stderr.write(
1987
- f"[AGENT_SETUP] toolset_registry has: {list(self.toolset_registry.keys())}\n"
1972
+ logger.debug(
1973
+ "Agent %r raw tools config: %s (available toolsets: %s)",
1974
+ agent_name,
1975
+ agent_tools_config,
1976
+ list(self.toolset_registry.keys()),
1988
1977
  )
1989
- sys.stderr.flush()
1990
- logger.info(f"Agent '{agent_name}' raw tools config: {agent_tools_config}")
1991
1978
  filtered_toolsets = self._parse_toolset_expressions(agent_tools_config)
1992
- sys.stderr.write(
1993
- f"[AGENT_SETUP] Agent '{agent_name}' parsed toolsets: {filtered_toolsets}\n"
1994
- )
1995
- sys.stderr.flush()
1996
- logger.info(f"Agent '{agent_name}' parsed toolsets: {filtered_toolsets}")
1979
+ logger.debug("Agent %r parsed toolsets: %s", agent_name, filtered_toolsets)
1997
1980
 
1998
1981
  # Append inline tools toolset if present
1999
1982
  if inline_tools_toolset:
@@ -2065,6 +2048,7 @@ class TactusRuntime:
2065
2048
  "system_prompt": system_prompt_template,
2066
2049
  "model": model_name,
2067
2050
  "provider": agent_config.get("provider"),
2051
+ "context": agent_config.get("context"),
2068
2052
  "tools": filtered_tools,
2069
2053
  "toolsets": filtered_toolsets,
2070
2054
  "output_schema": output_schema,
@@ -2112,10 +2096,7 @@ class TactusRuntime:
2112
2096
  # The agent was created during parsing WITHOUT toolsets, now we update it
2113
2097
  # to the new agent that HAS toolsets
2114
2098
  self.lua_sandbox.lua.globals()[agent_name] = agent_primitive
2115
- sys.stderr.write(
2116
- f"[AGENT_FIX] Updated Lua global '{agent_name}' to new agent with toolsets\n"
2117
- )
2118
- sys.stderr.flush()
2099
+ logger.debug("Updated Lua global %r to new agent with toolsets", agent_name)
2119
2100
 
2120
2101
  logger.info(f"Agent '{agent_name}' configured successfully with model '{model_name}'")
2121
2102
 
@@ -2516,6 +2497,60 @@ class TactusRuntime:
2516
2497
  Returns:
2517
2498
  Result from Lua procedure execution
2518
2499
  """
2500
+ if not self.task_name and self.registry:
2501
+ explicit_tasks = getattr(self.registry, "tasks", {}) or {}
2502
+ named_procedures = getattr(self.registry, "named_procedures", {}) or {}
2503
+
2504
+ def _flatten_tasks(task_map: dict, prefix: str = "") -> list[str]:
2505
+ names: list[str] = []
2506
+ for task_name, task in task_map.items():
2507
+ full_name = f"{prefix}{task_name}" if not prefix else f"{prefix}:{task_name}"
2508
+ names.append(full_name)
2509
+ if task.children:
2510
+ names.extend(_flatten_tasks(task.children, full_name))
2511
+ return names
2512
+
2513
+ implicit_tasks: list[str] = []
2514
+ retrievers = getattr(self.registry, "retrievers", {}) or {}
2515
+ if retrievers:
2516
+ from tactus.core.retriever_tasks import (
2517
+ resolve_retriever_id,
2518
+ supported_retriever_tasks,
2519
+ )
2520
+
2521
+ for retriever_name, retriever in retrievers.items():
2522
+ config = getattr(retriever, "config", {})
2523
+ retriever_id = resolve_retriever_id(config if isinstance(config, dict) else {})
2524
+ for task in sorted(supported_retriever_tasks(retriever_id)):
2525
+ if task in explicit_tasks:
2526
+ continue
2527
+ if task not in implicit_tasks:
2528
+ implicit_tasks.append(task)
2529
+ implicit_tasks.append(f"{task}:{retriever_name}")
2530
+
2531
+ if explicit_tasks:
2532
+ if "run" in explicit_tasks:
2533
+ self.task_name = "run"
2534
+ elif "main" in named_procedures:
2535
+ self.task_name = None
2536
+ elif len(explicit_tasks) == 1:
2537
+ self.task_name = next(iter(explicit_tasks.keys()))
2538
+ else:
2539
+ from tactus.core.exceptions import TaskSelectionRequired
2540
+
2541
+ raise TaskSelectionRequired(_flatten_tasks(explicit_tasks) + implicit_tasks)
2542
+ elif (
2543
+ implicit_tasks
2544
+ and not getattr(self, "_top_level_result", None)
2545
+ and not getattr(self.registry, "named_procedures", None)
2546
+ ):
2547
+ from tactus.core.exceptions import TaskSelectionRequired
2548
+
2549
+ raise TaskSelectionRequired(implicit_tasks)
2550
+
2551
+ if self.task_name:
2552
+ return self._execute_task(self.task_name)
2553
+
2519
2554
  if self.registry:
2520
2555
  # Check for named 'main' procedure first
2521
2556
  if "main" in self.registry.named_procedures:
@@ -2604,6 +2639,198 @@ class TactusRuntime:
2604
2639
  logger.error(f"Legacy procedure execution failed: {e}")
2605
2640
  raise
2606
2641
 
2642
+ def _execute_task(self, task_name: str) -> Any:
2643
+ if not self.registry:
2644
+ raise RuntimeError("No registry available for task execution")
2645
+
2646
+ task = self._resolve_task(task_name)
2647
+ if task is None:
2648
+ # Allow run fallback to main procedure
2649
+ if task_name == "run":
2650
+ self.task_name = None
2651
+ return self._execute_workflow()
2652
+
2653
+ retriever_tasks = self._resolve_retriever_task_targets(task_name)
2654
+ if retriever_tasks:
2655
+ return self._execute_retriever_tasks(task_name, retriever_tasks)
2656
+
2657
+ raise RuntimeError(f"Task '{task_name}' not found")
2658
+
2659
+ task_payload = task.model_dump()
2660
+ entry = task_payload.get("entry")
2661
+ if entry is None:
2662
+ if task.children:
2663
+ raise RuntimeError(
2664
+ f"Task '{task_name}' has no entry. Available sub-tasks: "
2665
+ f"{', '.join(task.children.keys())}"
2666
+ )
2667
+ raise RuntimeError(f"Task '{task_name}' has no entry")
2668
+
2669
+ if not callable(entry):
2670
+ raise RuntimeError(f"Task '{task_name}' entry must be a function")
2671
+ return entry()
2672
+
2673
+ def _execute_retriever_tasks(self, task_name: str, retriever_names: list[str]) -> Any:
2674
+ if not retriever_names:
2675
+ raise RuntimeError(f"No retrievers available for task '{task_name}'")
2676
+
2677
+ base_task = task_name.split(":")[0]
2678
+ if base_task == "index":
2679
+ results = []
2680
+ for retriever_name in retriever_names:
2681
+ results.append(self._execute_retriever_index(retriever_name))
2682
+ return results[0] if len(results) == 1 else results
2683
+
2684
+ raise RuntimeError(f"Retriever task '{base_task}' is not supported")
2685
+
2686
+ def _execute_retriever_index(self, retriever_name: str) -> Any:
2687
+ if not self.registry:
2688
+ raise RuntimeError("No registry available for retriever execution")
2689
+
2690
+ retriever = self.registry.retrievers.get(retriever_name)
2691
+ if retriever is None:
2692
+ raise RuntimeError(f"Retriever '{retriever_name}' not found")
2693
+
2694
+ if not retriever.corpus:
2695
+ raise RuntimeError(f"Retriever '{retriever_name}' has no corpus configured")
2696
+
2697
+ corpus_decl = self.registry.corpora.get(retriever.corpus)
2698
+ if corpus_decl is None:
2699
+ raise RuntimeError(f"Corpus '{retriever.corpus}' not found for '{retriever_name}'")
2700
+
2701
+ corpus_root = corpus_decl.config.get("corpus_root") or corpus_decl.config.get("root")
2702
+ if not corpus_root:
2703
+ raise RuntimeError(f"Corpus '{retriever.corpus}' is missing a root path")
2704
+
2705
+ extraction_pipeline = {}
2706
+ corpus_configuration = (
2707
+ corpus_decl.config.get("configuration", {})
2708
+ if isinstance(corpus_decl.config, dict)
2709
+ else {}
2710
+ )
2711
+ if isinstance(corpus_configuration, dict):
2712
+ pipeline = corpus_configuration.get("pipeline", {}) or {}
2713
+ if isinstance(pipeline, dict):
2714
+ extraction_pipeline = pipeline.get("extract", {}) or {}
2715
+ if isinstance(extraction_pipeline, list):
2716
+ extraction_pipeline = {}
2717
+
2718
+ retriever_id = retriever.config.get("retriever_id") or retriever.config.get(
2719
+ "retriever_type"
2720
+ )
2721
+ if not retriever_id:
2722
+ raise RuntimeError(
2723
+ f"Retriever '{retriever_name}' is missing retriever_id; cannot build snapshot"
2724
+ )
2725
+
2726
+ configuration = retriever.config.get("configuration", {})
2727
+ pipeline = configuration.get("pipeline", {}) if isinstance(configuration, dict) else {}
2728
+ if not pipeline and isinstance(retriever.config.get("pipeline"), dict):
2729
+ pipeline = retriever.config.get("pipeline") or {}
2730
+ index_config = pipeline.get("index", {}) if isinstance(pipeline, dict) else {}
2731
+ if isinstance(index_config, list):
2732
+ index_config = {}
2733
+
2734
+ try:
2735
+ from biblicus.corpus import Corpus
2736
+ from biblicus.extraction import build_extraction_snapshot
2737
+ from biblicus.retrievers import get_retriever
2738
+ except Exception as exc:
2739
+ raise RuntimeError(f"Biblicus retrieval retriever unavailable: {exc}") from exc
2740
+
2741
+ corpus = Corpus(Path(corpus_root))
2742
+ retriever_impl = get_retriever(retriever_id)
2743
+ configuration_name = retriever_name
2744
+
2745
+ if extraction_pipeline and isinstance(extraction_pipeline, dict):
2746
+ extraction_manifest = build_extraction_snapshot(
2747
+ corpus,
2748
+ extractor_id="pipeline",
2749
+ configuration_name=f"{retriever.corpus or retriever_name}-extract",
2750
+ configuration=extraction_pipeline,
2751
+ )
2752
+ if isinstance(index_config, dict) and "extraction_snapshot" not in index_config:
2753
+ index_config["extraction_snapshot"] = (
2754
+ f"{extraction_manifest.configuration.extractor_id}:"
2755
+ f"{extraction_manifest.snapshot_id}"
2756
+ )
2757
+ snapshot = retriever_impl.build_snapshot(
2758
+ corpus, configuration_name=configuration_name, configuration=index_config
2759
+ )
2760
+
2761
+ return snapshot.model_dump()
2762
+
2763
+ def _resolve_task(self, task_name: str) -> Optional[TaskDeclaration]:
2764
+ if not self.registry:
2765
+ return None
2766
+ segments = [segment for segment in task_name.split(":") if segment]
2767
+ if not segments:
2768
+ return None
2769
+ current = self.registry.tasks.get(segments[0])
2770
+ for segment in segments[1:]:
2771
+ if current is None:
2772
+ return None
2773
+ child = current.children.get(segment)
2774
+ if child is None:
2775
+ payload = current.model_dump(exclude={"name", "children"})
2776
+ inline_child = payload.get(segment)
2777
+ if isinstance(inline_child, dict):
2778
+ task_payload = dict(inline_child)
2779
+ task_payload["name"] = segment
2780
+ try:
2781
+ return TaskDeclaration(**task_payload)
2782
+ except Exception:
2783
+ return None
2784
+ return None
2785
+ current = child
2786
+ return current
2787
+
2788
+ def _resolve_retriever_task_targets(self, task_name: str) -> list[str]:
2789
+ if not self.registry or not self.registry.retrievers:
2790
+ return []
2791
+ segments = [segment for segment in task_name.split(":") if segment]
2792
+ if not segments:
2793
+ return []
2794
+ task = segments[0]
2795
+ target = segments[1] if len(segments) > 1 else None
2796
+ from tactus.core.retriever_tasks import resolve_retriever_id, supported_retriever_tasks
2797
+
2798
+ targets: list[str] = []
2799
+ for retriever_name, retriever in self.registry.retrievers.items():
2800
+ config = getattr(retriever, "config", {})
2801
+ retriever_id = resolve_retriever_id(config if isinstance(config, dict) else {})
2802
+ if task in supported_retriever_tasks(retriever_id):
2803
+ targets.append(retriever_name)
2804
+
2805
+ if target:
2806
+ return [name for name in targets if name == target]
2807
+ return targets
2808
+
2809
+ def _expand_inline_task_children(self, registry: ProcedureRegistry) -> None:
2810
+ if not registry or not registry.tasks:
2811
+ return
2812
+
2813
+ def add_children(parent: TaskDeclaration) -> None:
2814
+ payload = parent.model_dump(exclude={"name", "children"})
2815
+ for key, value in payload.items():
2816
+ if not isinstance(key, str) or not isinstance(value, dict):
2817
+ continue
2818
+ if not value.get("__tactus_task_config"):
2819
+ continue
2820
+ if key in parent.children:
2821
+ continue
2822
+ task_payload = dict(value)
2823
+ task_payload["name"] = key
2824
+ try:
2825
+ child = TaskDeclaration(**task_payload)
2826
+ except Exception:
2827
+ continue
2828
+ parent.children[key] = child
2829
+ add_children(child)
2830
+
2831
+ for task in registry.tasks.values():
2832
+ add_children(task)
2833
+
2607
2834
  def _maybe_transform_script_mode_source(self, source: str) -> str:
2608
2835
  """
2609
2836
  Transform "script mode" source into an implicit Procedure wrapper.
@@ -2657,6 +2884,7 @@ class TactusRuntime:
2657
2884
  decl_start = re.compile(
2658
2885
  r"^\s*(?:"
2659
2886
  r"input|output|Mocks|Agent|Toolset|Tool|Model|Module|Signature|LM|Dependency|Prompt|"
2887
+ r"Task|IncludeTasks|Context|Corpus|Retriever|Compactor|"
2660
2888
  r"Specifications|Evaluation|Evaluations|"
2661
2889
  r"default_provider|default_model|return_prompt|error_prompt|status_prompt|async|"
2662
2890
  r"max_depth|max_turns"
@@ -2665,7 +2893,13 @@ class TactusRuntime:
2665
2893
  require_stmt = re.compile(r"^\s*(?:local\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*require\(")
2666
2894
  assignment_decl = re.compile(
2667
2895
  r"^\s*[A-Za-z_][A-Za-z0-9_]*\s*=\s*(?:"
2668
- r"Agent|Toolset|Tool|Model|Module|Signature|LM|Dependency|Prompt"
2896
+ r"Agent|Toolset|Tool|Model|Module|Signature|LM|Dependency|Prompt|"
2897
+ r"Task|TaskFunction|Context|Corpus|Retriever|Compactor|function|"
2898
+ r"[A-Za-z_][A-Za-z0-9_]*Retriever|"
2899
+ r"[A-Za-z_][A-Za-z0-9_]*\.Retriever|"
2900
+ r"[A-Za-z_][A-Za-z0-9_]*\.Corpus|"
2901
+ r"[A-Za-z_][A-Za-z0-9_]*\.Context|"
2902
+ r"[A-Za-z_][A-Za-z0-9_]*\.Compactor"
2669
2903
  r")\b"
2670
2904
  )
2671
2905
  # Match function definitions: function name() or local function name()
@@ -2940,6 +3174,96 @@ class TactusRuntime:
2940
3174
  except LuaSandboxError as e:
2941
3175
  raise TactusRuntimeError(f"Failed to parse DSL: {e}")
2942
3176
 
3177
+ lua_globals = sandbox.lua.globals()
3178
+ self._register_assignment_tasks(builder, lua_globals)
3179
+
3180
+ self._expand_inline_task_children(builder.registry)
3181
+
3182
+ # Execute IncludeTasks files to register additional tasks
3183
+ if builder.registry.include_tasks:
3184
+ base_path = Path(self.source_file_path).parent if self.source_file_path else Path.cwd()
3185
+ include_queue = [
3186
+ {
3187
+ "path": include.get("path"),
3188
+ "namespace": include.get("namespace"),
3189
+ "base": base_path,
3190
+ }
3191
+ for include in builder.registry.include_tasks
3192
+ ]
3193
+ seen_includes: set[Path] = set()
3194
+
3195
+ while include_queue:
3196
+ include = include_queue.pop(0)
3197
+ include_path = include.get("path")
3198
+ if not include_path:
3199
+ continue
3200
+ include_base = include.get("base") or base_path
3201
+ include_file = (include_base / include_path).resolve()
3202
+ if include_file in seen_includes:
3203
+ raise TactusRuntimeError(f"IncludeTasks cycle detected: {include_file}")
3204
+ seen_includes.add(include_file)
3205
+ if not include_file.exists():
3206
+ raise TactusRuntimeError(f"Included tasks file not found: {include_file}")
3207
+
3208
+ pre_task_names = set(builder.registry.tasks.keys())
3209
+ pre_include_count = len(builder.registry.include_tasks)
3210
+ pre_counts = {
3211
+ "agents": len(builder.registry.agents),
3212
+ "toolsets": len(builder.registry.toolsets),
3213
+ "lua_tools": len(builder.registry.lua_tools),
3214
+ "contexts": len(builder.registry.contexts),
3215
+ "corpora": len(builder.registry.corpora),
3216
+ "retrievers": len(builder.registry.retrievers),
3217
+ "compactors": len(builder.registry.compactors),
3218
+ "named_procedures": len(builder.registry.named_procedures),
3219
+ }
3220
+ include_source = include_file.read_text()
3221
+ try:
3222
+ sandbox.execute(include_source)
3223
+ except LuaSandboxError as e:
3224
+ raise TactusRuntimeError(f"Failed to execute IncludeTasks file: {e}")
3225
+
3226
+ post_counts = {
3227
+ "agents": len(builder.registry.agents),
3228
+ "toolsets": len(builder.registry.toolsets),
3229
+ "lua_tools": len(builder.registry.lua_tools),
3230
+ "contexts": len(builder.registry.contexts),
3231
+ "corpora": len(builder.registry.corpora),
3232
+ "retrievers": len(builder.registry.retrievers),
3233
+ "compactors": len(builder.registry.compactors),
3234
+ "named_procedures": len(builder.registry.named_procedures),
3235
+ }
3236
+ if any(post_counts[key] != pre_counts[key] for key in post_counts):
3237
+ raise TactusRuntimeError(
3238
+ f"IncludeTasks files must only contain Task declarations: {include_file}"
3239
+ )
3240
+
3241
+ new_task_names = set(builder.registry.tasks.keys()) - pre_task_names
3242
+ namespace = include.get("namespace")
3243
+ if namespace and new_task_names:
3244
+ if namespace in builder.registry.tasks:
3245
+ raise TactusRuntimeError(f"Duplicate task namespace '{namespace}'")
3246
+ namespaced_children = {
3247
+ name: builder.registry.tasks.pop(name) for name in new_task_names
3248
+ }
3249
+ builder.registry.tasks[namespace] = TaskDeclaration(
3250
+ name=namespace,
3251
+ children=namespaced_children,
3252
+ )
3253
+
3254
+ new_includes = builder.registry.include_tasks[pre_include_count:]
3255
+ if new_includes:
3256
+ include_queue.extend(
3257
+ [
3258
+ {
3259
+ "path": nested.get("path"),
3260
+ "namespace": nested.get("namespace"),
3261
+ "base": include_file.parent,
3262
+ }
3263
+ for nested in new_includes
3264
+ ]
3265
+ )
3266
+
2943
3267
  # Auto-register plain function main() if it exists
2944
3268
  #
2945
3269
  # Some .tac files use plain Lua syntax: `function main() ... end`
@@ -2955,7 +3279,6 @@ class TactusRuntime:
2955
3279
  # The script mode transformation (in _maybe_transform_script_mode_source)
2956
3280
  # is designed to skip files with named function definitions to avoid wrapping
2957
3281
  # them incorrectly.
2958
- lua_globals = sandbox.lua.globals()
2959
3282
  if "main" in lua_globals:
2960
3283
  main_func = lua_globals["main"]
2961
3284
  # Check if it's a function and not already registered
@@ -2983,6 +3306,49 @@ class TactusRuntime:
2983
3306
  logger.debug(f"Registry after parsing: lua_tools={list(result.registry.lua_tools.keys())}")
2984
3307
  return result.registry
2985
3308
 
3309
+ def _register_assignment_tasks(self, builder: RegistryBuilder, lua_globals: Any) -> None:
3310
+ if not lua_globals:
3311
+ return
3312
+
3313
+ for key, value in lua_globals.items():
3314
+ if not isinstance(key, str):
3315
+ continue
3316
+ if not hasattr(value, "items"):
3317
+ continue
3318
+ try:
3319
+ marker = value["__tactus_task_config"]
3320
+ except Exception:
3321
+ continue
3322
+ if not marker:
3323
+ continue
3324
+ task_config = lua_table_to_dict(value)
3325
+ if not isinstance(task_config, dict):
3326
+ continue
3327
+ if key in builder.registry.tasks:
3328
+ continue
3329
+ if "entry" in task_config and not callable(task_config["entry"]):
3330
+ raise TactusRuntimeError(f"Task '{key}' entry must be a function")
3331
+ builder.register_task(key, task_config)
3332
+
3333
+ child_sources = task_config.get("__tactus_child_tasks")
3334
+ if isinstance(child_sources, dict):
3335
+ child_iter = child_sources.items()
3336
+ else:
3337
+ child_iter = value.items()
3338
+ for child_key, child_value in child_iter:
3339
+ child_name = child_key if isinstance(child_key, str) else None
3340
+ if not hasattr(child_value, "items"):
3341
+ continue
3342
+ child_config = lua_table_to_dict(child_value)
3343
+ if not child_name and isinstance(child_config, dict):
3344
+ child_name = child_config.get("__task_name")
3345
+ if child_name and isinstance(child_config, dict):
3346
+ if "entry" in child_config and not callable(child_config["entry"]):
3347
+ raise TactusRuntimeError(
3348
+ f"Task '{key}:{child_name}' entry must be a function"
3349
+ )
3350
+ builder.register_task(child_name, child_config, parent=key)
3351
+
2986
3352
  def _registry_to_config(self, registry: ProcedureRegistry) -> dict[str, Any]:
2987
3353
  """
2988
3354
  Convert registry to config dict format.