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
@@ -29,6 +29,8 @@ class TactusDSLVisitor(LuaParserVisitor):
29
29
  "Agent", # CamelCase
30
30
  "Model", # CamelCase
31
31
  "Procedure", # CamelCase
32
+ "Task", # CamelCase
33
+ "IncludeTasks", # CamelCase
32
34
  "Prompt", # CamelCase
33
35
  "Hitl", # CamelCase
34
36
  "Specification", # CamelCase
@@ -57,6 +59,9 @@ class TactusDSLVisitor(LuaParserVisitor):
57
59
  self.current_line = 0
58
60
  self.current_col = 0
59
61
  self.in_function_body = False # Track if we're inside a function body
62
+ self._processed_task_calls: set[int] = set()
63
+ self._retriever_aliases: dict[str, str] = {}
64
+ self._retriever_modules: dict[str, str] = {}
60
65
 
61
66
  def _record_error(
62
67
  self,
@@ -90,6 +95,27 @@ class TactusDSLVisitor(LuaParserVisitor):
90
95
  self.in_function_body = previous_in_function_body
91
96
  return child_visit_result
92
97
 
98
+ def visitChunk(self, context: LuaParser.ChunkContext):
99
+ """Enforce IncludeTasks file task-only constraint when used."""
100
+ result = self.visitChildren(context)
101
+ if self.builder.registry.include_tasks:
102
+ # If any non-task declarations exist, error out.
103
+ if (
104
+ self.builder.registry.agents
105
+ or self.builder.registry.toolsets
106
+ or self.builder.registry.lua_tools
107
+ or self.builder.registry.contexts
108
+ or self.builder.registry.corpora
109
+ or self.builder.registry.retrievers
110
+ or self.builder.registry.compactors
111
+ or self.builder.registry.named_procedures
112
+ ):
113
+ self._record_error(
114
+ "IncludeTasks files must only contain Task declarations.",
115
+ declaration="IncludeTasks",
116
+ )
117
+ return result
118
+
93
119
  def visitStat(self, context: LuaParser.StatContext):
94
120
  """Handle statement nodes including assignments."""
95
121
  # Check if this is an assignment statement
@@ -103,6 +129,7 @@ class TactusDSLVisitor(LuaParserVisitor):
103
129
  assignment_target_node = variable_list.var()[0]
104
130
  if assignment_target_node.NAME():
105
131
  assignment_target_name = assignment_target_node.NAME().getText()
132
+ self._track_retriever_alias(assignment_target_name, expression_list)
106
133
 
107
134
  # Check if this is a DSL setting assignment
108
135
  setting_handlers_by_name = {
@@ -131,6 +158,16 @@ class TactusDSLVisitor(LuaParserVisitor):
131
158
  assignment_target_name, first_expression
132
159
  )
133
160
 
161
+ attname_list = getattr(context, "attnamelist", None)
162
+ exp_list = getattr(context, "explist", None)
163
+ if callable(attname_list) and callable(exp_list):
164
+ attname_nodes = attname_list()
165
+ exp_nodes = exp_list()
166
+ if attname_nodes and exp_nodes:
167
+ name_nodes = attname_nodes.NAME()
168
+ if name_nodes:
169
+ self._track_retriever_alias(name_nodes[0].getText(), exp_nodes)
170
+
134
171
  # Continue visiting children
135
172
  return self.visitChildren(context)
136
173
 
@@ -144,12 +181,43 @@ class TactusDSLVisitor(LuaParserVisitor):
144
181
  if prefix_expression.functioncall():
145
182
  function_call = prefix_expression.functioncall()
146
183
  function_name = self._extract_function_name(function_call)
184
+ dotted_name = None
185
+ if function_name not in {
186
+ "Agent",
187
+ "Tool",
188
+ "Toolset",
189
+ "Context",
190
+ "Corpus",
191
+ "Retriever",
192
+ "Compactor",
193
+ }:
194
+ dotted_name = self._extract_dotted_dsl_name(function_call)
195
+ if dotted_name:
196
+ function_name = dotted_name
197
+ else:
198
+ dotted_name = self._extract_dotted_dsl_name(function_call)
199
+
200
+ # Check if this is a chained method call (e.g., Agent('name').turn()).
201
+ # Dotted module calls like vector.Corpus { ... } are not chained.
202
+ get_text = getattr(function_call, "getText", None)
203
+ function_call_text = get_text() if callable(get_text) else ""
204
+ is_chained_method_call = (
205
+ re.search(r"[)}\\]]\\s*\\.", function_call_text) is not None
206
+ )
147
207
 
148
- # Check if this is a chained method call (e.g., Agent('name').turn())
149
- # Chained calls have structure: func_name args . method_name args
150
- # Simple declarations have: func_name args or func_name table
151
- # If there are more than 2 children, it's a chained call, not a declaration
152
- is_chained_method_call = function_call.getChildCount() > 2
208
+ if function_name in {"Corpus", "Retriever"}:
209
+ has_namespace = dotted_name is not None or re.match(
210
+ r"[A-Za-z_][A-Za-z0-9_]*\\.", function_call_text
211
+ )
212
+ if not has_namespace:
213
+ self._record_error(
214
+ message=(
215
+ f"Direct {function_name} declarations are not supported. "
216
+ "Use module.Corpus { ... } instead."
217
+ ),
218
+ declaration=function_name,
219
+ )
220
+ return
153
221
 
154
222
  if function_name == "Agent" and not is_chained_method_call:
155
223
  # Extract config from Agent {...}
@@ -194,6 +262,37 @@ class TactusDSLVisitor(LuaParserVisitor):
194
262
  assignment_target_name,
195
263
  declaration_config if declaration_config else {},
196
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)
279
+ 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
+ ):
285
+ declaration_config["retriever_id"] = retriever_id
286
+ self.builder.register_retriever(
287
+ 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
+ )
197
296
  elif function_name == "Procedure":
