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
@@ -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,34 +129,25 @@ 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
- # Check if this is a DSL setting assignment
108
- setting_handlers_by_name = {
109
- "default_provider": self.builder.set_default_provider,
110
- "default_model": self.builder.set_default_model,
111
- "return_prompt": self.builder.set_return_prompt,
112
- "error_prompt": self.builder.set_error_prompt,
113
- "status_prompt": self.builder.set_status_prompt,
114
- "async": self.builder.set_async,
115
- "max_depth": self.builder.set_max_depth,
116
- "max_turns": self.builder.set_max_turns,
117
- }
118
- if assignment_target_name in setting_handlers_by_name:
119
- # Get the value from explist
120
- if expression_list.exp() and len(expression_list.exp()) > 0:
121
- first_expression = expression_list.exp()[0]
122
- literal_value = self._extract_literal_value(first_expression)
123
- # Process the assignment like a function call
124
- setting_handlers_by_name[assignment_target_name](literal_value)
125
- else:
126
- # Check for assignment-based DSL declarations
127
- # e.g., greeter = Agent {...}, done = Tool {...}
128
- if expression_list.exp() and len(expression_list.exp()) > 0:
129
- first_expression = expression_list.exp()[0]
134
+ if not self._apply_setting_assignment(assignment_target_name, expression_list):
135
+ first_expression = self._extract_first_expression(expression_list)
136
+ if first_expression:
130
137
  self._check_assignment_based_declaration(
131
138
  assignment_target_name, first_expression
132
139
  )
133
140
 
141
+ attname_list = getattr(context, "attnamelist", None)
142
+ exp_list = getattr(context, "explist", None)
143
+ if callable(attname_list) and callable(exp_list):
144
+ attname_nodes = attname_list()
145
+ exp_nodes = exp_list()
146
+ if attname_nodes and exp_nodes:
147
+ name_nodes = attname_nodes.NAME()
148
+ if name_nodes:
149
+ self._track_retriever_alias(name_nodes[0].getText(), exp_nodes)
150
+
134
151
  # Continue visiting children
135
152
  return self.visitChildren(context)
136
153
 
@@ -144,70 +161,162 @@ class TactusDSLVisitor(LuaParserVisitor):
144
161
  if prefix_expression.functioncall():
145
162
  function_call = prefix_expression.functioncall()
146
163
  function_name = self._extract_function_name(function_call)
