tactus 0.36.0__py3-none-any.whl → 0.38.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 (65) hide show
  1. tactus/__init__.py +1 -1
  2. tactus/adapters/channels/base.py +22 -2
  3. tactus/adapters/channels/broker.py +1 -0
  4. tactus/adapters/channels/host.py +3 -1
  5. tactus/adapters/channels/ipc.py +18 -3
  6. tactus/adapters/channels/sse.py +2 -0
  7. tactus/adapters/mcp_manager.py +24 -7
  8. tactus/backends/http_backend.py +2 -2
  9. tactus/backends/pytorch_backend.py +2 -2
  10. tactus/broker/client.py +3 -3
  11. tactus/broker/server.py +17 -5
  12. tactus/cli/app.py +212 -57
  13. tactus/core/compaction.py +17 -0
  14. tactus/core/context_assembler.py +73 -0
  15. tactus/core/context_models.py +41 -0
  16. tactus/core/dsl_stubs.py +560 -20
  17. tactus/core/exceptions.py +8 -0
  18. tactus/core/execution_context.py +24 -24
  19. tactus/core/message_history_manager.py +2 -2
  20. tactus/core/mocking.py +12 -0
  21. tactus/core/output_validator.py +6 -6
  22. tactus/core/registry.py +171 -29
  23. tactus/core/retrieval.py +317 -0
  24. tactus/core/retriever_tasks.py +30 -0
  25. tactus/core/runtime.py +431 -117
  26. tactus/dspy/agent.py +143 -82
  27. tactus/dspy/broker_lm.py +13 -7
  28. tactus/dspy/config.py +23 -4
  29. tactus/dspy/module.py +12 -1
  30. tactus/ide/coding_assistant.py +2 -2
  31. tactus/primitives/handles.py +79 -7
  32. tactus/primitives/model.py +1 -1
  33. tactus/primitives/procedure.py +1 -1
  34. tactus/primitives/state.py +2 -2
  35. tactus/sandbox/config.py +1 -1
  36. tactus/sandbox/container_runner.py +13 -6
  37. tactus/sandbox/entrypoint.py +51 -8
  38. tactus/sandbox/protocol.py +5 -0
  39. tactus/stdlib/README.md +10 -1
  40. tactus/stdlib/biblicus/__init__.py +3 -0
  41. tactus/stdlib/biblicus/text.py +189 -0
  42. tactus/stdlib/tac/biblicus/text.tac +32 -0
  43. tactus/stdlib/tac/tactus/biblicus.spec.tac +179 -0
  44. tactus/stdlib/tac/tactus/corpora/base.tac +42 -0
  45. tactus/stdlib/tac/tactus/corpora/filesystem.tac +5 -0
  46. tactus/stdlib/tac/tactus/retrievers/base.tac +37 -0
  47. tactus/stdlib/tac/tactus/retrievers/embedding_index_file.tac +6 -0
  48. tactus/stdlib/tac/tactus/retrievers/embedding_index_inmemory.tac +6 -0
  49. tactus/stdlib/tac/tactus/retrievers/index.md +137 -0
  50. tactus/stdlib/tac/tactus/retrievers/init.tac +11 -0
  51. tactus/stdlib/tac/tactus/retrievers/sqlite_full_text_search.tac +6 -0
  52. tactus/stdlib/tac/tactus/retrievers/tf_vector.tac +6 -0
  53. tactus/testing/behave_integration.py +2 -0
  54. tactus/testing/context.py +10 -6
  55. tactus/testing/evaluation_runner.py +5 -5
  56. tactus/testing/steps/builtin.py +2 -2
  57. tactus/testing/test_runner.py +6 -4
  58. tactus/utils/asyncio_helpers.py +2 -1
  59. tactus/validation/semantic_visitor.py +357 -6
  60. tactus/validation/validator.py +142 -2
  61. {tactus-0.36.0.dist-info → tactus-0.38.0.dist-info}/METADATA +9 -6
  62. {tactus-0.36.0.dist-info → tactus-0.38.0.dist-info}/RECORD +65 -47
  63. {tactus-0.36.0.dist-info → tactus-0.38.0.dist-info}/WHEEL +0 -0
  64. {tactus-0.36.0.dist-info → tactus-0.38.0.dist-info}/entry_points.txt +0 -0
  65. {tactus-0.36.0.dist-info → tactus-0.38.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 typing import Any
16
+ from pathlib import Path
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
@@ -68,19 +69,19 @@ class TactusRuntime:
68
69
  def __init__(
69
70
  self,
70
71
  procedure_id: str,
71
- storage_backend: StorageBackend | None = None,
72
- hitl_handler: HITLHandler | None = None,
73
- chat_recorder: ChatRecorder | None = None,
72
+ storage_backend: Optional[StorageBackend] = None,
73
+ hitl_handler: Optional[HITLHandler] = None,
74
+ chat_recorder: Optional[ChatRecorder] = None,
74
75
  mcp_server=None,
75
- mcp_servers: dict[str, Any] | None = None,
76
- openai_api_key: str | None = None,
76
+ mcp_servers: Optional[Dict[str, Any]] = None,
77
+ openai_api_key: Optional[str] = None,
77
78
  log_handler=None,
78
- tool_primitive: ToolPrimitive | None = None,
79
+ tool_primitive: Optional[ToolPrimitive] = None,
79
80
  recursion_depth: int = 0,
80
- tool_paths: list[str] | None = None,
81
- external_config: dict[str, Any] | None = None,
82
- run_id: str | None = None,
83
- source_file_path: str | None = None,
81
+ tool_paths: Optional[List[str]] = None,
82
+ external_config: Optional[Dict[str, Any]] = None,
83
+ run_id: Optional[str] = None,
84
+ source_file_path: Optional[str] = None,
84
85
  ):
85
86
  """
86
87
  Initialize the Tactus runtime.
@@ -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 {}
@@ -144,31 +146,31 @@ class TactusRuntime:
144
146
  self.source_file_path = source_file_path
145
147
 
146
148
  # Will be initialized during setup
147
- self.config: dict[str, Any] | None = None # Legacy YAML support
148
- self.registry: ProcedureRegistry | None = None # New DSL registry
149
- self.lua_sandbox: LuaSandbox | None = None
150
- self.output_validator: OutputValidator | None = None
151
- self.template_resolver: TemplateResolver | None = None
152
- self.message_history_manager: MessageHistoryManager | None = None
149
+ self.config: Optional[Dict[str, Any]] = None # Legacy YAML support
150
+ self.registry: Optional[ProcedureRegistry] = None # New DSL registry
151
+ self.lua_sandbox: Optional[LuaSandbox] = None
152
+ self.output_validator: Optional[OutputValidator] = None
153
+ self.template_resolver: Optional[TemplateResolver] = None
154
+ self.message_history_manager: Optional[MessageHistoryManager] = None
153
155
 
154
156
  # Execution context
155
- self.execution_context: BaseExecutionContext | None = None
157
+ self.execution_context: Optional[BaseExecutionContext] = None
156
158
 
157
159
  # Primitives (shared across all agents)
158
- self.state_primitive: StatePrimitive | None = None
159
- self.iterations_primitive: IterationsPrimitive | None = None
160
- self.stop_primitive: StopPrimitive | None = None
161
- self.tool_primitive: ToolPrimitive | None = None
162
- self.human_primitive: HumanPrimitive | None = None
163
- self.step_primitive: StepPrimitive | None = None
164
- self.checkpoint_primitive: CheckpointPrimitive | None = None
165
- self.log_primitive: LogPrimitive | None = None
166
- self.json_primitive: JsonPrimitive | None = None
167
- self.retry_primitive: RetryPrimitive | None = None
168
- self.file_primitive: FilePrimitive | None = None
169
- self.procedure_primitive: ProcedurePrimitive | None = None
170
- self.system_primitive: SystemPrimitive | None = None
171
- self.host_primitive: HostPrimitive | None = None
160
+ self.state_primitive: Optional[StatePrimitive] = None
161
+ self.iterations_primitive: Optional[IterationsPrimitive] = None
162
+ self.stop_primitive: Optional[StopPrimitive] = None
163
+ self.tool_primitive: Optional[ToolPrimitive] = None
164
+ self.human_primitive: Optional[HumanPrimitive] = None
165
+ self.step_primitive: Optional[StepPrimitive] = None
166
+ self.checkpoint_primitive: Optional[CheckpointPrimitive] = None
167
+ self.log_primitive: Optional[LogPrimitive] = None
168
+ self.json_primitive: Optional[JsonPrimitive] = None
169
+ self.retry_primitive: Optional[RetryPrimitive] = None
170
+ self.file_primitive: Optional[FilePrimitive] = None
171
+ self.procedure_primitive: Optional[ProcedurePrimitive] = None
172
+ self.system_primitive: Optional[SystemPrimitive] = None
173
+ self.host_primitive: Optional[HostPrimitive] = None
172
174
 
173
175
  # Agent primitives (one per agent)
174
176
  self.agents: dict[str, Any] = {}
@@ -181,17 +183,21 @@ class TactusRuntime:
181
183
 
182
184
  # User dependencies (HTTP clients, DB connections, etc.)
183
185
  self.user_dependencies: dict[str, Any] = {}
184
- self.dependency_manager: Any | None = None # ResourceManager for cleanup
186
+ self.dependency_manager: Optional[Any] = None # ResourceManager for cleanup
185
187
 
186
188
  # Mock manager for testing
187
- self.mock_manager: Any | None = None # MockManager instance
188
- self.external_agent_mocks: dict[str, list[dict[str, Any]]] | None = None
189
+ self.mock_manager: Optional[Any] = None # MockManager instance
190
+ self.external_agent_mocks: Optional[Dict[str, List[Dict[str, Any]]]] = None
189
191
  self.mock_all_agents: bool = False
190
192
 
191
193
  logger.info("TactusRuntime initialized for procedure %s", procedure_id)
192
194
 
193
195
  async def execute(
194
- self, source: str, context: dict[str, Any] | None = 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:
@@ -772,7 +800,7 @@ class TactusRuntime:
772
800
  except Exception as e:
773
801
  logger.warning("Error cleaning up dependencies: %s", e)
774
802
 
775
- def _resolve_sandbox_base_path(self) -> str | None:
803
+ def _resolve_sandbox_base_path(self) -> Optional[str]:
776
804
  # Compute base_path for sandbox from source file path if available.
777
805
  # This ensures require() works correctly even when running from different directories.
778
806
  if not self.source_file_path:
@@ -798,7 +826,7 @@ class TactusRuntime:
798
826
 
799
827
  async def _initialize_primitives(
800
828
  self,
801
- placeholder_tool: ToolPrimitive | None = None,
829
+ placeholder_tool: Optional[ToolPrimitive] = None,
802
830
  ):
803
831
  """Initialize all primitive objects.
804
832
 
@@ -836,7 +864,7 @@ class TactusRuntime:
836
864
 
837
865
  logger.debug("All primitives initialized")
838
866
 
839
- def resolve_toolset(self, name: str) -> Any | None:
867
+ def resolve_toolset(self, name: str) -> Optional[Any]:
840
868
  """
841
869
  Resolve a toolset by name from runtime's registered toolsets.
842
870
 
@@ -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())}"
@@ -1047,7 +1038,7 @@ class TactusRuntime:
1047
1038
  for name, toolset in self.toolset_registry.items():
1048
1039
  logger.debug(f" - {name}: {type(toolset)} -> {toolset}")
1049
1040
 
1050
- async def _resolve_tool_source(self, tool_name: str, source: str) -> Any | None:
1041
+ async def _resolve_tool_source(self, tool_name: str, source: str) -> Optional[Any]:
1051
1042
  """
1052
1043
  Resolve a tool from an external source.
1053
1044
 
@@ -1442,7 +1433,7 @@ class TactusRuntime:
1442
1433
 
1443
1434
  async def _create_toolset_from_config(
1444
1435
  self, name: str, definition: dict[str, Any]
1445
- ) -> Any | None:
1436
+ ) -> Optional[Any]:
1446
1437
  """
1447
1438
  Create toolset from YAML config definition.
1448
1439
 
@@ -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
 
@@ -2194,7 +2175,7 @@ class TactusRuntime:
2194
2175
  if is_required:
2195
2176
  fields[field_name] = (field_type, ...) # Required field
2196
2177
  else:
2197
- fields[field_name] = (field_type | None, None) # Optional field
2178
+ fields[field_name] = (Optional[field_type], None) # Optional field
2198
2179
 
2199
2180
  return create_model(model_name, **fields)
2200
2181
 
@@ -2516,6 +2497,57 @@ 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
+
2503
+ def _flatten_tasks(task_map: dict, prefix: str = "") -> list[str]:
2504
+ names: list[str] = []
2505
+ for task_name, task in task_map.items():
2506
+ full_name = f"{prefix}{task_name}" if not prefix else f"{prefix}:{task_name}"
2507
+ names.append(full_name)
2508
+ if task.children:
2509
+ names.extend(_flatten_tasks(task.children, full_name))
2510
+ return names
2511
+
2512
+ implicit_tasks: list[str] = []
2513
+ retrievers = getattr(self.registry, "retrievers", {}) or {}
2514
+ if retrievers:
2515
+ from tactus.core.retriever_tasks import (
2516
+ resolve_retriever_id,
2517
+ supported_retriever_tasks,
2518
+ )
2519
+
2520
+ for retriever_name, retriever in retrievers.items():
2521
+ config = getattr(retriever, "config", {})
2522
+ retriever_id = resolve_retriever_id(config if isinstance(config, dict) else {})
2523
+ for task in sorted(supported_retriever_tasks(retriever_id)):
2524
+ if task in explicit_tasks:
2525
+ continue
2526
+ if task not in implicit_tasks:
2527
+ implicit_tasks.append(task)
2528
+ implicit_tasks.append(f"{task}:{retriever_name}")
2529
+
2530
+ if explicit_tasks:
2531
+ if len(explicit_tasks) == 1:
2532
+ self.task_name = next(iter(explicit_tasks.keys()))
2533
+ elif "run" in explicit_tasks:
2534
+ self.task_name = "run"
2535
+ else:
2536
+ from tactus.core.exceptions import TaskSelectionRequired
2537
+
2538
+ raise TaskSelectionRequired(_flatten_tasks(explicit_tasks) + implicit_tasks)
2539
+ elif (
2540
+ implicit_tasks
2541
+ and not getattr(self, "_top_level_result", None)
2542
+ and not getattr(self.registry, "named_procedures", None)
2543
+ ):
2544
+ from tactus.core.exceptions import TaskSelectionRequired
2545
+
2546
+ raise TaskSelectionRequired(implicit_tasks)
2547
+
2548
+ if self.task_name:
2549
+ return self._execute_task(self.task_name)
2550
+
2519
2551
  if self.registry:
2520
2552
  # Check for named 'main' procedure first
2521
2553
  if "main" in self.registry.named_procedures:
@@ -2604,6 +2636,198 @@ class TactusRuntime:
2604
2636
  logger.error(f"Legacy procedure execution failed: {e}")
2605
2637
  raise
2606
2638
 
2639
+ def _execute_task(self, task_name: str) -> Any:
2640
+ if not self.registry:
2641
+ raise RuntimeError("No registry available for task execution")
2642
+
2643
+ task = self._resolve_task(task_name)
2644
+ if task is None:
2645
+ # Allow run fallback to main procedure
2646
+ if task_name == "run":
2647
+ self.task_name = None
2648
+ return self._execute_workflow()
2649
+
2650
+ retriever_tasks = self._resolve_retriever_task_targets(task_name)
2651
+ if retriever_tasks:
2652
+ return self._execute_retriever_tasks(task_name, retriever_tasks)
2653
+
2654
+ raise RuntimeError(f"Task '{task_name}' not found")
2655
+
2656
+ task_payload = task.model_dump()
2657
+ entry = task_payload.get("entry")
2658
+ if entry is None:
2659
+ if task.children:
2660
+ raise RuntimeError(
2661
+ f"Task '{task_name}' has no entry. Available sub-tasks: "
2662
+ f"{', '.join(task.children.keys())}"
2663
+ )
2664
+ raise RuntimeError(f"Task '{task_name}' has no entry")
2665
+
2666
+ if not callable(entry):
2667
+ raise RuntimeError(f"Task '{task_name}' entry must be a function")
2668
+ return entry()
2669
+
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}'")
2673
+
2674
+ base_task = task_name.split(":")[0]
2675
+ if base_task == "index":
2676
+ results = []
2677
+ for retriever_name in retriever_names:
2678
+ results.append(self._execute_retriever_index(retriever_name))
2679
+ return results[0] if len(results) == 1 else results
2680
+
2681
+ raise RuntimeError(f"Retriever task '{base_task}' is not supported")
2682
+
2683
+ def _execute_retriever_index(self, retriever_name: str) -> Any:
2684
+ if not self.registry:
2685
+ raise RuntimeError("No registry available for retriever execution")
2686
+
2687
+ retriever = self.registry.retrievers.get(retriever_name)
2688
+ if retriever is None:
2689
+ raise RuntimeError(f"Retriever '{retriever_name}' not found")
2690
+
2691
+ if not retriever.corpus:
2692
+ raise RuntimeError(f"Retriever '{retriever_name}' has no corpus configured")
2693
+
2694
+ corpus_decl = self.registry.corpora.get(retriever.corpus)
2695
+ if corpus_decl is None:
2696
+ raise RuntimeError(f"Corpus '{retriever.corpus}' not found for '{retriever_name}'")
2697
+
2698
+ corpus_root = corpus_decl.config.get("corpus_root") or corpus_decl.config.get("root")
2699
+ if not corpus_root:
2700
+ raise RuntimeError(f"Corpus '{retriever.corpus}' is missing a root path")
2701
+
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"
2717
+ )
2718
+ if not retriever_id:
2719
+ raise RuntimeError(
2720
+ f"Retriever '{retriever_name}' is missing retriever_id; cannot build snapshot"
2721
+ )
2722
+
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 = {}
2730
+
2731
+ try:
2732
+ from biblicus.corpus import Corpus
2733
+ from biblicus.extraction import build_extraction_snapshot
2734
+ from biblicus.retrievers import get_retriever
2735
+ 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
2756
+ )
2757
+
2758
+ return snapshot.model_dump()
2759
+
2760
+ def _resolve_task(self, task_name: str) -> Optional[TaskDeclaration]:
2761
+ if not self.registry:
2762
+ return None
2763
+ segments = [segment for segment in task_name.split(":") if segment]
2764
+ if not segments:
2765
+ return None
2766
+ current = self.registry.tasks.get(segments[0])
2767
+ for segment in segments[1:]:
2768
+ if current is None:
2769
+ return None
2770
+ child = current.children.get(segment)
2771
+ if child is None:
2772
+ payload = current.model_dump(exclude={"name", "children"})
2773
+ inline_child = payload.get(segment)
2774
+ if isinstance(inline_child, dict):
2775
+ task_payload = dict(inline_child)
2776
+ task_payload["name"] = segment
2777
+ try:
2778
+ return TaskDeclaration(**task_payload)
2779
+ except Exception:
2780
+ return None
2781
+ return None
2782
+ current = child
2783
+ return current
2784
+
2785
+ def _resolve_retriever_task_targets(self, task_name: str) -> list[str]:
2786
+ if not self.registry or not self.registry.retrievers:
2787
+ return []
2788
+ segments = [segment for segment in task_name.split(":") if segment]
2789
+ if not segments:
2790
+ return []
2791
+ task = segments[0]
2792
+ target = segments[1] if len(segments) > 1 else None
2793
+ from tactus.core.retriever_tasks import resolve_retriever_id, supported_retriever_tasks
2794
+
2795
+ targets: list[str] = []
2796
+ for retriever_name, retriever in self.registry.retrievers.items():
2797
+ config = getattr(retriever, "config", {})
2798
+ retriever_id = resolve_retriever_id(config if isinstance(config, dict) else {})
2799
+ if task in supported_retriever_tasks(retriever_id):
2800
+ targets.append(retriever_name)
2801
+
2802
+ if target:
2803
+ return [name for name in targets if name == target]
2804
+ return targets
2805
+
2806
+ def _expand_inline_task_children(self, registry: ProcedureRegistry) -> None:
2807
+ if not registry or not registry.tasks:
2808
+ return
2809
+
2810
+ def add_children(parent: TaskDeclaration) -> None:
2811
+ payload = parent.model_dump(exclude={"name", "children"})
2812
+ for key, value in payload.items():
2813
+ if not isinstance(key, str) or not isinstance(value, dict):
2814
+ continue
2815
+ if not value.get("__tactus_task_config"):
2816
+ continue
2817
+ if key in parent.children:
2818
+ continue
2819
+ task_payload = dict(value)
2820
+ task_payload["name"] = key
2821
+ try:
2822
+ child = TaskDeclaration(**task_payload)
2823
+ except Exception:
2824
+ continue
2825
+ parent.children[key] = child
2826
+ add_children(child)
2827
+
2828
+ for task in registry.tasks.values():
2829
+ add_children(task)
2830
+
2607
2831
  def _maybe_transform_script_mode_source(self, source: str) -> str:
2608
2832
  """