198
297
  # New assignment syntax: main = Procedure { function(input) ... }
199
298
  # Register as a named procedure
@@ -204,10 +303,29 @@ class TactusDSLVisitor(LuaParserVisitor):
204
303
  {}, # Output schema will be extracted from top-level output {}
205
304
  {}, # State schema
206
305
  )
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
+ )
207
321
 
208
322
  def _extract_single_table_arg(self, function_call) -> dict:
209
323
  """Extract a single table argument from a function call like Agent {...}."""
210
- argument_list_nodes = function_call.args()
324
+ args_method = getattr(function_call, "args", None)
325
+ if args_method is None:
326
+ return {}
327
+
328
+ argument_list_nodes = args_method()
211
329
  if not argument_list_nodes:
212
330
  return {}
213
331
 
@@ -239,6 +357,14 @@ class TactusDSLVisitor(LuaParserVisitor):
239
357
  if function_name in self.DSL_FUNCTIONS and not is_method_access_call:
240
358
  # Process the DSL call (but skip method calls like Tool.called())
241
359
  try:
360
+ if function_name == "Task":
361
+ if id(context) in self._processed_task_calls:
362
+ return None
363
+ self._register_task_declaration(None, context)
364
+ return None
365
+ if function_name == "IncludeTasks":
366
+ self._register_include_tasks(context)
367
+ return None
242
368
  self._process_dsl_call(function_name, context)
243
369
  except Exception as processing_exception:
