tactus 0.31.2__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 (160) hide show
  1. tactus/__init__.py +49 -0
  2. tactus/adapters/__init__.py +9 -0
  3. tactus/adapters/broker_log.py +76 -0
  4. tactus/adapters/cli_hitl.py +189 -0
  5. tactus/adapters/cli_log.py +223 -0
  6. tactus/adapters/cost_collector_log.py +56 -0
  7. tactus/adapters/file_storage.py +367 -0
  8. tactus/adapters/http_callback_log.py +109 -0
  9. tactus/adapters/ide_log.py +71 -0
  10. tactus/adapters/lua_tools.py +336 -0
  11. tactus/adapters/mcp.py +289 -0
  12. tactus/adapters/mcp_manager.py +196 -0
  13. tactus/adapters/memory.py +53 -0
  14. tactus/adapters/plugins.py +419 -0
  15. tactus/backends/http_backend.py +58 -0
  16. tactus/backends/model_backend.py +35 -0
  17. tactus/backends/pytorch_backend.py +110 -0
  18. tactus/broker/__init__.py +12 -0
  19. tactus/broker/client.py +247 -0
  20. tactus/broker/protocol.py +183 -0
  21. tactus/broker/server.py +1123 -0
  22. tactus/broker/stdio.py +12 -0
  23. tactus/cli/__init__.py +7 -0
  24. tactus/cli/app.py +2245 -0
  25. tactus/cli/commands/__init__.py +0 -0
  26. tactus/core/__init__.py +32 -0
  27. tactus/core/config_manager.py +790 -0
  28. tactus/core/dependencies/__init__.py +14 -0
  29. tactus/core/dependencies/registry.py +180 -0
  30. tactus/core/dsl_stubs.py +2117 -0
  31. tactus/core/exceptions.py +66 -0
  32. tactus/core/execution_context.py +480 -0
  33. tactus/core/lua_sandbox.py +508 -0
  34. tactus/core/message_history_manager.py +236 -0
  35. tactus/core/mocking.py +286 -0
  36. tactus/core/output_validator.py +291 -0
  37. tactus/core/registry.py +499 -0
  38. tactus/core/runtime.py +2907 -0
  39. tactus/core/template_resolver.py +142 -0
  40. tactus/core/yaml_parser.py +301 -0
  41. tactus/docker/Dockerfile +61 -0
  42. tactus/docker/entrypoint.sh +69 -0
  43. tactus/dspy/__init__.py +39 -0
  44. tactus/dspy/agent.py +1144 -0
  45. tactus/dspy/broker_lm.py +181 -0
  46. tactus/dspy/config.py +212 -0
  47. tactus/dspy/history.py +196 -0
  48. tactus/dspy/module.py +405 -0
  49. tactus/dspy/prediction.py +318 -0
  50. tactus/dspy/signature.py +185 -0
  51. tactus/formatting/__init__.py +7 -0
  52. tactus/formatting/formatter.py +437 -0
  53. tactus/ide/__init__.py +9 -0
  54. tactus/ide/coding_assistant.py +343 -0
  55. tactus/ide/server.py +2223 -0
  56. tactus/primitives/__init__.py +49 -0
  57. tactus/primitives/control.py +168 -0
  58. tactus/primitives/file.py +229 -0
  59. tactus/primitives/handles.py +378 -0
  60. tactus/primitives/host.py +94 -0
  61. tactus/primitives/human.py +342 -0
  62. tactus/primitives/json.py +189 -0
  63. tactus/primitives/log.py +187 -0
  64. tactus/primitives/message_history.py +157 -0
  65. tactus/primitives/model.py +163 -0
  66. tactus/primitives/procedure.py +564 -0
  67. tactus/primitives/procedure_callable.py +318 -0
  68. tactus/primitives/retry.py +155 -0
  69. tactus/primitives/session.py +152 -0
  70. tactus/primitives/state.py +182 -0
  71. tactus/primitives/step.py +209 -0
  72. tactus/primitives/system.py +93 -0
  73. tactus/primitives/tool.py +375 -0
  74. tactus/primitives/tool_handle.py +279 -0
  75. tactus/primitives/toolset.py +229 -0
  76. tactus/protocols/__init__.py +38 -0
  77. tactus/protocols/chat_recorder.py +81 -0
  78. tactus/protocols/config.py +97 -0
  79. tactus/protocols/cost.py +31 -0
  80. tactus/protocols/hitl.py +71 -0
  81. tactus/protocols/log_handler.py +27 -0
  82. tactus/protocols/models.py +355 -0
  83. tactus/protocols/result.py +33 -0
  84. tactus/protocols/storage.py +90 -0
  85. tactus/providers/__init__.py +13 -0
  86. tactus/providers/base.py +92 -0
  87. tactus/providers/bedrock.py +117 -0
  88. tactus/providers/google.py +105 -0
  89. tactus/providers/openai.py +98 -0
  90. tactus/sandbox/__init__.py +63 -0
  91. tactus/sandbox/config.py +171 -0
  92. tactus/sandbox/container_runner.py +1099 -0
  93. tactus/sandbox/docker_manager.py +433 -0
  94. tactus/sandbox/entrypoint.py +227 -0
  95. tactus/sandbox/protocol.py +213 -0
  96. tactus/stdlib/__init__.py +10 -0
  97. tactus/stdlib/io/__init__.py +13 -0
  98. tactus/stdlib/io/csv.py +88 -0
  99. tactus/stdlib/io/excel.py +136 -0
  100. tactus/stdlib/io/file.py +90 -0
  101. tactus/stdlib/io/fs.py +154 -0
  102. tactus/stdlib/io/hdf5.py +121 -0
  103. tactus/stdlib/io/json.py +109 -0
  104. tactus/stdlib/io/parquet.py +83 -0
  105. tactus/stdlib/io/tsv.py +88 -0
  106. tactus/stdlib/loader.py +274 -0
  107. tactus/stdlib/tac/tactus/tools/done.tac +33 -0
  108. tactus/stdlib/tac/tactus/tools/log.tac +50 -0
  109. tactus/testing/README.md +273 -0
  110. tactus/testing/__init__.py +61 -0
  111. tactus/testing/behave_integration.py +380 -0
  112. tactus/testing/context.py +486 -0
  113. tactus/testing/eval_models.py +114 -0
  114. tactus/testing/evaluation_runner.py +222 -0
  115. tactus/testing/evaluators.py +634 -0
  116. tactus/testing/events.py +94 -0
  117. tactus/testing/gherkin_parser.py +134 -0
  118. tactus/testing/mock_agent.py +315 -0
  119. tactus/testing/mock_dependencies.py +234 -0
  120. tactus/testing/mock_hitl.py +171 -0
  121. tactus/testing/mock_registry.py +168 -0
  122. tactus/testing/mock_tools.py +133 -0
  123. tactus/testing/models.py +115 -0
  124. tactus/testing/pydantic_eval_runner.py +508 -0
  125. tactus/testing/steps/__init__.py +13 -0
  126. tactus/testing/steps/builtin.py +902 -0
  127. tactus/testing/steps/custom.py +69 -0
  128. tactus/testing/steps/registry.py +68 -0
  129. tactus/testing/test_runner.py +489 -0
  130. tactus/tracing/__init__.py +5 -0
  131. tactus/tracing/trace_manager.py +417 -0
  132. tactus/utils/__init__.py +1 -0
  133. tactus/utils/cost_calculator.py +72 -0
  134. tactus/utils/model_pricing.py +132 -0
  135. tactus/utils/safe_file_library.py +502 -0
  136. tactus/utils/safe_libraries.py +234 -0
  137. tactus/validation/LuaLexerBase.py +66 -0
  138. tactus/validation/LuaParserBase.py +23 -0
  139. tactus/validation/README.md +224 -0
  140. tactus/validation/__init__.py +7 -0
  141. tactus/validation/error_listener.py +21 -0
  142. tactus/validation/generated/LuaLexer.interp +231 -0
  143. tactus/validation/generated/LuaLexer.py +5548 -0
  144. tactus/validation/generated/LuaLexer.tokens +124 -0
  145. tactus/validation/generated/LuaLexerBase.py +66 -0
  146. tactus/validation/generated/LuaParser.interp +173 -0
  147. tactus/validation/generated/LuaParser.py +6439 -0
  148. tactus/validation/generated/LuaParser.tokens +124 -0
  149. tactus/validation/generated/LuaParserBase.py +23 -0
  150. tactus/validation/generated/LuaParserVisitor.py +118 -0
  151. tactus/validation/generated/__init__.py +7 -0
  152. tactus/validation/grammar/LuaLexer.g4 +123 -0
  153. tactus/validation/grammar/LuaParser.g4 +178 -0
  154. tactus/validation/semantic_visitor.py +817 -0
  155. tactus/validation/validator.py +157 -0
  156. tactus-0.31.2.dist-info/METADATA +1809 -0
  157. tactus-0.31.2.dist-info/RECORD +160 -0
  158. tactus-0.31.2.dist-info/WHEEL +4 -0
  159. tactus-0.31.2.dist-info/entry_points.txt +2 -0
  160. tactus-0.31.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,817 @@