164
+ dotted_name = None
165
+ if function_name not in {
166
+ "Agent",
167
+ "Tool",
168
+ "Toolset",
169
+ "Context",
170
+ "Corpus",
171
+ "Retriever",
172
+ "Compactor",
173
+ }:
174
+ dotted_name = self._extract_dotted_dsl_name(function_call)
175
+ if dotted_name:
176
+ function_name = dotted_name
177
+ else:
178
+ dotted_name = self._extract_dotted_dsl_name(function_call)
179
+
180
+ # Check if this is a chained method call (e.g., Agent('name').turn()).
181
+ # Dotted module calls like vector.Corpus { ... } are not chained.
182
+ get_text = getattr(function_call, "getText", None)
183
+ function_call_text = get_text() if callable(get_text) else ""
184
+ is_chained_method_call = (
185
+ re.search(r"[)}\\]]\\s*\\.", function_call_text) is not None
186
+ )
147
187
 
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
153
-
154
- if function_name == "Agent" and not is_chained_method_call:
155
- # Extract config from Agent {...}
156
- declaration_config = self._extract_single_table_arg(function_call)
157
- # Filter out None values from tools list (variable refs can't be resolved)
158
- if declaration_config and "tools" in declaration_config:
159
- tool_name_list = declaration_config["tools"]
160
- if isinstance(tool_name_list, list):
161
- declaration_config["tools"] = [
162
- tool_name for tool_name in tool_name_list if tool_name is not None
163
- ]
164
- self.builder.register_agent(
165
- assignment_target_name,
166
- declaration_config if declaration_config else {},
167
- None,
188
+ if function_name in {"Corpus", "Retriever"}:
189
+ has_namespace = dotted_name is not None or re.match(
190
+ r"[A-Za-z_][A-Za-z0-9_]*\\.", function_call_text
168
191
  )
169
- elif function_name == "Tool":
170
- # Extract config from Tool {...}
171
- declaration_config = self._extract_single_table_arg(function_call)
172
- if (
173
- declaration_config
174
- and isinstance(declaration_config, dict)
175
- and isinstance(declaration_config.get("name"), str)
176
- and declaration_config.get("name") != assignment_target_name
177
- ):
192
+ if not has_namespace:
178
193
  self._record_error(
179
194
  message=(
180
- f"Tool name mismatch: '{assignment_target_name} = Tool {{ name = \"{declaration_config.get('name')}\" }}'. "
181
- f"Remove the 'name' field or set it to '{assignment_target_name}'."
195
+ f"Direct {function_name} declarations are not supported. "
196
+ "Use module.Corpus { ... } instead."
182
197
  ),
183
- declaration="Tool",
198
+ declaration=function_name,
184
199
  )
185
- self.builder.register_tool(
186
- assignment_target_name,
187
- declaration_config if declaration_config else {},
188
- None,
189
- )
190
- elif function_name == "Toolset":
191
- # Extract config from Toolset {...}
192
- declaration_config = self._extract_single_table_arg(function_call)
193
- self.builder.register_toolset(
194
- assignment_target_name,
195
- declaration_config if declaration_config else {},
196
- )
197
- elif function_name == "Procedure":
198
- # New assignment syntax: main = Procedure { function(input) ... }
199
- # Register as a named procedure
200
- self.builder.register_named_procedure(
200
+ return
201
+
202
+ if function_name == "Agent" and is_chained_method_call:
203
+ return
204
+ handled = self._register_assignment_based_declaration(
205
+ assignment_target_name, function_name, function_call
206
+ )
207
+ if handled:
208
+ return
209
+ declaration_config = self._extract_single_table_arg(function_call)
210
+ if isinstance(declaration_config, dict) and "corpus" in declaration_config:
211
+ retriever_id = self._resolve_retriever_id_from_call(function_call)
212
+ if retriever_id and "retriever_id" not in declaration_config:
213
+ declaration_config["retriever_id"] = retriever_id
214
+ self.builder.register_retriever(
201
215
  assignment_target_name,
202
- None, # Function not available during validation
203
- {}, # Input schema will be extracted from top-level input {}
204
- {}, # Output schema will be extracted from top-level output {}
205
- {}, # State schema
216
+ declaration_config,
206
217
  )
207
218
 
219
+ def _apply_setting_assignment(self, assignment_target_name: str, expression_list) -> bool:
220
+ setting_handlers_by_name = {
221
+ "default_provider": self.builder.set_default_provider,
222
+ "default_model": self.builder.set_default_model,
223
+ "return_prompt": self.builder.set_return_prompt,
224
+ "error_prompt": self.builder.set_error_prompt,
225
+ "status_prompt": self.builder.set_status_prompt,
226
+ "async": self.builder.set_async,
227
+ "max_depth": self.builder.set_max_depth,
228
+ "max_turns": self.builder.set_max_turns,
229
+ }
230
+ if assignment_target_name not in setting_handlers_by_name:
231
+ return False
232
+ first_expression = self._extract_first_expression(expression_list)
233
+ if not first_expression:
234
+ return True
235
+ literal_value = self._extract_literal_value(first_expression)
236
+ setting_handlers_by_name[assignment_target_name](literal_value)
237
+ return True
238
+
239
+ def _extract_first_expression(self, expression_list):
240
+ exp_list = getattr(expression_list, "exp", None)
241
+ if not callable(exp_list):
242
+ return None
243
+ expressions = exp_list()
244
+ if expressions:
245
+ return expressions[0]
246
+ return None
247
+
248
+ def _register_assignment_based_declaration(
249
+ self,
250
+ assignment_target_name: str,
251
+ function_name: str,
252
+ function_call,
253
+ ) -> bool:
254
+ declaration_config = self._extract_single_table_arg(function_call)
255
+ normalized_config = declaration_config if declaration_config else {}
256
+ if function_name == "Agent":
257
+ if "tools" in normalized_config:
258
+ tool_name_list = normalized_config["tools"]
259
+ if isinstance(tool_name_list, list):
260
+ normalized_config["tools"] = [
261
+ tool_name for tool_name in tool_name_list if tool_name is not None
262
+ ]
263
+ self.builder.register_agent(assignment_target_name, normalized_config, None)
264
+ return True
265
+ if function_name == "Tool":
266
+ if (
267
+ isinstance(normalized_config, dict)
268
+ and isinstance(normalized_config.get("name"), str)
269
+ and normalized_config.get("name") != assignment_target_name
270
+ ):
271
+ self._record_error(
272
+ message=(
273
+ f"Tool name mismatch: '{assignment_target_name} = Tool {{ name = \"{normalized_config.get('name')}\" }}'. "
274
+ f"Remove the 'name' field or set it to '{assignment_target_name}'."
275
+ ),
276
+ declaration="Tool",
277
+ )
278
+ self.builder.register_tool(assignment_target_name, normalized_config, None)
279
+ return True
280
+ if function_name == "Toolset":
281
+ self.builder.register_toolset(assignment_target_name, normalized_config)
282
+ return True
283
+ if function_name == "Context":
284
+ self.builder.register_context(assignment_target_name, normalized_config)
285
+ return True
286
+ if function_name == "Corpus":
287
+ self.builder.register_corpus(assignment_target_name, normalized_config)
288
+ return True
289
+ if function_name == "Retriever":
290
+ retriever_id = self._resolve_retriever_id_from_call(function_call)
291
+ if retriever_id and "retriever_id" not in normalized_config:
292
+ normalized_config["retriever_id"] = retriever_id
293
+ self.builder.register_retriever(assignment_target_name, normalized_config)
294
+ return True
295
+ if function_name == "Compactor":
296
+ self.builder.register_compactor(assignment_target_name, normalized_config)
297
+ return True
298
+ if function_name == "Procedure":
299
+ self.builder.register_named_procedure(
300
+ assignment_target_name,
301
+ None,
302
+ {},
303
+ {},
304
+ {},
305
+ )
306
+ return True
307
+ if function_name == "Task":
308
+ self._processed_task_calls.add(id(function_call))
309
+ self._register_task_declaration(assignment_target_name, function_call)
310
+ return True
311
+ return False
312
+
208
313
  def _extract_single_table_arg(self, function_call) -> dict:
209
314
  """Extract a single table argument from a function call like Agent {...}."""
210
- argument_list_nodes = function_call.args()
315
+ args_method = getattr(function_call, "args", None)
316
+ if args_method is None:
317
+ return {}
318
+
319
+ argument_list_nodes = args_method()
211
320
  if not argument_list_nodes:
212
321
  return {}
213
322
 
@@ -239,6 +348,14 @@ class TactusDSLVisitor(LuaParserVisitor):
239
348
  if function_name in self.DSL_FUNCTIONS and not is_method_access_call:
240
349
  # Process the DSL call (but skip method calls like Tool.called())
241
350
  try:
351
+ if function_name == "Task":
352
+ if id(context) in self._processed_task_calls:
353
+ return None
354
+ self._register_task_declaration(None, context)
355
+ return None
356
+ if function_name == "IncludeTasks":
357
+ self._register_include_tasks(context)
358
+ return None
242
359
  self._process_dsl_call(function_name, context)
243
360
  except Exception as processing_exception:
244
361
  self.errors.append(
@@ -254,6 +371,122 @@ class TactusDSLVisitor(LuaParserVisitor):
254
371
 
255
372
  return self.visitChildren(context)
256
373
 
374
+ def _register_task_declaration(
375
+ self,
376
+ assignment_name: Optional[str],
377
+ function_call_context: LuaParser.FunctioncallContext,
378
+ parent_task: Optional[str] = None,
379
+ ) -> None:
380
+ args = self._extract_arguments(function_call_context)
381
+ task_name = None
382
+ task_config: dict[str, Any] = {}
383
+
384
+ if args:
385
+ if isinstance(args[0], str):
386
+ task_name = args[0]
387
+ elif isinstance(args[0], dict):
388
+ task_config = args[0]
389
+
390
+ if len(args) >= 2 and isinstance(args[1], dict):
391
+ task_config = args[1]
392
+
393
+ if assignment_name and task_name and assignment_name != task_name:
394
+ self._record_error(
395
+ message=(
396
+ f"Task name mismatch: '{assignment_name} = Task \"{task_name}\" {{ ... }}'. "
397
+ f"Remove the string name or set it to '{assignment_name}'."
398
+ ),
399
+ declaration="Task",
400
+ )
401
+ task_name = assignment_name
402
+
403
+ if assignment_name and not task_name:
404
+ task_name = assignment_name
405
+
406
+ if not task_name:
407
+ self._record_error("Task name is required.", declaration="Task")
408
+ return
409
+
410
+ children = self._extract_nested_tasks(function_call_context)
411
+ for child in children:
412
+ child_name = child["name"]
413
+ self.builder.register_task(child_name, child.get("config", {}), parent=task_name)
414
+
415
+ self.builder.register_task(task_name, task_config, parent=parent_task)
416
+
417
+ def _extract_nested_tasks(
418
+ self, function_call_context: LuaParser.FunctioncallContext
419
+ ) -> list[dict[str, Any]]:
420
+ """Extract nested Task calls from a Task { ... } table constructor."""
421
+ nested_tasks: list[dict[str, Any]] = []
422
+ argument_list_nodes = function_call_context.args()
423
+ if not argument_list_nodes:
424
+ return nested_tasks
425
+
426
+ for args_ctx in argument_list_nodes:
427
+ table_ctx = args_ctx.tableconstructor()
428
+ if not table_ctx or not table_ctx.fieldlist():
429
+ continue
430
+ for field in table_ctx.fieldlist().field():
431
+ if len(field.exp()) != 1:
432
+ continue
433
+ child_exp = field.exp(0)
434
+ child_call = None
435
+ if hasattr(child_exp, "functioncall") and child_exp.functioncall():
436
+ child_call = child_exp.functioncall()
437
+ elif child_exp.prefixexp() and child_exp.prefixexp().functioncall():
438
+ child_call = child_exp.prefixexp().functioncall()
439
+ if not child_call:
440
+ continue
441
+ child_name = self._extract_function_name(child_call)
442
+ if child_name != "Task":
443
+ continue
444
+ child_args = self._extract_arguments(child_call)
445
+ child_task_name = None
446
+ if field.NAME():
447
+ child_task_name = field.NAME().getText()
448
+ if not child_task_name and child_args:
449
+ child_task_name = child_args[0] if isinstance(child_args[0], str) else None
450
+ child_task_config = {}
451
+ if len(child_args) >= 2 and isinstance(child_args[1], dict):
452
+ child_task_config = child_args[1]
453
+ if field.NAME() and child_args and isinstance(child_args[0], str):
454
+ if field.NAME().getText() != child_args[0]:
455
+ self._record_error(
456
+ message=(
457
+ f"Task name mismatch: '{field.NAME().getText()} = Task \"{child_args[0]}\" {{ ... }}'. "
458
+ f"Remove the string name or set it to '{field.NAME().getText()}'."
459
+ ),
460
+ declaration="Task",
461
+ )
462
+ if child_task_name:
463
+ nested_tasks.append(
464
+ {
465
+ "name": child_task_name,
466
+ "config": child_task_config,
467
+ }
468
+ )
469
+ return nested_tasks
470
+
471
+ def _register_include_tasks(self, context: LuaParser.FunctioncallContext) -> None:
472
+ args = self._extract_arguments(context)
473
+ if not args:
474
+ self._record_error("IncludeTasks requires a path string.", declaration="IncludeTasks")
475
+ return
476
+ if not isinstance(args[0], str):
477
+ self._record_error(
478
+ "IncludeTasks path must be a string literal.",
479
+ declaration="IncludeTasks",
480
+ )
481
+ return
482
+ namespace = None
483
+ if len(args) >= 2:
484
+ if isinstance(args[1], str):
485
+ namespace = args[1]
486
+ elif isinstance(args[1], dict):
487
+ namespace = args[1].get("namespace")
488
+ self.builder.register_include_tasks(args[0], namespace)
489
+
257
490
  def _check_deprecated_method_calls(self, ctx: LuaParser.FunctioncallContext):
258
491
  """Check for deprecated method calls like .turn() or .run()."""
259
492
  # Method calls have the form: varOrExp nameAndArgs+
@@ -373,6 +606,112 @@ class TactusDSLVisitor(LuaParserVisitor):
373
606
 
374
607
  return None
375
608
 
609
+ def _extract_dotted_dsl_name(
610
+ self, function_call_context: LuaParser.FunctioncallContext
611
+ ) -> Optional[str]:
612
+ """Extract DSL names from dotted calls like module.Corpus {...}."""
613
+ get_text = getattr(function_call_context, "getText", None)
614
+ if not callable(get_text):
615
+ return None
616
+ function_call_text = get_text()
617
+ match = re.match(
618
+ r"(?:[A-Za-z_][A-Za-z0-9_]*\.)+(Agent|Tool|Toolset|Context|Corpus|Retriever|Compactor)\b",
619
+ function_call_text,
620
+ )
621
+ if match:
622
+ return match.group(1)
623
+ return None
624
+
625
+ def _track_retriever_alias(self, assignment_name: str, expression_list) -> None:
626
+ """Track retriever constructor aliases from require(...) assignments."""
627
+ try:
628
+ expressions = expression_list.exp()
629
+ except Exception:
630
+ return
631
+ if not expressions:
632
+ return
633
+ expression_node = expressions[0]
634
+ get_text = getattr(expression_node, "getText", None)
635
+ if not callable(get_text):
636
+ return
637
+ expression_text = get_text()
638
+ match = re.match(
639
+ r'require\("tactus\.retrievers\.([A-Za-z0-9_]+)"\)(?:\.Retriever)?$',
640
+ expression_text,
641
+ )
642
+ if not match:
643
+ return
644
+ module_name = match.group(1)
645
+ retriever_id = module_name.replace("_", "-")
646
+ if expression_text.endswith(".Retriever"):
647
+ self._retriever_aliases[assignment_name] = retriever_id
648
+ else:
649
+ self._retriever_modules[assignment_name] = retriever_id
650
+
651
+ def _resolve_retriever_id_from_call(
652
+ self, function_call_context: LuaParser.FunctioncallContext
653
+ ) -> Optional[str]:
654
+ function_name = self._extract_function_name(function_call_context)
655
+ if function_name and function_name in self._retriever_aliases:
656
+ return self._retriever_aliases[function_name]
657
+ call_text = function_call_context.getText()
658
+ dotted_match = re.match(r"([A-Za-z_][A-Za-z0-9_]*)\.Retriever\b", call_text)
659
+ if dotted_match:
660
+ module_name = dotted_match.group(1)
661
+ return self._retriever_modules.get(module_name)
662
+ return None
663
+
664
+ def _parse_functioncall_expression(
665
+ self, function_call_context: LuaParser.FunctioncallContext
666
+ ) -> Any:
667
+ """Parse function calls used inside DSL expressions (e.g., context messages)."""
668
+ function_name = self._extract_function_name(function_call_context)
669
+ if not function_name:
670
+ return None
671
+
672
+ args = self._extract_arguments(function_call_context)
673
+
674
+ if function_name == "template":
675
+ template_text = args[0] if args else ""
676
+ vars_dict = args[1] if len(args) > 1 else {}
677
+ if not isinstance(template_text, str):
678
+ return None
679
+ if not isinstance(vars_dict, dict):
680
+ vars_dict = {}
681
+ return {"template": template_text, "vars": vars_dict}
682
+
683
+ if function_name in {"system", "user", "assistant"}:
684
+ if not args:
685
+ return None
686
+ arg = args[0]
687
+ if isinstance(arg, dict) and "template" in arg:
688
+ return {
689
+ "type": function_name,
690
+ "template": arg.get("template"),
691
+ "vars": arg.get("vars", {}),
692
+ }
693
+ if isinstance(arg, str):
694
+ return {"type": function_name, "content": arg}
695
+ return {"type": function_name, "content": str(arg)}
696
+
697
+ if function_name == "context":
698
+ if not args:
699
+ return None
700
+ pack_name = args[0]
701
+ budget = args[1] if len(args) > 1 else None
702
+ directive: dict[str, Any] = {
703
+ "type": "context",
704
+ "name": pack_name if isinstance(pack_name, str) else str(pack_name),
705
+ }
706
+ if isinstance(budget, dict):
707
+ directive["budget"] = budget
708
+ return directive
709
+
710
+ if function_name == "history":
711
+ return {"type": "history"}
712
+
713
+ return None
714
+
376
715
  def _process_dsl_call(self, function_name: str, ctx: LuaParser.FunctioncallContext):
377
716
  """Extract arguments and register declaration."""
378
717
  argument_values = self._extract_arguments(ctx)
@@ -742,6 +1081,9 @@ class TactusDSLVisitor(LuaParserVisitor):
742
1081
  return True
743
1082
  elif ctx.tableconstructor():
744
1083
  return self._parse_table_constructor(ctx.tableconstructor())
1084
+ function_call = getattr(ctx, "functioncall", None)
1085
+ if callable(function_call):
1086
+ return self._parse_functioncall_expression(function_call())
745
1087
 
746
1088
  # For other expressions, return None (can't evaluate without execution)
747
1089
  return None
@@ -771,23 +1113,23 @@ class TactusDSLVisitor(LuaParserVisitor):
771
1113
  return token_text[2:-2]
772
1114
  elif token_text.startswith('"') and token_text.endswith('"'):
773
1115
  # Double-quoted string
774
- content = token_text[1:-1]
775
- content = content.replace("\\n", "\n")
776
- content = content.replace("\\t", "\t")
777
- content = content.replace('\\"', '"')
778
- content = content.replace("\\\\", "\\")
779
- return content
1116
+ return self._unescape_basic_string(token_text[1:-1], '"')
780
1117
  elif token_text.startswith("'") and token_text.endswith("'"):
781
1118
  # Single-quoted string
782
- content = token_text[1:-1]
783
- content = content.replace("\\n", "\n")
784
- content = content.replace("\\t", "\t")
785
- content = content.replace("\\'", "'")
786
- content = content.replace("\\\\", "\\")
787
- return content
1119
+ return self._unescape_basic_string(token_text[1:-1], "'")
788
1120
 
789
1121
  return token_text
790
1122
 
1123
+ def _unescape_basic_string(self, content: str, quote_char: str) -> str:
1124
+ content = content.replace("\\n", "\n")
1125
+ content = content.replace("\\t", "\t")
1126
+ if quote_char == '"':
1127
+ content = content.replace('\\"', '"')
1128
+ elif quote_char == "'":
1129
+ content = content.replace("\\'", "'")
1130
+ content = content.replace("\\\\", "\\")
1131
+ return content
1132
+
791
1133
  def _parse_table_constructor(self, ctx: LuaParser.TableconstructorContext) -> Any:
792
1134
  """Parse Lua table constructor to Python dict."""
793
1135
  parsed_table = {}