244
370
  self.errors.append(
@@ -254,6 +380,122 @@ class TactusDSLVisitor(LuaParserVisitor):
254
380
 
255
381
  return self.visitChildren(context)
256
382
 
383
+ def _register_task_declaration(
384
+ self,
385
+ assignment_name: Optional[str],
386
+ function_call_context: LuaParser.FunctioncallContext,
387
+ parent_task: Optional[str] = None,
388
+ ) -> None:
389
+ args = self._extract_arguments(function_call_context)
390
+ task_name = None
391
+ task_config: dict[str, Any] = {}
392
+
393
+ if args:
394
+ if isinstance(args[0], str):
395
+ task_name = args[0]
396
+ elif isinstance(args[0], dict):
397
+ task_config = args[0]
398
+
399
+ if len(args) >= 2 and isinstance(args[1], dict):
400
+ task_config = args[1]
401
+
402
+ if assignment_name and task_name and assignment_name != task_name:
403
+ self._record_error(
404
+ message=(
405
+ f"Task name mismatch: '{assignment_name} = Task \"{task_name}\" {{ ... }}'. "
406
+ f"Remove the string name or set it to '{assignment_name}'."
407
+ ),
408
+ declaration="Task",
409
+ )
410
+ task_name = assignment_name
411
+
412
+ if assignment_name and not task_name:
413
+ task_name = assignment_name
414
+
415
+ if not task_name:
416
+ self._record_error("Task name is required.", declaration="Task")
417
+ return
418
+
419
+ children = self._extract_nested_tasks(function_call_context)
420
+ for child in children:
421
+ child_name = child["name"]
422
+ self.builder.register_task(child_name, child.get("config", {}), parent=task_name)
423
+
424
+ self.builder.register_task(task_name, task_config, parent=parent_task)
425
+
426
+ def _extract_nested_tasks(
427
+ self, function_call_context: LuaParser.FunctioncallContext
428
+ ) -> list[dict[str, Any]]:
429
+ """Extract nested Task calls from a Task { ... } table constructor."""
430
+ nested_tasks: list[dict[str, Any]] = []
431
+ argument_list_nodes = function_call_context.args()
432
+ if not argument_list_nodes:
433
+ return nested_tasks
434
+
435
+ for args_ctx in argument_list_nodes:
436
+ table_ctx = args_ctx.tableconstructor()
437
+ if not table_ctx or not table_ctx.fieldlist():
438
+ continue
439
+ for field in table_ctx.fieldlist().field():
440
+ if len(field.exp()) != 1:
441
+ continue
442
+ child_exp = field.exp(0)
443
+ child_call = None
444
+ if hasattr(child_exp, "functioncall") and child_exp.functioncall():
445
+ child_call = child_exp.functioncall()
446
+ elif child_exp.prefixexp() and child_exp.prefixexp().functioncall():
447
+ child_call = child_exp.prefixexp().functioncall()
448
+ if not child_call:
449
+ continue
450
+ child_name = self._extract_function_name(child_call)
451
+ if child_name != "Task":
452
+ continue
453
+ child_args = self._extract_arguments(child_call)
454
+ child_task_name = None
455
+ if field.NAME():
456
+ child_task_name = field.NAME().getText()
457
+ if not child_task_name and child_args:
458
+ child_task_name = child_args[0] if isinstance(child_args[0], str) else None
459
+ child_task_config = {}
460
+ if len(child_args) >= 2 and isinstance(child_args[1], dict):
461
+ child_task_config = child_args[1]
462
+ if field.NAME() and child_args and isinstance(child_args[0], str):
463
+ if field.NAME().getText() != child_args[0]:
464
+ self._record_error(
465
+ message=(
466
+ f"Task name mismatch: '{field.NAME().getText()} = Task \"{child_args[0]}\" {{ ... }}'. "
467
+ f"Remove the string name or set it to '{field.NAME().getText()}'."
468
+ ),
469
+ declaration="Task",
470
+ )
471
+ if child_task_name:
472
+ nested_tasks.append(
473
+ {
474
+ "name": child_task_name,
475
+ "config": child_task_config,
476
+ }
477
+ )
478
+ return nested_tasks
479
+
480
+ def _register_include_tasks(self, context: LuaParser.FunctioncallContext) -> None:
481
+ args = self._extract_arguments(context)
482
+ if not args:
483
+ self._record_error("IncludeTasks requires a path string.", declaration="IncludeTasks")
484
+ return
485
+ if not isinstance(args[0], str):
486
+ self._record_error(
487
+ "IncludeTasks path must be a string literal.",
488
+ declaration="IncludeTasks",
489
+ )
490
+ return
491
+ namespace = None
492
+ if len(args) >= 2:
493
+ if isinstance(args[1], str):
494
+ namespace = args[1]
495
+ elif isinstance(args[1], dict):
496
+ namespace = args[1].get("namespace")
497
+ self.builder.register_include_tasks(args[0], namespace)
498
+
257
499
  def _check_deprecated_method_calls(self, ctx: LuaParser.FunctioncallContext):
258
500
  """Check for deprecated method calls like .turn() or .run()."""
259
501
  # Method calls have the form: varOrExp nameAndArgs+
@@ -373,6 +615,112 @@ class TactusDSLVisitor(LuaParserVisitor):
373
615
 
374
616
  return None
375
617
 
618
+ def _extract_dotted_dsl_name(
619
+ self, function_call_context: LuaParser.FunctioncallContext
620
+ ) -> Optional[str]:
621
+ """Extract DSL names from dotted calls like module.Corpus {...}."""
622
+ get_text = getattr(function_call_context, "getText", None)
623
+ if not callable(get_text):
624
+ return None
625
+ function_call_text = get_text()
626
+ match = re.match(
627
+ r"(?:[A-Za-z_][A-Za-z0-9_]*\.)+(Agent|Tool|Toolset|Context|Corpus|Retriever|Compactor)\b",
628
+ function_call_text,
629
+ )
630
+ if match:
631
+ return match.group(1)
632
+ return None
633
+
634
+ def _track_retriever_alias(self, assignment_name: str, expression_list) -> None:
635
+ """Track retriever constructor aliases from require(...) assignments."""
636
+ try:
637
+ expressions = expression_list.exp()
638
+ except Exception:
639
+ return
640
+ if not expressions:
641
+ return
642
+ expression_node = expressions[0]
643
+ get_text = getattr(expression_node, "getText", None)
644
+ if not callable(get_text):
645
+ return
646
+ expression_text = get_text()
647
+ match = re.match(
648
+ r'require\("tactus\.retrievers\.([A-Za-z0-9_]+)"\)(?:\.Retriever)?$',
649
+ expression_text,
650
+ )
651
+ if not match:
652
+ return
653
+ module_name = match.group(1)
654
+ retriever_id = module_name.replace("_", "-")
655
+ if expression_text.endswith(".Retriever"):
656
+ self._retriever_aliases[assignment_name] = retriever_id
657
+ else:
658
+ self._retriever_modules[assignment_name] = retriever_id
659
+
660
+ def _resolve_retriever_id_from_call(
661
+ self, function_call_context: LuaParser.FunctioncallContext
662
+ ) -> Optional[str]:
663
+ function_name = self._extract_function_name(function_call_context)
664
+ if function_name and function_name in self._retriever_aliases:
665
+ return self._retriever_aliases[function_name]
666
+ call_text = function_call_context.getText()
667
+ dotted_match = re.match(r"([A-Za-z_][A-Za-z0-9_]*)\.Retriever\b", call_text)
668
+ if dotted_match:
669
+ module_name = dotted_match.group(1)
670
+ return self._retriever_modules.get(module_name)
671
+ return None
672
+
673
+ def _parse_functioncall_expression(
674
+ self, function_call_context: LuaParser.FunctioncallContext
675
+ ) -> Any:
676
+ """Parse function calls used inside DSL expressions (e.g., context messages)."""
677
+ function_name = self._extract_function_name(function_call_context)
678
+ if not function_name:
679
+ return None
680
+
681
+ args = self._extract_arguments(function_call_context)
682
+
683
+ if function_name == "template":
684
+ template_text = args[0] if args else ""
685
+ vars_dict = args[1] if len(args) > 1 else {}
686
+ if not isinstance(template_text, str):
687
+ return None
688
+ if not isinstance(vars_dict, dict):
689
+ vars_dict = {}
690
+ return {"template": template_text, "vars": vars_dict}
691
+
692
+ if function_name in {"system", "user", "assistant"}:
693
+ if not args:
694
+ return None
695
+ arg = args[0]
696
+ if isinstance(arg, dict) and "template" in arg:
697
+ return {
698
+ "type": function_name,
699
+ "template": arg.get("template"),
700
+ "vars": arg.get("vars", {}),
701
+ }
702
+ if isinstance(arg, str):
703
+ return {"type": function_name, "content": arg}
704
+ return {"type": function_name, "content": str(arg)}
705
+
706
+ if function_name == "context":
707
+ if not args:
708
+ return None
709
+ pack_name = args[0]
710
+ budget = args[1] if len(args) > 1 else None
711
+ directive: dict[str, Any] = {
712
+ "type": "context",
713
+ "name": pack_name if isinstance(pack_name, str) else str(pack_name),
714
+ }
715
+ if isinstance(budget, dict):
716
+ directive["budget"] = budget
717
+ return directive
718
+
719
+ if function_name == "history":
720
+ return {"type": "history"}
721
+
722
+ return None
723
+
376
724
  def _process_dsl_call(self, function_name: str, ctx: LuaParser.FunctioncallContext):
377
725
  """Extract arguments and register declaration."""
378
726
  argument_values = self._extract_arguments(ctx)
@@ -742,6 +1090,9 @@ class TactusDSLVisitor(LuaParserVisitor):
742
1090
  return True
743
1091
  elif ctx.tableconstructor():
744
1092
  return self._parse_table_constructor(ctx.tableconstructor())
1093
+ function_call = getattr(ctx, "functioncall", None)
1094
+ if callable(function_call):
1095
+ return self._parse_functioncall_expression(function_call())
745
1096
 
746
1097
  # For other expressions, return None (can't evaluate without execution)
747
1098
  return None
@@ -9,6 +9,7 @@ Validates .tac files using ANTLR parser:
9
9
 
10
10
  import logging
11
11
  from enum import Enum
12
+ from pathlib import Path
12
13
  from typing import Optional
13
14
 
14
15
  from antlr4 import InputStream, CommonTokenStream
@@ -16,7 +17,7 @@ from .generated.LuaLexer import LuaLexer
16
17
  from .generated.LuaParser import LuaParser
17
18
  from .semantic_visitor import TactusDSLVisitor
18
19
  from .error_listener import TactusErrorListener
19
- from tactus.core.registry import ValidationResult, ValidationMessage
20
+ from tactus.core.registry import ValidationResult, ValidationMessage, TaskDeclaration
20
21
 
21
22
  logger = logging.getLogger(__name__)
22
23
 
@@ -175,7 +176,101 @@ class TactusValidator:
175
176
  try:
176
177
  with open(file_path, "r") as source_file_handle:
177
178
  source_text = source_file_handle.read()
178
- return self.validate(source_text, mode)
179
+ primary_result = self.validate(source_text, mode)
180
+ if not primary_result.valid or mode == ValidationMode.QUICK:
181
+ return primary_result
182
+ registry = primary_result.registry
183
+ if not registry or not registry.include_tasks:
184
+ return primary_result
185
+
186
+ base_path = Path(file_path).parent
187
+ merged_registry = registry
188
+ include_queue = [
189
+ {
190
+ "path": include.get("path"),
191
+ "namespace": include.get("namespace"),
192
+ "base": base_path,
193
+ }
194
+ for include in registry.include_tasks
195
+ ]
196
+ seen_includes: set[Path] = set()
197
+
198
+ while include_queue:
199
+ include = include_queue.pop(0)
200
+ include_path = include.get("path")
201
+ if not include_path:
202
+ continue
203
+ include_base = include.get("base") or base_path
204
+ include_file = (include_base / include_path).resolve()
205
+ if include_file in seen_includes:
206
+ return self._result_with_errors(
207
+ errors=[
208
+ ValidationMessage(
209
+ level="error",
210
+ message=f"IncludeTasks cycle detected: {include_file}",
211
+ )
212
+ ]
213
+ )
214
+ seen_includes.add(include_file)
215
+ if not include_file.exists():
216
+ return self._result_with_errors(
217
+ errors=[
218
+ ValidationMessage(
219
+ level="error",
220
+ message=f"Included tasks file not found: {include_file}",
221
+ )
222
+ ]
223
+ )
224
+ with open(include_file, "r") as include_handle:
225
+ include_source = include_handle.read()
226
+ include_result = self.validate(include_source, mode)
227
+ if not include_result.valid or not include_result.registry:
228
+ return include_result
229
+ if self._include_has_non_task_declarations(include_result.registry):
230
+ return self._result_with_errors(
231
+ errors=[
232
+ ValidationMessage(
233
+ level="error",
234
+ message=(
235
+ "IncludeTasks files must only contain Task declarations: "
236
+ f"{include_file}"
237
+ ),
238
+ )
239
+ ]
240
+ )
241
+ try:
242
+ self._merge_tasks(
243
+ merged_registry,
244
+ include_result.registry,
245
+ namespace=include.get("namespace"),
246
+ )
247
+ except ValueError as merge_error:
248
+ return self._result_with_errors(
249
+ errors=[
250
+ ValidationMessage(
251
+ level="error",
252
+ message=str(merge_error),
253
+ )
254
+ ]
255
+ )
256
+
257
+ if include_result.registry.include_tasks:
258
+ include_queue.extend(
259
+ [
260
+ {
261
+ "path": nested.get("path"),
262
+ "namespace": nested.get("namespace"),
263
+ "base": include_file.parent,
264
+ }
265
+ for nested in include_result.registry.include_tasks
266
+ ]
267
+ )
268
+
269
+ return self._result_success(
270
+ errors=primary_result.errors,
271
+ warnings=primary_result.warnings,
272
+ registry=merged_registry,
273
+ )
179
274
  except FileNotFoundError:
180
275
  return self._result_with_errors(
181
276
  errors=[
@@ -194,3 +289,48 @@ class TactusValidator:
194
289
  )
195
290
  ]
196
291
  )