2609
2833
  Transform "script mode" source into an implicit Procedure wrapper.
@@ -2652,11 +2876,12 @@ class TactusRuntime:
2652
2876
  in_body = False
2653
2877
  brace_depth = 0
2654
2878
  function_depth = 0 # Track function...end blocks
2655
- long_string_eq: str | None = None
2879
+ long_string_eq: Optional[str] = None
2656
2880
 
2657
2881
  decl_start = re.compile(
2658
2882
  r"^\s*(?:"
2659
2883
  r"input|output|Mocks|Agent|Toolset|Tool|Model|Module|Signature|LM|Dependency|Prompt|"
2884
+ r"Task|IncludeTasks|Context|Corpus|Retriever|Compactor|"
2660
2885
  r"Specifications|Evaluation|Evaluations|"
2661
2886
  r"default_provider|default_model|return_prompt|error_prompt|status_prompt|async|"
2662
2887
  r"max_depth|max_turns"
@@ -2665,7 +2890,9 @@ class TactusRuntime:
2665
2890
  require_stmt = re.compile(r"^\s*(?:local\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*require\(")
2666
2891
  assignment_decl = re.compile(
2667
2892
  r"^\s*[A-Za-z_][A-Za-z0-9_]*\s*=\s*(?:"
2668
- r"Agent|Toolset|Tool|Model|Module|Signature|LM|Dependency|Prompt"
2893
+ r"Agent|Toolset|Tool|Model|Module|Signature|LM|Dependency|Prompt|"
2894
+ 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"
2669
2896
  r")\b"
2670
2897
  )
2671
2898
  # Match function definitions: function name() or local function name()
@@ -2871,7 +3098,7 @@ class TactusRuntime:
2871
3098
  return False
2872
3099
 
2873
3100
  def _parse_declarations(
2874
- self, source: str, tool_primitive: ToolPrimitive | None = None
3101
+ self, source: str, tool_primitive: Optional[ToolPrimitive] = None
2875
3102
  ) -> ProcedureRegistry:
2876
3103
  """
2877
3104
  Execute .tac to collect declarations.
@@ -2940,6 +3167,93 @@ class TactusRuntime:
2940
3167
  except LuaSandboxError as e:
2941
3168
  raise TactusRuntimeError(f"Failed to parse DSL: {e}")
2942
3169
 
3170
+ self._expand_inline_task_children(builder.registry)
3171
+
3172
+ # Execute IncludeTasks files to register additional tasks
3173
+ if builder.registry.include_tasks:
3174
+ base_path = Path(self.source_file_path).parent if self.source_file_path else Path.cwd()
3175
+ include_queue = [
3176
+ {
3177
+ "path": include.get("path"),
3178
+ "namespace": include.get("namespace"),
3179
+ "base": base_path,
3180
+ }
3181
+ for include in builder.registry.include_tasks
3182
+ ]
3183
+ seen_includes: set[Path] = set()
3184
+
3185
+ while include_queue:
3186
+ include = include_queue.pop(0)
3187
+ include_path = include.get("path")
3188
+ if not include_path:
3189
+ continue
3190
+ include_base = include.get("base") or base_path
3191
+ include_file = (include_base / include_path).resolve()
3192
+ if include_file in seen_includes:
3193
+ raise TactusRuntimeError(f"IncludeTasks cycle detected: {include_file}")
3194
+ seen_includes.add(include_file)
3195
+ if not include_file.exists():
3196
+ raise TactusRuntimeError(f"Included tasks file not found: {include_file}")
3197
+
3198
+ pre_task_names = set(builder.registry.tasks.keys())
3199
+ pre_include_count = len(builder.registry.include_tasks)
3200
+ pre_counts = {
3201
+ "agents": len(builder.registry.agents),
3202
+ "toolsets": len(builder.registry.toolsets),
3203
+ "lua_tools": len(builder.registry.lua_tools),
3204
+ "contexts": len(builder.registry.contexts),
3205
+ "corpora": len(builder.registry.corpora),
3206
+ "retrievers": len(builder.registry.retrievers),
3207
+ "compactors": len(builder.registry.compactors),
3208
+ "named_procedures": len(builder.registry.named_procedures),
3209
+ }
3210
+ include_source = include_file.read_text()
3211
+ try:
3212
+ sandbox.execute(include_source)
3213
+ except LuaSandboxError as e:
3214
+ raise TactusRuntimeError(f"Failed to execute IncludeTasks file: {e}")
3215
+
3216
+ post_counts = {
3217
+ "agents": len(builder.registry.agents),
3218
+ "toolsets": len(builder.registry.toolsets),
3219
+ "lua_tools": len(builder.registry.lua_tools),
3220
+ "contexts": len(builder.registry.contexts),
3221
+ "corpora": len(builder.registry.corpora),
3222
+ "retrievers": len(builder.registry.retrievers),
3223
+ "compactors": len(builder.registry.compactors),
3224
+ "named_procedures": len(builder.registry.named_procedures),
3225
+ }
3226
+ if any(post_counts[key] != pre_counts[key] for key in post_counts):
3227
+ raise TactusRuntimeError(
3228
+ f"IncludeTasks files must only contain Task declarations: {include_file}"
3229
+ )
3230
+
3231
+ new_task_names = set(builder.registry.tasks.keys()) - pre_task_names
3232
+ namespace = include.get("namespace")
3233
+ if namespace and new_task_names:
3234
+ if namespace in builder.registry.tasks:
3235
+ raise TactusRuntimeError(f"Duplicate task namespace '{namespace}'")
3236
+ namespaced_children = {
3237
+ name: builder.registry.tasks.pop(name) for name in new_task_names
3238
+ }
3239
+ builder.registry.tasks[namespace] = TaskDeclaration(
3240
+ name=namespace,
3241
+ children=namespaced_children,
3242
+ )
3243
+
3244
+ new_includes = builder.registry.include_tasks[pre_include_count:]
3245
+ if new_includes:
3246
+ include_queue.extend(
3247
+ [
3248
+ {
3249
+ "path": nested.get("path"),
3250
+ "namespace": nested.get("namespace"),
3251
+ "base": include_file.parent,
3252
+ }
3253
+ for nested in new_includes
3254
+ ]
3255
+ )
3256
+
2943
3257
  # Auto-register plain function main() if it exists
2944
3258
  #
2945
3259
  # Some .tac files use plain Lua syntax: `function main() ... end`