1
+ """
2
+ Semantic visitor for Tactus DSL.
3
+
4
+ Walks the ANTLR parse tree and recognizes DSL patterns,
5
+ extracting declarations without executing code.
6
+ """
7
+
8
+ import logging
9
+ from typing import Any, Optional
10
+
11
+ from .generated.LuaParser import LuaParser
12
+ from .generated.LuaParserVisitor import LuaParserVisitor
13
+ from tactus.core.registry import RegistryBuilder, ValidationMessage
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class TactusDSLVisitor(LuaParserVisitor):
20
+ """
21
+ Walks ANTLR parse tree and recognizes DSL patterns.
22
+ Does NOT execute code - only analyzes structure.
23
+ """
24
+
25
+ DSL_FUNCTIONS = {
26
+ "name",
27
+ "version",
28
+ "description",
29
+ "Agent", # CamelCase
30
+ "Model", # CamelCase
31
+ "Procedure", # CamelCase
32
+ "Prompt", # CamelCase
33
+ "Hitl", # CamelCase
34
+ "Specification", # CamelCase
35
+ "Specifications", # CamelCase - Gherkin BDD specs
36
+ "Step", # CamelCase - Custom step definitions
37
+ "Evaluation", # CamelCase - Evaluation configuration
38
+ "Evaluations", # CamelCase - Pydantic Evals configuration
39
+ "default_provider",
40
+ "default_model",
41
+ "return_prompt",
42
+ "error_prompt",
43
+ "status_prompt",
44
+ "async",
45
+ "max_depth",
46
+ "max_turns",
47
+ "Tool", # CamelCase - Lua-defined tools
48
+ "Toolset", # CamelCase - Added for toolsets
49
+ "input", # lowercase - top-level input schema for script mode
50
+ "output", # lowercase - top-level output schema for script mode
51
+ }
52
+
53
+ def __init__(self):
54
+ self.builder = RegistryBuilder()
55
+ self.errors = []
56
+ self.warnings = []
57
+ self.current_line = 0
58
+ self.current_col = 0
59
+ self.in_function_body = False # Track if we're inside a function body
60
+
61
+ def visitFunctiondef(self, ctx):
62
+ """Track when entering/exiting function definitions."""
63
+ # Set flag when entering function body
64
+ old_in_function = self.in_function_body
65
+ self.in_function_body = True
66
+ try:
67
+ result = super().visitChildren(ctx)
68
+ finally:
69
+ # Restore previous state when exiting
70
+ self.in_function_body = old_in_function
71
+ return result
72
+
73
+ def visitStat(self, ctx: LuaParser.StatContext):
74
+ """Handle statement nodes including assignments."""
75
+ # Check if this is an assignment statement
76
+ if ctx.varlist() and ctx.explist():
77
+ # This is an assignment: varlist '=' explist
78
+ varlist = ctx.varlist()
79
+ explist = ctx.explist()
80
+
81
+ # Get the variable name
82
+ if varlist.var() and len(varlist.var()) > 0:
83
+ var = varlist.var()[0]
84
+ if var.NAME():
85
+ var_name = var.NAME().getText()
86
+
87
+ # Check if this is a DSL setting assignment
88
+ if var_name in [
89
+ "default_provider",
90
+ "default_model",
91
+ "return_prompt",
92
+ "error_prompt",
93
+ "status_prompt",
94
+ "async",
95
+ "max_depth",
96
+ "max_turns",
97
+ ]:
98
+ # Get the value from explist
99
+ if explist.exp() and len(explist.exp()) > 0:
100
+ exp = explist.exp()[0]
101
+ value = self._extract_literal_value(exp)
102
+
103
+ # Process the assignment like a function call
104
+ if var_name == "default_provider":
105
+ self.builder.set_default_provider(value)
106
+ elif var_name == "default_model":
107
+ self.builder.set_default_model(value)
108
+ elif var_name == "return_prompt":
109
+ self.builder.set_return_prompt(value)
110
+ elif var_name == "error_prompt":
111
+ self.builder.set_error_prompt(value)
112
+ elif var_name == "status_prompt":
113
+ self.builder.set_status_prompt(value)
114
+ elif var_name == "async":
115
+ self.builder.set_async(value)
116
+ elif var_name == "max_depth":
117
+ self.builder.set_max_depth(value)
118
+ elif var_name == "max_turns":
119
+ self.builder.set_max_turns(value)
120
+ else:
121
+ # Check for assignment-based DSL declarations
122
+ # e.g., greeter = Agent {...}, done = Tool {...}
123
+ if explist.exp() and len(explist.exp()) > 0:
124
+ exp = explist.exp()[0]
125
+ self._check_assignment_based_declaration(var_name, exp)
126
+
127
+ # Continue visiting children
128
+ return self.visitChildren(ctx)
129
+
130
+ def _check_assignment_based_declaration(self, var_name: str, exp):
131
+ """Check if an assignment is a DSL declaration like 'greeter = Agent {...}'."""
132
+ # Look for prefixexp with functioncall pattern: Agent {...}
133
+ if exp.prefixexp():
134
+ prefixexp = exp.prefixexp()
135
+ if prefixexp.functioncall():
136
+ func_call = prefixexp.functioncall()
137
+ func_name = self._extract_function_name(func_call)
138
+
139
+ # Check if this is a chained method call (e.g., Agent('name').turn())
140
+ # Chained calls have structure: func_name args . method_name args
141
+ # Simple declarations have: func_name args or func_name table
142
+ # If there are more than 2 children, it's a chained call, not a declaration
143
+ is_chained_call = func_call.getChildCount() > 2
144
+
145
+ if func_name == "Agent" and not is_chained_call:
146
+ # Extract config from Agent {...}
147
+ config = self._extract_single_table_arg(func_call)
148
+ # Filter out None values from tools list (variable refs can't be resolved)
149
+ if config and "tools" in config:
150
+ tools = config["tools"]
151
+ if isinstance(tools, list):
152
+ config["tools"] = [t for t in tools if t is not None]
153
+ self.builder.register_agent(var_name, config if config else {}, None)
154
+ elif func_name == "Tool":
155
+ # Extract config from Tool {...}
156
+ config = self._extract_single_table_arg(func_call)
157
+ if (
158
+ config
159
+ and isinstance(config, dict)
160
+ and isinstance(config.get("name"), str)
161
+ and config.get("name") != var_name
162
+ ):
163
+ self.errors.append(
164
+ ValidationMessage(
165
+ level="error",
166
+ message=(
167
+ f"Tool name mismatch: '{var_name} = Tool {{ name = \"{config.get('name')}\" }}'. "
168
+ f"Remove the 'name' field or set it to '{var_name}'."
169
+ ),
170
+ location=(self.current_line, self.current_col),
171
+ declaration="Tool",
172
+ )
173
+ )
174
+ self.builder.register_tool(var_name, config if config else {}, None)
175
+ elif func_name == "Toolset":
176
+ # Extract config from Toolset {...}
177
+ config = self._extract_single_table_arg(func_call)
178
+ self.builder.register_toolset(var_name, config if config else {})
179
+ elif func_name == "Procedure":
180
+ # New assignment syntax: main = Procedure { function(input) ... }
181
+ # Register as a named procedure
182
+ self.builder.register_named_procedure(
183
+ var_name,
184
+ None, # Function not available during validation
185
+ {}, # Input schema will be extracted from top-level input {}
186
+ {}, # Output schema will be extracted from top-level output {}
187
+ {}, # State schema
188
+ )
189
+
190
+ def _extract_single_table_arg(self, func_call) -> dict:
191
+ """Extract a single table argument from a function call like Agent {...}."""
192
+ args_list = func_call.args()
193
+ if not args_list:
194
+ return {}
195
+
196
+ # Process first args entry only
197
+ if len(args_list) > 0:
198
+ args_ctx = args_list[0]
199
+ if args_ctx.tableconstructor():
200
+ return self._parse_table_constructor(args_ctx.tableconstructor())
201
+
202
+ return {}
203
+
204
+ def visitFunctioncall(self, ctx: LuaParser.FunctioncallContext):
205
+ """Recognize and process DSL function calls."""
206
+ try:
207
+ # Extract line/column for error reporting
208
+ if ctx.start:
209
+ self.current_line = ctx.start.line
210
+ self.current_col = ctx.start.column
211
+
212
+ # Check for deprecated method calls like .turn() or .run()
213
+ self._check_deprecated_method_calls(ctx)
214
+
215
+ func_name = self._extract_function_name(ctx)
216
+
217
+ # Check if this is a method call (e.g., Tool.called()) vs a direct call (e.g., Tool())
218
+ # For "Tool.called()", parser extracts "Tool" as func_name but full text is "Tool.called(...)"
219
+ # We want to skip if full_text shows it's actually calling a method ON Tool, not Tool itself
220
+ full_text = ctx.getText()
221
+ is_method_call = False
222
+ if func_name:
223
+ # If the text is "Tool.called(...)" and func_name is "Tool",
224
+ # then it's actually calling .called() method on Tool, not calling Tool()
225
+ # Check: does full_text have func_name followed by a dot/colon (not by opening paren)?
226
+ # Pattern: func_name followed by . or : means it's accessing a method/property
227
+ import re
228
+
229
+ # Match: funcName followed by . or : (not by opening paren directly)
230
+ method_access_pattern = re.escape(func_name) + r"[.:]"
231
+ if re.search(method_access_pattern, full_text):
232
+ is_method_call = True
233
+
234
+ if func_name in self.DSL_FUNCTIONS and not is_method_call:
235
+ # Process the DSL call (but skip method calls like Tool.called())
236
+ try:
237
+ self._process_dsl_call(func_name, ctx)
238
+ except Exception as e:
239
+ self.errors.append(
240
+ ValidationMessage(
241
+ level="error",
242
+ message=f"Error processing {func_name}: {e}",
243
+ location=(self.current_line, self.current_col),
244
+ declaration=func_name,
245
+ )
246
+ )
247
+ except Exception as e:
248
+ logger.debug(f"Error in visitFunctioncall: {e}")
249
+
250
+ return self.visitChildren(ctx)
251
+
252
+ def _check_deprecated_method_calls(self, ctx: LuaParser.FunctioncallContext):
253
+ """Check for deprecated method calls like .turn() or .run()."""
254
+ # Method calls have the form: varOrExp nameAndArgs+
255
+ # The nameAndArgs contains ':' NAME args for method calls
256
+ # We need to check if any nameAndArgs contains 'turn' or 'run'
257
+
258
+ # Get the full text of the function call
259
+ full_text = ctx.getText()
260
+
261
+ # Check for .turn() pattern (method call with dot notation)
262
+ if ".turn(" in full_text or ":turn(" in full_text:
263
+ self.errors.append(
264
+ ValidationMessage(
265
+ level="error",
266
+ message='The .turn() method is deprecated. Use callable syntax instead: agent() or agent({message = "..."})',
267
+ location=(self.current_line, self.current_col),
268
+ declaration="Agent.turn",
269
+ )
270
+ )
271
+
272
+ # Check for .run() pattern on agents
273
+ if ".run(" in full_text or ":run(" in full_text:
274
+ # Try to determine if this is an agent call (not procedure or other types)
275
+ # If the text contains "Agent(" it's likely an agent method
276
+ if "Agent(" in full_text or ctx.getText().startswith("agent"):
277
+ self.errors.append(
278
+ ValidationMessage(
279
+ level="error",
280
+ message='The .run() method on agents is deprecated. Use callable syntax instead: agent() or agent({message = "..."})',
281
+ location=(self.current_line, self.current_col),
282
+ declaration="Agent.run",
283
+ )
284
+ )
285
+
286
+ def _extract_literal_value(self, exp):
287
+ """Extract a literal value from an expression node."""
288
+ if not exp:
289
+ return None
290
+
291
+ # Check for string literals
292
+ if exp.string():
293
+ string_ctx = exp.string()
294
+ # Extract the string value (remove quotes)
295
+ if string_ctx.NORMALSTRING():
296
+ text = string_ctx.NORMALSTRING().getText()
297
+ # Remove surrounding quotes
298
+ if text.startswith('"') and text.endswith('"'):
299
+ return text[1:-1]
300
+ elif text.startswith("'") and text.endswith("'"):
301
+ return text[1:-1]
302
+ elif string_ctx.CHARSTRING():
303
+ text = string_ctx.CHARSTRING().getText()
304
+ # Remove surrounding quotes
305
+ if text.startswith('"') and text.endswith('"'):
306
+ return text[1:-1]
307
+ elif text.startswith("'") and text.endswith("'"):
308
+ return text[1:-1]
309
+
310
+ # Check for number literals
311
+ if exp.number():
312
+ number_ctx = exp.number()
313
+ if number_ctx.INT():
314
+ return int(number_ctx.INT().getText())
315
+ elif number_ctx.FLOAT():
316
+ return float(number_ctx.FLOAT().getText())
317
+
318
+ # Check for boolean literals
319
+ if exp.getText() == "true":
320
+ return True
321
+ elif exp.getText() == "false":
322
+ return False
323
+
324
+ # Check for nil
325
+ if exp.getText() == "nil":
326
+ return None
327
+
328
+ # Default to the text representation
329
+ return exp.getText()
330
+
331
+ def _extract_function_name(self, ctx: LuaParser.FunctioncallContext) -> Optional[str]:
332
+ """Extract function name from parse tree."""
333
+ # The function name is the first child of functioncall
334
+ # Look for a terminal node with text
335
+ for i in range(ctx.getChildCount()):
336
+ child = ctx.getChild(i)
337
+ if hasattr(child, "symbol"):
338
+ # It's a terminal node
339
+ text = child.getText()
340
+ if text and text.isidentifier():
341
+ return text
342
+
343
+ # Fallback: try varOrExp approach
344
+ if ctx.varOrExp():
345
+ var_or_exp = ctx.varOrExp()
346
+ # varOrExp: var | '(' exp ')'
347
+ if var_or_exp.var():
348
+ var_ctx = var_or_exp.var()
349
+ # var: (NAME | '(' exp ')' varSuffix) varSuffix*
350
+ if var_ctx.NAME():
351
+ return var_ctx.NAME().getText()
352
+
353
+ return None
354
+
355
+ def _process_dsl_call(self, func_name: str, ctx: LuaParser.FunctioncallContext):
356
+ """Extract arguments and register declaration."""
357
+ args = self._extract_arguments(ctx)
358
+
359
+ if func_name == "name":
360
+ if args and len(args) >= 1:
361
+ self.builder.set_name(args[0])
362
+ elif func_name == "version":
363
+ if args and len(args) >= 1:
364
+ self.builder.set_version(args[0])
365
+ elif func_name == "Agent": # CamelCase only
366
+ # Skip Agent calls inside function bodies - they're runtime lookups, not declarations
367
+ if self.in_function_body:
368
+ return self.visitChildren(ctx)
369
+
370
+ if args and len(args) >= 1: # Support curried syntax with just name
371
+ agent_name = args[0]
372
+ # Check if this is a declaration (has config) or a lookup (just name)
373
+ if len(args) >= 2 and isinstance(args[1], dict):
374
+ # DEPRECATED: Curried syntax Agent "name" { config }
375
+ # Raise validation error
376
+ self.errors.append(
377
+ ValidationMessage(
378
+ level="error",
379
+ message=f'Curried syntax Agent "{agent_name}" {{...}} is deprecated. Use assignment syntax: {agent_name} = Agent {{...}}',
380
+ location=(self.current_line, self.current_col),
381
+ declaration="Agent",
382
+ )
383
+ )
384
+ elif len(args) == 1 and isinstance(agent_name, str):
385
+ # DEPRECATED: Agent("name") lookup or curried declaration
386
+ # This is now invalid - users should use variable references
387
+ self.errors.append(
388
+ ValidationMessage(
389
+ level="error",
390
+ message=f'Agent("{agent_name}") lookup syntax is deprecated. Declare the agent with assignment: {agent_name} = Agent {{...}}, then use {agent_name}() to call it.',
391
+ location=(self.current_line, self.current_col),
392
+ declaration="Agent",
393
+ )
394
+ )
395
+ elif func_name == "Model": # CamelCase only
396
+ if args and len(args) >= 1:
397
+ # Check if this is assignment syntax (single dict arg) or curried syntax (name + dict)
398
+ if len(args) == 1 and isinstance(args[0], dict):
399
+ # Assignment syntax: my_model = Model {config}
400
+ # Generate a temp name for validation
401
+ import uuid
402
+
403
+ temp_name = f"_temp_model_{uuid.uuid4().hex[:8]}"
404
+ self.builder.register_model(temp_name, args[0])
405
+ elif len(args) >= 2 and isinstance(args[1], dict):
406
+ # Curried syntax: Model "name" {config}
407
+ config = args[1]
408
+ self.builder.register_model(args[0], config)
409
+ elif isinstance(args[0], str):
410
+ # Just a name, register with empty config
411
+ self.builder.register_model(args[0], {})
412
+ elif func_name == "Procedure": # CamelCase only
413
+ # Supports multiple syntax variants:
414
+ # 1. Unnamed (new): Procedure { config with function }
415
+ # 2. Named (curried): Procedure "name" { config }
416
+ # 3. Named (old): procedure("name", {config}, function)
417
+ # Note: args may contain None for unparseable expressions (like functions)
418
+ if args and len(args) >= 1:
419
+ # Check if first arg is a table (unnamed procedure syntax)
420
+ # Tables are parsed as dict if they have named fields, or list if only positional
421
+ if isinstance(args[0], dict):
422
+ # Unnamed syntax: Procedure {...} with named fields
423
+ # e.g., Procedure { output = {...}, function(input) ... end }
424
+ proc_name = "main"
425
+ config = args[0]
426
+ elif isinstance(args[0], list):
427
+ # Unnamed syntax: Procedure {...} with only function (no named fields)
428
+ # e.g., Procedure { function(input) ... end }
429
+ # The list contains [None] for the unparseable function
430
+ proc_name = "main"
431
+ config = {} # No extractable config from function-only table
432
+ elif isinstance(args[0], str):
433
+ # Named syntax: Procedure "name" {...}
434
+ proc_name = args[0]
435
+ config = args[1] if len(args) >= 2 and isinstance(args[1], dict) else None
436
+ else:
437
+ # Invalid syntax
438
+ return
439
+
440
+ # Register that this named procedure exists (validation needs to know about 'main')
441
+ # We use a stub/placeholder since the actual function will be registered at runtime
442
+ self.builder.register_named_procedure(
443
+ proc_name,
444
+ None, # Function not available during validation
445
+ {}, # Input schema extracted below
446
+ {}, # Output schema extracted below
447
+ {}, # State schema extracted below
448
+ )
449
+
450
+ # Extract schemas from config if available
451
+ if config is not None and isinstance(config, dict):
452
+ # Extract inline input schema
453
+ if "input" in config and isinstance(config["input"], dict):
454
+ self.builder.register_input_schema(config["input"])
455
+
456
+ # Extract inline output schema
457
+ if "output" in config and isinstance(config["output"], dict):
458
+ self.builder.register_output_schema(config["output"])
459
+
460
+ # Extract inline state schema
461
+ if "state" in config and isinstance(config["state"], dict):
462
+ self.builder.register_state_schema(config["state"])
463
+ elif func_name == "Prompt": # CamelCase
464
+ if args and len(args) >= 2:
465
+ self.builder.register_prompt(args[0], args[1])
466
+ elif func_name == "Hitl": # CamelCase
467
+ if args and len(args) >= 2:
468
+ self.builder.register_hitl(args[0], args[1] if isinstance(args[1], dict) else {})
469
+ elif func_name == "Specification": # CamelCase
470
+ # Either:
471
+ # - Specification([[ Gherkin text ]]) (alias for Specifications)
472
+ # - Specification("name", { ... }) (structured form)
473
+ if args and len(args) == 1:
474
+ self.builder.register_specifications(args[0])
475
+ elif args and len(args) >= 2:
476
+ self.builder.register_specification(
477
+ args[0], args[1] if isinstance(args[1], list) else []
478
+ )
479
+ elif func_name == "Specifications": # CamelCase
480
+ # Specifications([[ Gherkin text ]]) (plural form; singular is Specification([[...]]))
481
+ if args and len(args) >= 1:
482
+ self.builder.register_specifications(args[0])
483
+ elif func_name == "Step": # CamelCase
484
+ # Step("step text", function() ... end)
485
+ if args and len(args) >= 2:
486
+ self.builder.register_custom_step(args[0], args[1])
487
+ elif func_name == "Evaluation": # CamelCase
488
+ # Either:
489
+ # - Evaluation({ runs = 10, parallel = true }) (simple config)
490
+ # - Evaluation({ dataset = {...}, evaluators = {...}, ... }) (alias for Evaluations)
491
+ if args and len(args) >= 1 and isinstance(args[0], dict):
492
+ cfg = args[0]
493
+ if any(k in cfg for k in ("dataset", "dataset_file", "evaluators", "thresholds")):
494
+ self.builder.register_evaluations(cfg)
495
+ else:
496
+ self.builder.set_evaluation_config(cfg)
497
+ elif args and len(args) >= 1:
498
+ self.builder.set_evaluation_config({})
499
+ elif func_name == "Evaluations": # CamelCase
500
+ # Evaluation(s)({ dataset = {...}, evaluators = {...} })
501
+ if args and len(args) >= 1:
502
+ self.builder.register_evaluations(args[0] if isinstance(args[0], dict) else {})
503
+ elif func_name == "default_provider":
504
+ if args and len(args) >= 1:
505
+ self.builder.set_default_provider(args[0])
506
+ elif func_name == "default_model":
507
+ if args and len(args) >= 1:
508
+ self.builder.set_default_model(args[0])
509
+ elif func_name == "return_prompt":
510
+ if args and len(args) >= 1:
511
+ self.builder.set_return_prompt(args[0])
512
+ elif func_name == "error_prompt":
513
+ if args and len(args) >= 1:
514
+ self.builder.set_error_prompt(args[0])
515
+ elif func_name == "status_prompt":
516
+ if args and len(args) >= 1:
517
+ self.builder.set_status_prompt(args[0])
518
+ elif func_name == "async":
519
+ if args and len(args) >= 1:
520
+ self.builder.set_async(args[0])
521
+ elif func_name == "max_depth":
522
+ if args and len(args) >= 1:
523
+ self.builder.set_max_depth(args[0])
524
+ elif func_name == "max_turns":
525
+ if args and len(args) >= 1:
526
+ self.builder.set_max_turns(args[0])
527
+ elif func_name == "input":
528
+ # Top-level input schema for script mode: input { field1 = ..., field2 = ... }
529
+ if args and len(args) >= 1 and isinstance(args[0], dict):
530
+ self.builder.register_top_level_input(args[0])
531
+ elif func_name == "output":
532
+ # Top-level output schema for script mode: output { field1 = ..., field2 = ... }
533
+ if args and len(args) >= 1 and isinstance(args[0], dict):
534
+ self.builder.register_top_level_output(args[0])
535
+ elif func_name == "Tool": # CamelCase only
536
+ # Curried syntax (Tool "name" {...} / Tool("name", ...)) is not supported.
537
+ # Use assignment syntax: my_tool = Tool { ... }.
538
+ if args and len(args) >= 1 and isinstance(args[0], str):
539
+ tool_name = args[0]
540
+ self.errors.append(
541
+ ValidationMessage(
542
+ level="error",
543
+ message=(
544
+ f'Curried Tool syntax is not supported: Tool "{tool_name}" {{...}}. '
545
+ f"Use assignment syntax: {tool_name} = Tool {{...}}."
546
+ ),
547
+ location=(self.current_line, self.current_col),
548
+ declaration="Tool",
549
+ )
550
+ )
551
+ elif func_name == "Toolset": # CamelCase only
552
+ # Toolset("name", {config})
553
+ # or new curried syntax: Toolset "name" { config }
554
+ if args and len(args) >= 1: # Support curried syntax
555
+ # First arg must be name (string)
556
+ if isinstance(args[0], str):
557
+ toolset_name = args[0]
558
+ config = args[1] if len(args) >= 2 and isinstance(args[1], dict) else {}
559
+ # Register the toolset (validation only, no runtime impl yet)
560
+ self.builder.register_toolset(toolset_name, config)
561
+
562
+ def _extract_arguments(self, ctx: LuaParser.FunctioncallContext) -> list:
563
+ """Extract function arguments from parse tree.
564
+
565
+ Returns a list where:
566
+ - Parseable expressions are included as Python values
567
+ - Unparseable expressions (like functions) are included as None placeholders
568
+ This allows checking total argument count for validation.
569
+ """
570
+ args = []
571
+
572
+ # functioncall has args() children
573
+ # args: '(' explist? ')' | tableconstructor | LiteralString
574
+
575
+ args_list = ctx.args()
576
+ if not args_list:
577
+ return args
578
+
579
+ # Check if this is a method call chain by looking for '.' or ':' between args
580
+ # For Agent("name").turn({...}), we should only extract "name"
581
+ # For Procedure "name" {...}, we should extract both "name" and {...}
582
+ is_method_chain = False
583
+ if len(args_list) > 1:
584
+ # Check if there's a method access between the first two args
585
+ # Method chains have pattern: func(arg1).method(arg2)
586
+ # Shorthand has pattern: func arg1 arg2
587
+
588
+ # Look at the children of the functioncall context to see if there's
589
+ # a '.' or ':' token between the first and second args
590
+ found_first_args = False
591
+ for i in range(ctx.getChildCount()):
592
+ child = ctx.getChild(i)
593
+ # Check if this is the first args
594
+ if child == args_list[0]:
595
+ found_first_args = True
596
+ elif found_first_args and child == args_list[1]:
597
+ # We've reached the second args without finding . or :
598
+ # So this is NOT a method chain
599
+ break
600
+ elif found_first_args and hasattr(child, "symbol"):
601
+ # Check if this is a . or : token
602
+ token_text = child.getText()
603
+ if token_text in [".", ":"]:
604
+ is_method_chain = True
605
+ break
606
+
607
+ # Process arguments
608
+ if is_method_chain:
609
+ # Only process first args for method chains like Agent("name").turn(...)
610
+ args_to_process = [args_list[0]]
611
+ else:
612
+ # Process all args for shorthand syntax like Procedure "name" {...}
613
+ args_to_process = args_list
614
+
615
+ for args_ctx in args_to_process:
616
+ # Check for different argument types
617
+ if args_ctx.explist():
618
+ # Regular function call with expression list
619
+ explist = args_ctx.explist()
620
+ for exp in explist.exp():
621
+ value = self._parse_expression(exp)
622
+ # Include None placeholders to preserve argument count
623
+ args.append(value)
624
+ elif args_ctx.tableconstructor():
625
+ # Table constructor argument
626
+ table = self._parse_table_constructor(args_ctx.tableconstructor())
627
+ args.append(table)
628
+ elif args_ctx.string():
629
+ # String literal argument
630
+ string_val = self._parse_string(args_ctx.string())
631
+ args.append(string_val)
632
+
633
+ return args
634
+
635
+ def _parse_expression(self, ctx: LuaParser.ExpContext) -> Any:
636
+ """Parse an expression to a Python value."""
637
+ if not ctx:
638
+ return None
639
+
640
+ # Detect field.<type>{...} builder syntax so we can preserve schema info
641
+ prefix = ctx.prefixexp()
642
+ if prefix and prefix.functioncall():
643
+ func_ctx = prefix.functioncall()
644
+ name_tokens = [t.getText() for t in func_ctx.NAME()]
645
+
646
+ # field.string{required = true, ...}
647
+ if len(name_tokens) >= 2 and name_tokens[0] == "field":
648
+ field_type = name_tokens[-1]
649
+
650
+ # Default field definition
651
+ field_def = {"type": field_type, "required": False}
652
+
653
+ # Parse options table if present
654
+ if func_ctx.args():
655
+ # We only expect a single args() entry for the builder
656
+ first_arg = func_ctx.args(0)
657
+ if first_arg.tableconstructor():
658
+ options = self._parse_table_constructor(first_arg.tableconstructor())
659
+ if isinstance(options, dict):
660
+ field_def["required"] = bool(options.get("required", False))
661
+ if "default" in options and not field_def["required"]:
662
+ field_def["default"] = options["default"]
663
+ if "description" in options:
664
+ field_def["description"] = options["description"]
665
+ if "enum" in options:
666
+ field_def["enum"] = options["enum"]
667
+
668
+ return field_def
669
+
670
+ # Check for literals
671
+ if ctx.number():
672
+ return self._parse_number(ctx.number())
673
+ elif ctx.string():
674
+ return self._parse_string(ctx.string())
675
+ elif ctx.NIL():
676
+ return None
677
+ elif ctx.FALSE():
678
+ return False
679
+ elif ctx.TRUE():
680
+ return True
681
+ elif ctx.tableconstructor():
682
+ return self._parse_table_constructor(ctx.tableconstructor())
683
+
684
+ # For other expressions, return None (can't evaluate without execution)
685
+ return None
686
+
687
+ def _parse_string(self, ctx: LuaParser.StringContext) -> str:
688
+ """Parse string context to Python string."""
689
+ if not ctx:
690
+ return ""
691
+
692
+ # string has NORMALSTRING, CHARSTRING, or LONGSTRING
693
+ if ctx.NORMALSTRING():
694
+ return self._parse_string_token(ctx.NORMALSTRING())
695
+ elif ctx.CHARSTRING():
696
+ return self._parse_string_token(ctx.CHARSTRING())
697
+ elif ctx.LONGSTRING():
698
+ return self._parse_string_token(ctx.LONGSTRING())
699
+
700
+ return ""
701
+
702
+ def _parse_string_token(self, token) -> str:
703
+ """Parse string token to Python string."""
704
+ text = token.getText()
705
+
706
+ # Handle different Lua string formats
707
+ if text.startswith("[[") and text.endswith("]]"):
708
+ # Long string literal
709
+ return text[2:-2]
710
+ elif text.startswith('"') and text.endswith('"'):
711
+ # Double-quoted string
712
+ content = text[1:-1]
713
+ content = content.replace("\\n", "\n")
714
+ content = content.replace("\\t", "\t")
715
+ content = content.replace('\\"', '"')
716
+ content = content.replace("\\\\", "\\")
717
+ return content
718
+ elif text.startswith("'") and text.endswith("'"):
719
+ # Single-quoted string
720
+ content = text[1:-1]
721
+ content = content.replace("\\n", "\n")
722
+ content = content.replace("\\t", "\t")
723
+ content = content.replace("\\'", "'")
724
+ content = content.replace("\\\\", "\\")
725
+ return content
726
+
727
+ return text
728
+
729
+ def _parse_table_constructor(self, ctx: LuaParser.TableconstructorContext) -> dict:
730
+ """Parse Lua table constructor to Python dict."""
731
+ result = {}
732
+ array_items = []
733
+
734
+ if not ctx or not ctx.fieldlist():
735
+ # Empty table
736
+ return [] # Return empty list for empty tables (matches runtime behavior)
737
+
738
+ fieldlist = ctx.fieldlist()
739
+ for field in fieldlist.field():
740
+ # field: '[' exp ']' '=' exp | NAME '=' exp | exp
741
+ if field.NAME():
742
+ # Named field: NAME '=' exp
743
+ key = field.NAME().getText()
744
+ value = self._parse_expression(field.exp(0))
745
+
746
+ # Check for old type syntax in field definitions
747
+ # Only check if this looks like a field definition (has type + required/description)
748
+ if (
749
+ key == "type"
750
+ and isinstance(value, str)
751
+ and value in ["string", "number", "boolean", "integer", "array", "object"]
752
+ ):
753
+ # Check if the parent table also has 'required' or 'description' keys
754
+ # which would indicate this is a field definition, not a JSON schema or evaluator config
755
+ parent_text = ctx.getText() if ctx else ""
756
+ # Skip if this is part of JSON schema or evaluator configuration
757
+ if (
758
+ "json_schema" not in parent_text
759
+ and "evaluators" not in parent_text
760
+ and "properties" not in parent_text # JSON schema has 'properties'
761
+ and ("required=" in parent_text or "description=" in parent_text)
762
+ ):
763
+ self.errors.append(
764
+ ValidationMessage(
765
+ level="error",
766
+ message=f"Old type syntax detected. Use field.{value}{{}} instead of {{type = '{value}'}}",
767
+ line=field.start.line if field.start else 0,
768
+ column=field.start.column if field.start else 0,
769
+ )
770
+ )
771
+
772
+ result[key] = value
773
+ elif len(field.exp()) == 2:
774
+ # Indexed field: '[' exp ']' '=' exp
775
+ # Skip for now (complex)
776
+ pass
777
+ elif len(field.exp()) == 1:
778
+ # Array element: exp
779
+ value = self._parse_expression(field.exp(0))
780
+ array_items.append(value)
781
+
782
+ # If we only have array items, return as list
783
+ if array_items and not result:
784
+ return array_items
785
+
786
+ # If we have both, prefer dict (shouldn't happen in DSL)
787
+ if array_items:
788
+ # Mixed table - add array items with numeric keys
789
+ for i, item in enumerate(array_items, 1):
790
+ result[i] = item
791
+
792
+ return result if result else []
793
+
794
+ def _parse_number(self, ctx: LuaParser.NumberContext) -> float:
795
+ """Parse Lua number to Python number."""
796
+ text = ctx.getText()
797
+
798
+ # Try integer first
799
+ try:
800
+ return int(text)
801
+ except ValueError:
802
+ pass
803
+
804
+ # Try float
805
+ try:
806
+ return float(text)
807
+ except ValueError:
808
+ pass
809
+
810
+ # Try hex
811
+ if text.startswith("0x") or text.startswith("0X"):
812
+ try:
813
+ return int(text, 16)
814
+ except ValueError:
815
+ pass
816
+
817
+ return 0