292
+
293
+ def _include_has_non_task_declarations(self, registry) -> bool:
294
+ if registry is None:
295
+ return False
296
+ return any(
297
+ [
298
+ registry.agents,
299
+ registry.toolsets,
300
+ registry.lua_tools,
301
+ registry.contexts,
302
+ registry.corpora,
303
+ registry.retrievers,
304
+ registry.compactors,
305
+ registry.named_procedures,
306
+ registry.specifications,
307
+ registry.dependencies,
308
+ registry.models,
309
+ registry.hitl_points,
310
+ registry.mocks,
311
+ registry.agent_mocks,
312
+ registry.prompts,
313
+ ]
314
+ )
315
+
316
+ def _merge_tasks(
317
+ self,
318
+ target_registry,
319
+ include_registry,
320
+ namespace: Optional[str] = None,
321
+ ) -> None:
322
+ if not include_registry.tasks:
323
+ return
324
+ if namespace:
325
+ if namespace in target_registry.tasks:
326
+ raise ValueError(f"Duplicate task namespace '{namespace}'")
327
+ target_registry.tasks[namespace] = TaskDeclaration(
328
+ name=namespace,
329
+ children=include_registry.tasks,
330
+ )
331
+ return
332
+
333
+ for task_name, task in include_registry.tasks.items():
334
+ if task_name in target_registry.tasks:
335
+ raise ValueError(f"Duplicate task '{task_name}'")
336
+ target_registry.tasks[task_name] = task
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tactus
3
- Version: 0.36.0
3
+ Version: 0.38.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
@@ -14,15 +14,18 @@ Classifier: Development Status :: 3 - Alpha
14
14
  Classifier: Intended Audience :: Developers
15
15
  Classifier: License :: OSI Approved :: MIT License
16
16
  Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
17
19
  Classifier: Programming Language :: Python :: 3.11
18
20
  Classifier: Programming Language :: Python :: 3.12
19
21
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
22
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
- Requires-Python: >=3.11
23
+ Requires-Python: >=3.9
22
24
  Requires-Dist: antlr4-python3-runtime==4.13.1
23
25
  Requires-Dist: behave>=1.2.6
26
+ Requires-Dist: biblicus>=1.0.0
24
27
  Requires-Dist: boto3>=1.28.0
25
- Requires-Dist: dotyaml>=0.1.0
28
+ Requires-Dist: dotyaml>=0.1.4
26
29
  Requires-Dist: dspy>=2.5
27
30
  Requires-Dist: flask-cors>=4.0.0
28
31
  Requires-Dist: flask>=3.0.0
@@ -39,7 +42,7 @@ Requires-Dist: openpyxl>=3.1
39
42
  Requires-Dist: opentelemetry-api>=1.39.1
40
43
  Requires-Dist: opentelemetry-sdk>=1.39.1
41
44
  Requires-Dist: pyarrow>=14.0
42
- Requires-Dist: pydantic-ai-slim[bedrock,evals]
45
+ Requires-Dist: pydantic-ai
43
46
  Requires-Dist: pydantic>=2.0
44
47
  Requires-Dist: pyyaml
45
48
  Requires-Dist: rapidfuzz>=3.0.0
@@ -48,9 +51,9 @@ Requires-Dist: typer
48
51
  Provides-Extra: dev
49
52
  Requires-Dist: antlr4-tools>=0.2.1; extra == 'dev'
50
53
  Requires-Dist: behave>=1.2.6; extra == 'dev'
51
- Requires-Dist: black==25.12.0; extra == 'dev'
54
+ Requires-Dist: black==24.10.0; extra == 'dev'
52
55
  Requires-Dist: coverage>=7.4; extra == 'dev'
53
- Requires-Dist: fastmcp>=2.3.5; extra == 'dev'
56
+ Requires-Dist: fastmcp>=2.3.5; (python_version >= '3.10') and extra == 'dev'
54
57
  Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
55
58
  Requires-Dist: pytest-xdist>=3.0; extra == 'dev'
56
59
  Requires-Dist: pytest>=8.0; extra == 'dev'