tactus 0.31.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 (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.0.dist-info/METADATA +1809 -0
  157. tactus-0.31.0.dist-info/RECORD +160 -0
  158. tactus-0.31.0.dist-info/WHEEL +4 -0
  159. tactus-0.31.0.dist-info/entry_points.txt +2 -0
  160. tactus-0.31.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,2117 @@
1
+ """
2
+ DSL stub functions for Lua execution.
3
+
4
+ These functions are injected into the Lua sandbox before executing
5
+ .tac files. They populate the registry with declarations.
6
+
7
+ Current Syntax (assignment-based):
8
+ -- Tools: import from stdlib or define custom
9
+ local done = require("tactus.tools.done")
10
+ multiply = Tool { input = {...}, function(args) ... end }
11
+
12
+ -- Agents: assign to variable, tools as variable refs
13
+ greeter = Agent {
14
+ provider = "openai",
15
+ system_prompt = "...",
16
+ tools = {done, multiply}
17
+ }
18
+
19
+ -- Procedure: unnamed defaults to "main"
20
+ Procedure {
21
+ output = { result = field.string{required = true} },
22
+ function(input)
23
+ greeter()
24
+ return { result = "done" }
25
+ end
26
+ }
27
+
28
+ Agent/Tool calls use direct variable access:
29
+ greeter() -- Execute agent turn (callable syntax)
30
+ multiply.called() -- Check if tool was called
31
+ done.last_result() -- Get last tool result
32
+ """
33
+
34
+ from typing import Any, Callable, Dict
35
+
36
+ from .registry import RegistryBuilder
37
+ from tactus.primitives.handles import AgentHandle, ModelHandle, AgentLookup, ModelLookup
38
+
39
+
40
+ # NEW Builder pattern for field types - moved outside function for import
41
+ class FieldDefinition(dict):
42
+ """Special marker class for new field.type{} syntax."""
43
+
44
+ pass
45
+
46
+
47
+ def lua_table_to_dict(lua_table):
48
+ """
49
+ Convert lupa table to Python dict or list recursively.
50
+
51
+ Handles:
52
+ - Nested tables
53
+ - Arrays (tables with numeric indices)
54
+ - Empty tables (converted to empty list)
55
+ - Mixed tables
56
+ - Primitive values
57
+ """
58
+ if lua_table is None:
59
+ return {}
60
+
61
+ # Check if it's a lupa table
62
+ if not hasattr(lua_table, "items"):
63
+ # It's a primitive value, return as-is
64
+ return lua_table
65
+
66
+ try:
67
+ # Get all keys
68
+ keys = list(lua_table.keys())
69
+
70
+ # Empty table - return empty list (common for tools = {})
71
+ if not keys:
72
+ return []
73
+
74
+ # Check if it's an array (all keys are consecutive integers starting from 1)
75
+ if all(isinstance(k, int) for k in keys):
76
+ sorted_keys = sorted(keys)
77
+ if sorted_keys == list(range(1, len(keys) + 1)):
78
+ # It's an array
79
+ return [
80
+ (
81
+ lua_table_to_dict(lua_table[k])
82
+ if hasattr(lua_table[k], "items")
83
+ else lua_table[k]
84
+ )
85
+ for k in sorted_keys
86
+ ]
87
+
88
+ # It's a dictionary
89
+ result = {}
90
+ for key, value in lua_table.items():
91
+ # Recursively convert nested tables
92
+ if hasattr(value, "items"):
93
+ result[key] = lua_table_to_dict(value)
94
+ else:
95
+ result[key] = value
96
+ return result
97
+
98
+ except (AttributeError, TypeError):
99
+ # Fallback: return as-is
100
+ return lua_table
101
+
102
+
103
+ def _normalize_schema(schema):
104
+ """Convert empty list to empty dict (lua_table_to_dict converts {} to [])."""
105
+ if isinstance(schema, list) and len(schema) == 0:
106
+ return {}
107
+ return schema
108
+
109
+
110
+ def create_dsl_stubs(
111
+ builder: RegistryBuilder,
112
+ tool_primitive: Any = None,
113
+ mock_manager: Any = None,
114
+ runtime_context: Dict[str, Any] | None = None,
115
+ ) -> dict[str, Callable]:
116
+ """
117
+ Create DSL stub functions that populate the registry.
118
+
119
+ These functions are injected into the Lua environment before
120
+ executing the .tac file.
121
+
122
+ Args:
123
+ builder: RegistryBuilder to register declarations
124
+ tool_primitive: Optional ToolPrimitive for creating callable ToolHandles
125
+ mock_manager: Optional MockManager for checking module mocks
126
+ runtime_context: Optional runtime context for immediate agent creation
127
+ (includes registry, mock_manager, execution_context, etc.)
128
+
129
+ Returns:
130
+ Dict of DSL functions to inject into Lua, including:
131
+ - Lowercase definition functions: agent, tool, procedure, model
132
+ - Uppercase lookup functions: Agent, Tool, Model
133
+ """
134
+ # Registries for handle lookup
135
+ _agent_registry: Dict[str, AgentHandle] = {}
136
+ _tool_registry: Dict[str, Any] = {} # ToolHandle instances
137
+ _model_registry: Dict[str, ModelHandle] = {}
138
+
139
+ # Store runtime context for immediate agent creation
140
+ _runtime_context = runtime_context or {}
141
+
142
+ # Global registry for named procedure stubs to find their implementations
143
+ _procedure_registry = {}
144
+
145
+ def _process_procedure_config(name: str | None, config, procedure_registry: dict):
146
+ """
147
+ Process procedure config and register the procedure.
148
+
149
+ This helper extracts the function from config, registers the procedure,
150
+ and returns a stub for later invocation.
151
+
152
+ Args:
153
+ name: Procedure name (defaults to "main" if None)
154
+ config: Lua table with procedure config
155
+ procedure_registry: Registry to store procedure stubs
156
+
157
+ Returns:
158
+ NamedProcedureStub for the registered procedure
159
+ """
160
+ # Default to "main" if no name provided (for assignment-based syntax)
161
+ if name is None:
162
+ name = "main"
163
+ # Extract the function from the raw Lua table before conversion
164
+ # In Lua tables, unnamed elements are stored with numeric indices (1-based)
165
+ run_fn = None
166
+
167
+ # Check for function in array part of table (numeric indices)
168
+ if hasattr(config, "__getitem__"):
169
+ # Try to get function from numeric indices (Lua uses 1-based indexing)
170
+ for i in range(1, 10): # Check first few positions
171
+ try:
172
+ item = config[i]
173
+ if callable(item):
174
+ run_fn = item
175
+ # Remove from table so it doesn't appear in config_dict
176
+ config[i] = None
177
+ break
178
+ except (KeyError, TypeError):
179
+ break
180
+
181
+ # Now convert to dict (excluding the function we removed)
182
+ config_dict = lua_table_to_dict(config)
183
+ # Normalize empty config (lua {} -> python [])
184
+ if isinstance(config_dict, list) and len(config_dict) == 0:
185
+ config_dict = {}
186
+
187
+ # If we got a list with None values from removing function, clean it up
188
+ if isinstance(config_dict, list):
189
+ config_dict = [x for x in config_dict if x is not None]
190
+ if len(config_dict) == 0:
191
+ config_dict = {}
192
+
193
+ # If no function found in array part, check for legacy 'run' field
194
+ if run_fn is None:
195
+ run_fn = config_dict.pop("run", None)
196
+
197
+ if run_fn is None:
198
+ raise TypeError(
199
+ f"Procedure '{name}' requires a function. "
200
+ f"Use: Procedure {{ input = {{...}}, function() ... end }}"
201
+ )
202
+
203
+ # Extract schemas (normalize empty lists to dicts)
204
+ input_schema = _normalize_schema(config_dict.get("input", {}))
205
+ output_schema = _normalize_schema(config_dict.get("output", {}))
206
+ state_schema = _normalize_schema(config_dict.get("state", {}))
207
+
208
+ # Also extract dependencies schema if present
209
+ dependencies_schema = _normalize_schema(config_dict.get("dependencies", {}))
210
+
211
+ # Register named procedure (pass dependencies as part of state for now)
212
+ # Future: Add dedicated dependencies field to registry
213
+ if dependencies_schema:
214
+ state_schema["_dependencies"] = dependencies_schema
215
+
216
+ builder.register_named_procedure(name, run_fn, input_schema, output_schema, state_schema)
217
+
218
+ # Return a stub that will delegate to the registry at call time
219
+ class NamedProcedureStub:
220
+ """
221
+ Stub that delegates to the actual ProcedureCallable when called.
222
+ This gets replaced during runtime initialization.
223
+ """
224
+
225
+ def __init__(self, proc_name, registry):
226
+ self.name = proc_name
227
+ self.registry = registry
228
+
229
+ def __call__(self, *args):
230
+ # Look up the real implementation from the registry
231
+ if self.name in self.registry:
232
+ return self.registry[self.name](*args)
233
+ else:
234
+ raise RuntimeError(f"Named procedure '{self.name}' not initialized yet")
235
+
236
+ stub = NamedProcedureStub(name, procedure_registry)
237
+ procedure_registry[name] = stub # Store stub temporarily
238
+ return stub
239
+
240
+ def _procedure(name_or_config=None, config=None, run_fn=None):
241
+ """
242
+ Procedure definition supporting multiple syntax variants.
243
+
244
+ Unnamed syntax (becomes the main entry point):
245
+ Procedure {
246
+ input = {...},
247
+ output = {...},
248
+ function(input) ... end
249
+ }
250
+ -- This automatically becomes "main" procedure
251
+
252
+ Named procedure syntax:
253
+ main = Procedure {
254
+ input = {...},
255
+ output = {...},
256
+ function(input)
257
+ return {result = input.value * 2}
258
+ end
259
+ }
260
+
261
+ Sub-procedure syntax:
262
+ helper = Procedure {
263
+ input = {...},
264
+ output = {...},
265
+ function(input)
266
+ return {result = input.value * 2}
267
+ end
268
+ }
269
+
270
+ Note: Only ONE unnamed Procedure is allowed per file.
271
+ Multiple unnamed Procedures will result in a validation error.
272
+
273
+ Args:
274
+ name_or_config: Must be None (assignment-based syntax)
275
+ config: Procedure configuration table
276
+ run_fn: Not used (kept for compatibility)
277
+
278
+ Returns:
279
+ ProcedureStub that will be registered when assigned to a variable
280
+ """
281
+ # Check if this is assignment-based syntax: main = Procedure { ... }
282
+ # In this case, name_or_config is the table/config
283
+ if name_or_config is not None and hasattr(name_or_config, "items"):
284
+ # This is: variable = Procedure { ... }
285
+ # We can't know the variable name here, so return a callable stub
286
+ # that will process the config when assigned
287
+ config_table = name_or_config
288
+ return _process_procedure_config(None, config_table, _procedure_registry)
289
+
290
+ # First argument must be a string name for curried syntax
291
+ name = name_or_config
292
+ if name is not None and not isinstance(name, str):
293
+ raise TypeError(
294
+ f"Procedure() first argument must be a string name, got {type(name).__name__}"
295
+ )
296
+
297
+ # Check if this is old-style 3-argument call
298
+ if config is not None or run_fn is not None:
299
+ # Old syntax: procedure("name", {config}, function)
300
+ # Convert config if needed
301
+ if config is not None:
302
+ config_dict = lua_table_to_dict(config)
303
+ else:
304
+ config_dict = {}
305
+
306
+ # Normalize empty config (lua {} -> python [])
307
+ if isinstance(config_dict, list) and len(config_dict) == 0:
308
+ config_dict = {}
309
+
310
+ if run_fn is None:
311
+ raise TypeError(
312
+ f"procedure '{name}' requires a function in old syntax. "
313
+ f"Use: procedure('{name}', {{config}}, function() ... end)"
314
+ )
315
+
316
+ # Extract schemas (normalize empty lists to dicts)
317
+ input_schema = _normalize_schema(config_dict.get("input", {}))
318
+ output_schema = _normalize_schema(config_dict.get("output", {}))
319
+ state_schema = _normalize_schema(config_dict.get("state", {}))
320
+
321
+ # Register named procedure
322
+ builder.register_named_procedure(
323
+ name, run_fn, input_schema, output_schema, state_schema
324
+ )
325
+
326
+ # Return a stub that will delegate to the registry at call time
327
+ class NamedProcedureStub:
328
+ """
329
+ Stub that delegates to the actual ProcedureCallable when called.
330
+ This gets replaced during runtime initialization.
331
+ """
332
+
333
+ def __init__(self, proc_name, registry):
334
+ self.name = proc_name
335
+ self.registry = registry
336
+
337
+ def __call__(self, *args):
338
+ # Look up the real implementation from the registry
339
+ if self.name in self.registry:
340
+ return self.registry[self.name](*args)
341
+ else:
342
+ raise RuntimeError(f"Named procedure '{self.name}' not initialized yet")
343
+
344
+ stub = NamedProcedureStub(name, _procedure_registry)
345
+ _procedure_registry[name] = stub # Store stub temporarily
346
+ return stub
347
+
348
+ # New curried syntax - return a function that accepts config
349
+ def accept_config(config):
350
+ """Accept config (with function as last unnamed element) and register procedure."""
351
+ return _process_procedure_config(name, config, _procedure_registry)
352
+
353
+ return accept_config
354
+
355
+ def _prompt(prompt_name: str, content: str) -> None:
356
+ """Register a prompt template."""
357
+ builder.register_prompt(prompt_name, content)
358
+
359
+ def _toolset(toolset_name: str, config=None):
360
+ """
361
+ Toolset definition supporting both old and new syntax.
362
+
363
+ Old syntax (deprecated):
364
+ Toolset("name", {config})
365
+
366
+ New syntax (curried):
367
+ Toolset "name" { config }
368
+
369
+ Supports multiple sources:
370
+ - Import all tools from a .tac file via use = "./helpers/math.tac"
371
+ - MCP server collection via use = "mcp.filesystem"
372
+ - Group existing tools via tools = ["tool1", "tool2"]
373
+
374
+ Args:
375
+ toolset_name: Name of the toolset
376
+ config: Optional config dict (for old syntax)
377
+
378
+ Returns:
379
+ Function that accepts config (new syntax) or None (old syntax)
380
+
381
+ Example (Import from file):
382
+ Toolset "math" { use = "./helpers/math.tac" }
383
+
384
+ Example (MCP server):
385
+ Toolset "filesystem" {
386
+ use = "mcp.filesystem",
387
+ include = {"read_file", "write_file"}, -- optional filter
388
+ exclude = {"delete_file"} -- optional filter
389
+ }
390
+
391
+ Example (Group existing tools):
392
+ Toolset "research" {
393
+ tools = {"search", "analyze", "summarize"}
394
+ }
395
+
396
+ Example (Inline Lua tools):
397
+ Toolset "custom" {
398
+ tools = {
399
+ {
400
+ name = "my_tool",
401
+ description = "A custom tool",
402
+ input = {text = field.string{required = true}},
403
+ function(args) return args.text:upper() end
404
+ }
405
+ }
406
+ }
407
+ """
408
+ # Check if this is old-style 2-argument call
409
+ if config is not None:
410
+ # Old syntax: Toolset("name", {config})
411
+ config_dict = lua_table_to_dict(config)
412
+
413
+ # Normalize empty config
414
+ if isinstance(config_dict, list) and len(config_dict) == 0:
415
+ config_dict = {}
416
+
417
+ # Register the toolset
418
+ builder.register_toolset(toolset_name, config_dict)
419
+ return None
420
+
421
+ # New curried syntax - return a function that accepts config
422
+ def accept_config(config):
423
+ """Accept config and register toolset."""
424
+ config_dict = lua_table_to_dict(config)
425
+
426
+ # Normalize empty config
427
+ if isinstance(config_dict, list) and len(config_dict) == 0:
428
+ config_dict = {}
429
+
430
+ # Register the toolset
431
+ builder.register_toolset(toolset_name, config_dict)
432
+
433
+ return accept_config
434
+
435
+ def _hitl(hitl_name: str, config) -> None:
436
+ """Register a HITL interaction point."""
437
+ builder.register_hitl(hitl_name, lua_table_to_dict(config))
438
+
439
+ def _model(model_name: str):
440
+ """
441
+ Curried model definition: model "name" { config }
442
+
443
+ First call captures name, returns config acceptor.
444
+
445
+ Args:
446
+ model_name: Model name (string identifier)
447
+
448
+ Returns:
449
+ Function that accepts config and returns ModelHandle
450
+
451
+ Example (Lua):
452
+ classifier = model "classifier" {
453
+ type = "pytorch",
454
+ path = "models/classifier.pt"
455
+ }
456
+
457
+ local result = Model("classifier").predict(data)
458
+ """
459
+
460
+ def accept_config(config) -> ModelHandle:
461
+ """Accept config and register model."""
462
+ config_dict = lua_table_to_dict(config)
463
+ builder.register_model(model_name, config_dict)
464
+
465
+ # Create and register handle for lookup
466
+ handle = ModelHandle(model_name)
467
+ _model_registry[model_name] = handle
468
+ return handle
469
+
470
+ return accept_config
471
+
472
+ def _specification(*args) -> None:
473
+ """Register BDD specs.
474
+
475
+ Supported forms:
476
+ - Specification([[ Gherkin text ]]) (alias for Specifications)
477
+ - Specification("name", { ... }) (structured form; legacy)
478
+ """
479
+ if len(args) == 1:
480
+ builder.register_specifications(args[0])
481
+ return
482
+ if len(args) >= 2:
483
+ spec_name, scenarios = args[0], args[1]
484
+ builder.register_specification(spec_name, lua_table_to_dict(scenarios))
485
+ return
486
+ raise TypeError("Specification expects either (gherkin_text) or (name, scenarios)")
487
+
488
+ def _specifications(gherkin_text: str) -> None:
489
+ """Register Gherkin BDD specifications."""
490
+ builder.register_specifications(gherkin_text)
491
+
492
+ def _step(step_text: str, lua_function) -> None:
493
+ """Register a custom step definition."""
494
+ builder.register_custom_step(step_text, lua_function)
495
+
496
+ def _evaluation(config) -> None:
497
+ """Register evaluation configuration.
498
+
499
+ Supported forms:
500
+ - Evaluation({ runs=..., parallel=... }) (single-run config)
501
+ - Evaluation({ dataset=..., evaluators=..., ...}) (alias for Evaluations)
502
+ """
503
+ config_dict = lua_table_to_dict(config or {})
504
+ if any(k in config_dict for k in ("dataset", "dataset_file", "evaluators", "thresholds")):
505
+ builder.register_evaluations(config_dict)
506
+ return
507
+ builder.set_evaluation_config(config_dict)
508
+
509
+ def _evaluations(config) -> None:
510
+ """Register Pydantic Evals evaluation configuration."""
511
+ builder.register_evaluations(lua_table_to_dict(config or {}))
512
+
513
+ def _default_provider(provider: str) -> None:
514
+ """Set default provider."""
515
+ builder.set_default_provider(provider)
516
+
517
+ def _default_model(model: str) -> None:
518
+ """Set default model."""
519
+ builder.set_default_model(model)
520
+
521
+ def _return_prompt(prompt: str) -> None:
522
+ """Set return prompt."""
523
+ builder.set_return_prompt(prompt)
524
+
525
+ def _error_prompt(prompt: str) -> None:
526
+ """Set error prompt."""
527
+ builder.set_error_prompt(prompt)
528
+
529
+ def _status_prompt(prompt: str) -> None:
530
+ """Set status prompt."""
531
+ builder.set_status_prompt(prompt)
532
+
533
+ def _async(enabled: bool) -> None:
534
+ """Set async execution flag."""
535
+ builder.set_async(enabled)
536
+
537
+ def _max_depth(depth: int) -> None:
538
+ """Set maximum recursion depth."""
539
+ builder.set_max_depth(depth)
540
+
541
+ def _max_turns(turns: int) -> None:
542
+ """Set maximum turns."""
543
+ builder.set_max_turns(turns)
544
+
545
+ # Built-in session filters
546
+ def _last_n(n: int) -> tuple:
547
+ """Filter to keep last N messages."""
548
+ return ("last_n", n)
549
+
550
+ def _token_budget(max_tokens: int) -> tuple:
551
+ """Filter by token budget."""
552
+ return ("token_budget", max_tokens)
553
+
554
+ def _by_role(role: str) -> tuple:
555
+ """Filter by message role."""
556
+ return ("by_role", role)
557
+
558
+ def _compose(*filters) -> tuple:
559
+ """Compose multiple filters."""
560
+ return ("compose", filters)
561
+
562
+ # Built-in spec matchers
563
+ def _contains(value: Any) -> tuple:
564
+ """Matcher: contains value."""
565
+ return ("contains", value)
566
+
567
+ def _equals(value: Any) -> tuple:
568
+ """Matcher: equals value."""
569
+ return ("equals", value)
570
+
571
+ def _matches(pattern: str) -> tuple:
572
+ """Matcher: matches regex pattern."""
573
+ return ("matches", pattern)
574
+
575
+ def _input(schema) -> None:
576
+ """
577
+ Top-level input schema declaration for script mode.
578
+
579
+ Used when there's no explicit main procedure - defines input
580
+ for the top-level script code.
581
+
582
+ Example:
583
+ input {
584
+ query = {type = "string", required = true},
585
+ limit = {type = "number", default = 10}
586
+ }
587
+ """
588
+ schema_dict = lua_table_to_dict(schema)
589
+ builder.register_top_level_input(schema_dict)
590
+
591
+ def _output(schema) -> None:
592
+ """
593
+ Top-level output schema declaration for script mode.
594
+
595
+ Used when there's no explicit main procedure - defines output
596
+ for the top-level script code.
597
+
598
+ Example:
599
+ output {
600
+ result = {type = "string", required = true},
601
+ count = {type = "number", required = true}
602
+ }
603
+ """
604
+ schema_dict = lua_table_to_dict(schema)
605
+ builder.register_top_level_output(schema_dict)
606
+
607
+ # Type shorthand helper functions
608
+ # OLD type functions - keeping temporarily until examples are updated
609
+ def _required(type_name: str, description: str = None) -> dict:
610
+ """Create a required field of given type."""
611
+ result = {"type": type_name, "required": True}
612
+ if description:
613
+ result["description"] = description
614
+ return result
615
+
616
+ def _string(default: str = None, description: str = None) -> dict:
617
+ """Create an optional string field."""
618
+ result = {"type": "string", "required": False}
619
+ if default is not None:
620
+ result["default"] = default
621
+ if description:
622
+ result["description"] = description
623
+ return result
624
+
625
+ def _number(default: float = None, description: str = None) -> dict:
626
+ """Create an optional number field."""
627
+ result = {"type": "number", "required": False}
628
+ if default is not None:
629
+ result["default"] = default
630
+ if description:
631
+ result["description"] = description
632
+ return result
633
+
634
+ def _boolean(default: bool = None, description: str = None) -> dict:
635
+ """Create an optional boolean field."""
636
+ result = {"type": "boolean", "required": False}
637
+ if default is not None:
638
+ result["default"] = default
639
+ if description:
640
+ result["description"] = description
641
+ return result
642
+
643
+ def _array(default: list = None, description: str = None) -> dict:
644
+ """Create an optional array field."""
645
+ result = {"type": "array", "required": False}
646
+ if default is not None:
647
+ result["default"] = default if default else []
648
+ if description:
649
+ result["description"] = description
650
+ return result
651
+
652
+ def _object(default: dict = None, description: str = None) -> dict:
653
+ """Create an optional object field."""
654
+ result = {"type": "object", "required": False}
655
+ if default is not None:
656
+ result["default"] = default if default else {}
657
+ if description:
658
+ result["description"] = description
659
+ return result
660
+
661
+ # NEW Builder pattern for field types
662
+ def _field_builder(field_type: str):
663
+ """Create a field builder for the given type."""
664
+
665
+ def build_field(options=None):
666
+ """Build a field with the given options."""
667
+ if options is None:
668
+ options = {}
669
+
670
+ # Convert Lua table to dict if needed
671
+ if hasattr(options, "items"):
672
+ options = lua_table_to_dict(options)
673
+
674
+ # Create a FieldDefinition (subclass of dict) to mark new syntax
675
+ result = FieldDefinition()
676
+ result["type"] = field_type
677
+
678
+ # Add required flag (default to false)
679
+ result["required"] = options.get("required", False)
680
+
681
+ # Add default value if provided and not required
682
+ if "default" in options and not result["required"]:
683
+ result["default"] = options["default"]
684
+
685
+ # Add description if provided
686
+ if "description" in options:
687
+ result["description"] = options["description"]
688
+
689
+ return result
690
+
691
+ return build_field
692
+
693
+ # Create the field table with builders for each type
694
+ field = {
695
+ "string": _field_builder("string"),
696
+ "number": _field_builder("number"),
697
+ "boolean": _field_builder("boolean"),
698
+ "array": _field_builder("array"),
699
+ "object": _field_builder("object"),
700
+ "integer": _field_builder("integer"),
701
+ }
702
+
703
+ def _evaluator_builder(evaluator_type: str):
704
+ """Create a simple evaluator config builder for Evaluation(s)({ evaluators = {...} })."""
705
+
706
+ def build_evaluator(options=None):
707
+ if options is None:
708
+ options = {}
709
+ if hasattr(options, "items"):
710
+ options = lua_table_to_dict(options)
711
+ if not isinstance(options, dict):
712
+ options = {}
713
+ cfg = {"type": evaluator_type}
714
+ cfg.update(options)
715
+ return cfg
716
+
717
+ return build_evaluator
718
+
719
+ # Evaluation(s)() helper constructors (Pydantic Evals integration).
720
+ # These are configuration builders, not runtime behavior.
721
+ field["equals_expected"] = _evaluator_builder("equals_expected")
722
+ field["min_length"] = _evaluator_builder("min_length")
723
+ field["contains"] = _evaluator_builder("contains")
724
+
725
+ # Create lookup functions for uppercase names (Agent, Model)
726
+ # These allow: Agent("greeter")(), Model("classifier")() (callable syntax)
727
+ _Agent = AgentLookup(_agent_registry)
728
+ _Model = ModelLookup(_model_registry)
729
+
730
+ # For Tool lookup, we'll add __call__ to ToolPrimitive
731
+ # Set the tool registry on the primitive so it can do lookups
732
+ if tool_primitive is not None:
733
+ tool_primitive.set_tool_registry(_tool_registry)
734
+
735
+ # Create hybrid functions that handle both definition and lookup
736
+ class HybridModel:
737
+ """Callable that handles both Model definition and lookup."""
738
+
739
+ def __init__(self, definer, lookup):
740
+ self.definer = definer
741
+ self.lookup = lookup
742
+
743
+ def __call__(self, name, config=None):
744
+ try:
745
+ # NEW: Assignment syntax - Model {config} with no name
746
+ # When called as: my_model = Model {type = "http", ...}
747
+ # Lua passes the config table (not a string, not None)
748
+ if not isinstance(name, str) and config is None:
749
+ # Assignment syntax: generate temp name and register
750
+ import uuid
751
+
752
+ temp_name = f"_temp_model_{uuid.uuid4().hex[:8]}"
753
+ config_dict = lua_table_to_dict(name)
754
+ builder.register_model(temp_name, config_dict)
755
+
756
+ handle = ModelHandle(temp_name)
757
+ _model_registry[temp_name] = handle
758
+ return handle
759
+
760
+ # If config is provided, it's old-style definition: Model("name", {config})
761
+ if config is not None:
762
+ return self.definer(name, config)
763
+
764
+ # If called with just a string
765
+ if isinstance(name, str):
766
+ # Check if the model is already defined (lookup case)
767
+ try:
768
+ if self.lookup and name in self.lookup._registry:
769
+ # This is a lookup: Model("name") where model exists
770
+ return self.lookup(name)
771
+ except (TypeError, KeyError):
772
+ pass
773
+ # This is the start of a definition: Model "name" {...}
774
+ # Return the curried function from definer
775
+ return self.definer(name)
776
+
777
+ # Otherwise pass through to definer
778
+ return self.definer(name, config)
779
+ except TypeError as e:
780
+ # Handle unhashable type errors from Lua tables
781
+ if "unhashable type" in str(e):
782
+ # This is assignment syntax with a Lua table
783
+ import uuid
784
+
785
+ temp_name = f"_temp_model_{uuid.uuid4().hex[:8]}"
786
+ config_dict = lua_table_to_dict(name)
787
+ builder.register_model(temp_name, config_dict)
788
+
789
+ handle = ModelHandle(temp_name)
790
+ _model_registry[temp_name] = handle
791
+ return handle
792
+ raise
793
+
794
+ def _signature(sig_input, config=None):
795
+ """
796
+ Create a DSPy Signature.
797
+
798
+ Supports both string format and structured format.
799
+
800
+ String format:
801
+ - Simple: "question -> answer"
802
+ - Multi-field: "context, question -> reasoning, answer"
803
+ - Typed: "question: str -> answer: str"
804
+
805
+ Structured format (curried):
806
+ - Signature "name" { input = {...}, output = {...} }
807
+
808
+ Args:
809
+ sig_input: Signature string like "question -> answer" or name for curried form
810
+ config: Optional config dict (for structured form)
811
+
812
+ Returns:
813
+ A dspy.Signature class
814
+
815
+ Example (Lua):
816
+ -- String form
817
+ Signature("question -> answer")
818
+ Signature("context, question -> reasoning, answer")
819
+
820
+ -- Structured form
821
+ Signature "qa" {
822
+ input = {
823
+ question = field.string{description = "The question to answer"}
824
+ },
825
+ output = {
826
+ answer = field.string{description = "The answer"}
827
+ }
828
+ }
829
+ """
830
+ from tactus.dspy import create_signature
831
+
832
+ # String form - check if it looks like a signature string (contains "->")
833
+ if isinstance(sig_input, str):
834
+ if "->" in sig_input:
835
+ # This is a signature string like "question -> answer"
836
+ return create_signature(sig_input)
837
+ else:
838
+ # This is a name for curried form: Signature "name" {...}
839
+ def accept_config(cfg):
840
+ """Accept config and create structured signature."""
841
+ config_dict = lua_table_to_dict(cfg)
842
+
843
+ # Normalize empty config
844
+ if isinstance(config_dict, list) and len(config_dict) == 0:
845
+ config_dict = {}
846
+
847
+ return create_signature(config_dict, name=sig_input)
848
+
849
+ return accept_config
850
+
851
+ # Direct dict form: Signature({ input = {...}, output = {...} })
852
+ if hasattr(sig_input, "items"):
853
+ config_dict = lua_table_to_dict(sig_input)
854
+ return create_signature(config_dict)
855
+
856
+ raise TypeError(
857
+ f"Signature expects a string like 'input -> output' or a name for structured form, "
858
+ f"got {type(sig_input).__name__}"
859
+ )
860
+
861
+ def _lm(model: str, config=None):
862
+ """
863
+ Configure Language Model for DSPy operations.
864
+
865
+ Uses LiteLLM's model naming convention:
866
+ - OpenAI: "openai/gpt-4o", "openai/gpt-4o-mini"
867
+ - Anthropic: "anthropic/claude-3-5-sonnet-20241022"
868
+ - AWS Bedrock: "bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0"
869
+ - Google: "gemini/gemini-pro"
870
+
871
+ Args:
872
+ model: Model identifier in LiteLLM format
873
+ config: Optional configuration dict (temperature, api_key, etc.)
874
+
875
+ Returns:
876
+ Configured LM instance
877
+
878
+ Example (Lua):
879
+ LM("openai/gpt-4o")
880
+ LM("openai/gpt-4o", { temperature = 0.7 })
881
+ LM "anthropic/claude-3-5-sonnet-20241022" { temperature = 0.3 }
882
+ """
883
+ from tactus.dspy import configure_lm
884
+
885
+ # Note: With unified mocking, mocks are handled at Module/Agent level
886
+ # LM configuration still happens normally; mocks intercept at call time
887
+
888
+ # Check if this is curried syntax (config is None, return acceptor)
889
+ if config is None:
890
+ # Return a function that accepts config
891
+ def accept_config(cfg=None):
892
+ cfg_dict = lua_table_to_dict(cfg) if cfg else {}
893
+ return configure_lm(model, **cfg_dict)
894
+
895
+ # Also allow immediate call without config
896
+ # This handles: LM("openai/gpt-4o") with no second arg
897
+ return accept_config
898
+
899
+ # Direct call with config: LM("model", {config})
900
+ config_dict = lua_table_to_dict(config)
901
+ return configure_lm(model, **config_dict)
902
+
903
+ def _mocks(config):
904
+ """
905
+ Define mock configurations for tools and agents.
906
+
907
+ Example usage:
908
+ Mocks {
909
+ -- Tool mocks
910
+ search = {
911
+ returns = {results = {"mocked result"}}
912
+ },
913
+ get_time = {
914
+ temporal = {
915
+ {time = "10:00"},
916
+ {time = "11:00"},
917
+ {time = "12:00"}
918
+ }
919
+ },
920
+ translate = {
921
+ conditional = {
922
+ {when = {text = "hello"}, returns = {translation = "hola"}},
923
+ {when = {text = "goodbye"}, returns = {translation = "adiós"}}
924
+ }
925
+ },
926
+
927
+ -- Agent mocks (specifies what tool calls to simulate)
928
+ my_agent = {
929
+ tool_calls = {
930
+ {tool = "search", args = {query = "test"}},
931
+ {tool = "done", args = {reason = "completed"}}
932
+ },
933
+ message = "I found the results."
934
+ }
935
+ }
936
+
937
+ Args:
938
+ config: Lua table containing mock definitions
939
+ """
940
+ if config is None:
941
+ return
942
+
943
+ config_dict = lua_table_to_dict(config)
944
+
945
+ # Register mock configurations with the builder
946
+ for name, mock_config in config_dict.items():
947
+ if not isinstance(mock_config, dict):
948
+ continue
949
+
950
+ # Tool mocks use explicit keys.
951
+ tool_mock_keys = {"returns", "temporal", "conditional", "error"}
952
+ if any(k in mock_config for k in tool_mock_keys):
953
+ # Convert DSL syntax to MockConfig format
954
+ processed_config = {}
955
+
956
+ # Static mocking with 'returns' key
957
+ if "returns" in mock_config:
958
+ processed_config["output"] = mock_config["returns"]
959
+
960
+ # Temporal mocking
961
+ elif "temporal" in mock_config:
962
+ processed_config["temporal"] = mock_config["temporal"]
963
+
964
+ # Conditional mocking
965
+ elif "conditional" in mock_config:
966
+ # Convert DSL conditional format to MockManager format
967
+ conditionals = []
968
+ for cond in mock_config["conditional"]:
969
+ if isinstance(cond, dict) and "when" in cond and "returns" in cond:
970
+ conditionals.append({"when": cond["when"], "return": cond["returns"]})
971
+ processed_config["conditional_mocks"] = conditionals
972
+
973
+ # Error simulation
974
+ elif "error" in mock_config:
975
+ processed_config["error"] = mock_config["error"]
976
+
977
+ # Register the tool mock configuration
978
+ builder.register_mock(name, processed_config)
979
+ continue
980
+
981
+ # Agent mocks can be message-only, tool_calls-only, or both.
982
+ if any(k in mock_config for k in ("tool_calls", "message", "data")):
983
+ agent_config = {
984
+ "tool_calls": mock_config.get("tool_calls", []),
985
+ "message": mock_config.get("message", ""),
986
+ "data": mock_config.get("data", {}),
987
+ "usage": mock_config.get("usage", {}),
988
+ }
989
+ builder.register_agent_mock(name, agent_config)
990
+ continue
991
+
992
+ # Otherwise, ignore unknown mock config.
993
+ continue
994
+
995
+ def _history(messages=None):
996
+ """
997
+ Create a History for managing conversation messages.
998
+
999
+ History is used to track multi-turn conversations and can be
1000
+ passed to Modules as an input field.
1001
+
1002
+ Returns an object with methods:
1003
+ - add(message): Add a message to history
1004
+ - get(): Get all messages
1005
+ - clear(): Clear all messages
1006
+
1007
+ Example (Lua):
1008
+ -- Create history
1009
+ local history = History()
1010
+
1011
+ -- Add messages
1012
+ history.add({ question = "What is 2+2?", answer = "4" })
1013
+
1014
+ -- Get messages
1015
+ local messages = history.get()
1016
+
1017
+ -- Clear
1018
+ history.clear()
1019
+ """
1020
+ from tactus.dspy import create_history
1021
+
1022
+ if messages is not None:
1023
+ messages_list = lua_table_to_dict(messages)
1024
+ return create_history(messages_list)
1025
+ return create_history()
1026
+
1027
+ class TactusMessage:
1028
+ """
1029
+ First-class Message primitive for conversation history.
1030
+
1031
+ Represents a single message in a conversation with validated role and content.
1032
+ """
1033
+
1034
+ VALID_ROLES = {"user", "assistant", "system"}
1035
+
1036
+ def __init__(self, role: str, content: str, **metadata):
1037
+ if role not in self.VALID_ROLES:
1038
+ raise ValueError(
1039
+ f"Invalid role '{role}'. Must be one of: {', '.join(self.VALID_ROLES)}"
1040
+ )
1041
+ self.role = role
1042
+ self.content = content
1043
+ self.metadata = metadata
1044
+
1045
+ def to_dict(self):
1046
+ """Convert to dict format for history/DSPy."""
1047
+ result = {"role": self.role, "content": self.content}
1048
+ if self.metadata:
1049
+ result.update(self.metadata)
1050
+ return result
1051
+
1052
+ def __repr__(self):
1053
+ content_preview = self.content[:50] + "..." if len(self.content) > 50 else self.content
1054
+ return f"Message(role='{self.role}', content='{content_preview}')"
1055
+
1056
+ def _message(config):
1057
+ """
1058
+ Create a Message for use in conversation history.
1059
+
1060
+ Message is a first-class primitive representing a single message
1061
+ in a multi-turn conversation. It validates role and provides
1062
+ a clean API for building conversation history.
1063
+
1064
+ Valid roles: user, assistant, system
1065
+
1066
+ Example (Lua):
1067
+ -- Create messages
1068
+ msg = Message {role = "user", content = "Hello"}
1069
+ msg = Message {role = "assistant", content = "Hi there!"}
1070
+ msg = Message {role = "system", content = "You are helpful"}
1071
+
1072
+ -- Add to history
1073
+ history.add(msg)
1074
+
1075
+ Args:
1076
+ config: Table with 'role' and 'content' fields
1077
+
1078
+ Returns:
1079
+ TactusMessage instance
1080
+ """
1081
+ config_dict = lua_table_to_dict(config)
1082
+
1083
+ role = config_dict.get("role")
1084
+ content = config_dict.get("content")
1085
+
1086
+ if not role:
1087
+ raise ValueError("Message requires 'role' field")
1088
+ if not content:
1089
+ raise ValueError("Message requires 'content' field")
1090
+
1091
+ # Extract any additional metadata
1092
+ metadata = {k: v for k, v in config_dict.items() if k not in ("role", "content")}
1093
+
1094
+ return TactusMessage(role, content, **metadata)
1095
+
1096
+ def _module(module_name: str, config=None):
1097
+ """
1098
+ Create a DSPy Module with a given strategy.
1099
+
1100
+ Supports curried syntax: Module "name" { signature = "...", strategy = "predict" }
1101
+
1102
+ Strategies:
1103
+ - "predict": Direct prediction using dspy.Predict
1104
+ - "chain_of_thought": Reasoning with dspy.ChainOfThought
1105
+
1106
+ Args:
1107
+ module_name: Name for this module (used for tracking)
1108
+ config: Optional config dict (for old syntax)
1109
+
1110
+ Returns:
1111
+ A callable TactusModule instance
1112
+
1113
+ Example (Lua):
1114
+ -- Create a module
1115
+ local qa = Module "qa" {
1116
+ signature = "question -> answer",
1117
+ strategy = "predict"
1118
+ }
1119
+
1120
+ -- Call the module
1121
+ local result = qa({ question = "What is 2+2?" })
1122
+ -- result.answer == "4"
1123
+ """
1124
+ from tactus.dspy import create_module
1125
+
1126
+ # Check if this is old-style 2-argument call
1127
+ if config is not None:
1128
+ config_dict = lua_table_to_dict(config)
1129
+ return create_module(
1130
+ module_name, config_dict, registry=builder.registry, mock_manager=mock_manager
1131
+ )
1132
+
1133
+ # New curried syntax - return a function that accepts config
1134
+ def accept_config(cfg):
1135
+ """Accept config and create module."""
1136
+ config_dict = lua_table_to_dict(cfg)
1137
+ return create_module(
1138
+ module_name, config_dict, registry=builder.registry, mock_manager=mock_manager
1139
+ )
1140
+
1141
+ return accept_config
1142
+
1143
+ def _get_current_lm():
1144
+ """
1145
+ Get the currently configured Language Model.
1146
+
1147
+ Returns:
1148
+ The current LM instance or None if not configured
1149
+ """
1150
+ from tactus.dspy import get_current_lm
1151
+
1152
+ return get_current_lm()
1153
+
1154
+ def _dspy_agent(config=None):
1155
+ """
1156
+ Create a DSPy Agent.
1157
+
1158
+ Supports curried syntax: DSPyAgent { system_prompt = "...", ... }
1159
+
1160
+ Args:
1161
+ config: Optional config dict
1162
+
1163
+ Returns:
1164
+ A DSPyAgentHandle instance
1165
+
1166
+ Example (Lua):
1167
+ local agent = DSPyAgent {
1168
+ system_prompt = "You are a helpful assistant"
1169
+ }
1170
+
1171
+ -- Use the agent
1172
+ local result = agent:turn({ input = "Hello" })
1173
+ """
1174
+ from tactus.dspy import create_dspy_agent
1175
+
1176
+ # If config provided directly, create agent
1177
+ if config is not None:
1178
+ config_dict = lua_table_to_dict(config)
1179
+ # Generate a unique name if not provided
1180
+ agent_name = config_dict.pop("name", "dspy_agent")
1181
+ return create_dspy_agent(
1182
+ agent_name, config_dict, registry=builder.registry, mock_manager=mock_manager
1183
+ )
1184
+
1185
+ # Curried form - return function that accepts config
1186
+ def accept_config(cfg):
1187
+ """Accept config and create DSPy agent."""
1188
+ config_dict = lua_table_to_dict(cfg)
1189
+ agent_name = config_dict.pop("name", "dspy_agent")
1190
+ return create_dspy_agent(
1191
+ agent_name, config_dict, registry=builder.registry, mock_manager=mock_manager
1192
+ )
1193
+
1194
+ return accept_config
1195
+
1196
+ # ========================================================================
1197
+ # NEW SYNTAX SUPPORT - Phase B
1198
+ # ========================================================================
1199
+
1200
+ # Create MCP namespace for accessing MCP server tools
1201
+ class McpServerNamespace:
1202
+ """Namespace for a specific MCP server's tools."""
1203
+
1204
+ def __init__(self, server_name: str):
1205
+ self.server_name = server_name
1206
+
1207
+ def __getattr__(self, tool_name: str):
1208
+ """Dynamically create tool handles for MCP tools."""
1209
+ from tactus.primitives.tool_handle import ToolHandle
1210
+
1211
+ full_name = f"mcp.{self.server_name}.{tool_name}"
1212
+
1213
+ # Return existing handle if already created
1214
+ if full_name in _tool_registry:
1215
+ return _tool_registry[full_name]
1216
+
1217
+ # Create a placeholder handler - will be replaced by runtime
1218
+ def mcp_placeholder_handler(args):
1219
+ raise RuntimeError(
1220
+ f"MCP tool '{full_name}' not connected. "
1221
+ f"Configure MCP server '{self.server_name}' in runtime."
1222
+ )
1223
+
1224
+ # Register the tool with MCP source info
1225
+ config = {
1226
+ "description": f"MCP tool from {self.server_name}",
1227
+ "source": full_name,
1228
+ "mcp_server": self.server_name,
1229
+ "mcp_tool": tool_name,
1230
+ }
1231
+ builder.register_tool(full_name, config, mcp_placeholder_handler)
1232
+
1233
+ # Create and register handle
1234
+ handle = ToolHandle(full_name, mcp_placeholder_handler, tool_primitive)
1235
+ _tool_registry[full_name] = handle
1236
+ return handle
1237
+
1238
+ class McpNamespace:
1239
+ """
1240
+ Namespace for MCP server tools.
1241
+
1242
+ Supports syntax like:
1243
+ read_file = mcp.filesystem.read_file
1244
+ search = mcp.brave_search.search
1245
+
1246
+ The tool handle is created as a placeholder that will be
1247
+ connected to the actual MCP server at runtime.
1248
+ """
1249
+
1250
+ def __getattr__(self, server_name: str):
1251
+ """Return a server namespace for the given MCP server."""
1252
+ return McpServerNamespace(server_name)
1253
+
1254
+ _mcp_namespace = McpNamespace()
1255
+
1256
+ def _process_tool_config(tool_name, config):
1257
+ """
1258
+ Process tool configuration for both curried and direct syntax.
1259
+
1260
+ Args:
1261
+ tool_name: Name of the tool
1262
+ config: Configuration table
1263
+
1264
+ Returns:
1265
+ ToolHandle
1266
+ """
1267
+ from tactus.primitives.tool_handle import ToolHandle
1268
+
1269
+ # Extract function from config
1270
+ handler_fn = None
1271
+ if hasattr(config, "__getitem__"):
1272
+ for i in range(1, 10):
1273
+ try:
1274
+ item = config[i]
1275
+ if callable(item):
1276
+ handler_fn = item
1277
+ config[i] = None
1278
+ break
1279
+ except (KeyError, TypeError, IndexError):
1280
+ break
1281
+
1282
+ # Convert to dict
1283
+ config_dict = lua_table_to_dict(config)
1284
+
1285
+ # Clean up None values from function extraction
1286
+ if isinstance(config_dict, list):
1287
+ config_dict = [x for x in config_dict if x is not None]
1288
+ if len(config_dict) == 0:
1289
+ config_dict = {}
1290
+
1291
+ # Normalize empty schemas (lua {} -> python []) so tools treat empty schemas
1292
+ # as empty objects, not arrays.
1293
+ if isinstance(config_dict, dict):
1294
+ config_dict["input"] = _normalize_schema(config_dict.get("input", {}))
1295
+ config_dict["output"] = _normalize_schema(config_dict.get("output", {}))
1296
+
1297
+ # Check for legacy handler field
1298
+ if handler_fn is None and isinstance(config_dict, dict):
1299
+ handler_fn = config_dict.pop("handler", None)
1300
+
1301
+ # Tool sources: allow `use = "..."` (or legacy/internal `source = "..."`) in lieu of a handler.
1302
+ source = None
1303
+ if isinstance(config_dict, dict):
1304
+ source = config_dict.pop("use", None)
1305
+ if source is not None:
1306
+ if "source" in config_dict:
1307
+ raise TypeError(f"Tool '{tool_name}' cannot specify both 'use' and 'source'")
1308
+ config_dict["source"] = source
1309
+ else:
1310
+ source = config_dict.get("source")
1311
+
1312
+ if handler_fn is not None and isinstance(source, str) and source.strip():
1313
+ raise TypeError(
1314
+ f"Tool '{tool_name}' cannot specify both a function and 'use = \"...\"'"
1315
+ )
1316
+
1317
+ is_source_tool = handler_fn is None and isinstance(source, str) and bool(source.strip())
1318
+
1319
+ if handler_fn is None and not is_source_tool:
1320
+ raise TypeError(
1321
+ f"Tool '{tool_name}' requires either a function or 'use = \"...\"'. "
1322
+ 'Example: my_tool = Tool { use = "broker.host.ping" }'
1323
+ )
1324
+
1325
+ if is_source_tool:
1326
+ source_str = source.strip()
1327
+
1328
+ def source_tool_handler(args):
1329
+ import asyncio
1330
+ import threading
1331
+
1332
+ # Resolve at call time so runtime toolsets are available.
1333
+ if tool_primitive is None:
1334
+ raise RuntimeError(
1335
+ f"Tool '{tool_name}' is not available (tool primitive missing)"
1336
+ )
1337
+
1338
+ runtime = getattr(tool_primitive, "_runtime", None)
1339
+ if runtime is None:
1340
+ raise RuntimeError(
1341
+ f"Tool '{tool_name}' is not available (runtime not connected)"
1342
+ )
1343
+
1344
+ toolset = runtime.toolset_registry.get(tool_name)
1345
+ if toolset is None:
1346
+ raise RuntimeError(
1347
+ f"Tool '{tool_name}' not resolved from source '{source_str}'"
1348
+ )
1349
+
1350
+ tool_fn = tool_primitive._extract_tool_function(toolset, tool_name)
1351
+
1352
+ # Support both tool_fn(**kwargs) and tool_fn(args_dict) styles.
1353
+ # Prefer kwargs (pydantic-ai Tool functions) then fall back to dict.
1354
+ if hasattr(args, "items"):
1355
+ args_dict = lua_table_to_dict(args)
1356
+ else:
1357
+ args_dict = args or {}
1358
+ if not isinstance(args_dict, dict):
1359
+ raise TypeError(f"Tool '{tool_name}' args must be an object/table")
1360
+
1361
+ if asyncio.iscoroutinefunction(tool_fn):
1362
+
1363
+ def _run_coro(coro):
1364
+ try:
1365
+ asyncio.get_running_loop()
1366
+ except RuntimeError:
1367
+ return asyncio.run(coro)
1368
+
1369
+ result_container = {"value": None, "exception": None}
1370
+
1371
+ def run_in_thread():
1372
+ try:
1373
+ result_container["value"] = asyncio.run(coro)
1374
+ except Exception as e:
1375
+ result_container["exception"] = e
1376
+
1377
+ thread = threading.Thread(target=run_in_thread)
1378
+ thread.start()
1379
+ thread.join()
1380
+
1381
+ if result_container["exception"] is not None:
1382
+ raise result_container["exception"]
1383
+ return result_container["value"]
1384
+
1385
+ try:
1386
+ return _run_coro(tool_fn(**args_dict))
1387
+ except TypeError:
1388
+ return _run_coro(tool_fn(args_dict))
1389
+
1390
+ try:
1391
+ return tool_fn(**args_dict)
1392
+ except TypeError:
1393
+ return tool_fn(args_dict)
1394
+
1395
+ handler_fn = source_tool_handler
1396
+
1397
+ # Register tool with provided name
1398
+ builder.register_tool(tool_name, config_dict, handler_fn)
1399
+ handle = ToolHandle(
1400
+ tool_name,
1401
+ handler_fn,
1402
+ tool_primitive,
1403
+ record_calls=not is_source_tool,
1404
+ )
1405
+
1406
+ # Store in registry
1407
+ _tool_registry[tool_name] = handle
1408
+
1409
+ return handle
1410
+
1411
+ def _new_tool(name_or_config=None):
1412
+ """
1413
+ New Tool factory for assignment-based syntax.
1414
+
1415
+ Syntax:
1416
+ multiply = Tool {
1417
+ description = "Multiply two numbers",
1418
+ input = {
1419
+ a = field.number{required = true},
1420
+ b = field.number{required = true}
1421
+ },
1422
+ function(args)
1423
+ return args.a * args.b
1424
+ end
1425
+ }
1426
+
1427
+ The name is captured via assignment interception.
1428
+
1429
+ Args:
1430
+ name_or_config: Configuration table
1431
+
1432
+ Returns:
1433
+ ToolHandle that will be assigned to a variable (or returned directly)
1434
+ """
1435
+ from tactus.primitives.tool_handle import ToolHandle
1436
+
1437
+ if isinstance(name_or_config, str):
1438
+ raise TypeError(
1439
+ "Curried Tool syntax is not supported. Use assignment syntax: my_tool = Tool { ... }, "
1440
+ 'or provide an explicit name in the config: Tool { name = "my_tool", ... }.'
1441
+ )
1442
+
1443
+ # Handle direct config syntax: multiply = Tool { ... }
1444
+ config = name_or_config
1445
+ if config is None:
1446
+ raise TypeError("Tool requires a configuration table")
1447
+
1448
+ # Extract function from config
1449
+ handler_fn = None
1450
+ if hasattr(config, "__getitem__"):
1451
+ for i in range(1, 10):
1452
+ try:
1453
+ item = config[i]
1454
+ if callable(item):
1455
+ handler_fn = item
1456
+ config[i] = None
1457
+ break
1458
+ except (KeyError, TypeError, IndexError):
1459
+ break
1460
+
1461
+ # Convert to dict
1462
+ config_dict = lua_table_to_dict(config)
1463
+
1464
+ # Clean up None values from function extraction
1465
+ if isinstance(config_dict, list):
1466
+ config_dict = [x for x in config_dict if x is not None]
1467
+ if len(config_dict) == 0:
1468
+ config_dict = {}
1469
+
1470
+ # Normalize empty schemas (lua {} -> python []) so tools treat empty schemas
1471
+ # as empty objects, not arrays.
1472
+ if isinstance(config_dict, dict):
1473
+ config_dict["input"] = _normalize_schema(config_dict.get("input", {}))
1474
+ config_dict["output"] = _normalize_schema(config_dict.get("output", {}))
1475
+
1476
+ # Check for legacy handler field
1477
+ if handler_fn is None and isinstance(config_dict, dict):
1478
+ handler_fn = config_dict.pop("handler", None)
1479
+
1480
+ # Tool sources: allow `use = "..."` (or legacy/internal `source = "..."`) in lieu of a handler.
1481
+ source = None
1482
+ if isinstance(config_dict, dict):
1483
+ source = config_dict.pop("use", None)
1484
+ if source is not None:
1485
+ if "source" in config_dict:
1486
+ raise TypeError("Tool cannot specify both 'use' and 'source'")
1487
+ config_dict["source"] = source
1488
+ else:
1489
+ source = config_dict.get("source")
1490
+
1491
+ if handler_fn is not None and isinstance(source, str) and source.strip():
1492
+ raise TypeError("Tool cannot specify both a function and 'use = \"...\"'")
1493
+
1494
+ is_source_tool = handler_fn is None and isinstance(source, str) and bool(source.strip())
1495
+
1496
+ if handler_fn is None and not is_source_tool:
1497
+ raise TypeError(
1498
+ "Tool requires either a function or 'use = \"...\"'. "
1499
+ 'Example: my_tool = Tool { use = "broker.host.ping" }'
1500
+ )
1501
+
1502
+ # Optional explicit tool name (primarily for `return Tool { ... }` cases).
1503
+ explicit_name = None
1504
+ if isinstance(config_dict, dict):
1505
+ explicit_name = config_dict.pop("name", None)
1506
+ if explicit_name is not None and not isinstance(explicit_name, str):
1507
+ raise TypeError("Tool 'name' must be a string")
1508
+ if isinstance(explicit_name, str) and not explicit_name.strip():
1509
+ raise TypeError("Tool 'name' cannot be empty")
1510
+
1511
+ # Generate a temporary name - will be replaced when assigned
1512
+ import uuid
1513
+
1514
+ temp_name = (
1515
+ explicit_name.strip()
1516
+ if isinstance(explicit_name, str)
1517
+ else f"_temp_tool_{uuid.uuid4().hex[:8]}"
1518
+ )
1519
+
1520
+ if is_source_tool:
1521
+ import asyncio
1522
+ import threading
1523
+
1524
+ source_str = source.strip()
1525
+ handle_ref = {"handle": None}
1526
+
1527
+ def source_tool_handler(args):
1528
+ # Resolve at call time so runtime toolsets are available.
1529
+ if tool_primitive is None:
1530
+ raise RuntimeError("Tool not available (tool primitive missing)")
1531
+
1532
+ runtime = getattr(tool_primitive, "_runtime", None)
1533
+ if runtime is None:
1534
+ raise RuntimeError("Tool not available (runtime not connected)")
1535
+
1536
+ resolved_name = handle_ref["handle"].name if handle_ref["handle"] else temp_name
1537
+ toolset = runtime.toolset_registry.get(resolved_name)
1538
+ if toolset is None:
1539
+ raise RuntimeError(
1540
+ f"Tool '{resolved_name}' not resolved from source '{source_str}'"
1541
+ )
1542
+
1543
+ tool_fn = tool_primitive._extract_tool_function(toolset, resolved_name)
1544
+
1545
+ # Support both tool_fn(**kwargs) and tool_fn(args_dict) styles.
1546
+ # Prefer kwargs (pydantic-ai Tool functions) then fall back to dict.
1547
+ if hasattr(args, "items"):
1548
+ args_dict = lua_table_to_dict(args)
1549
+ else:
1550
+ args_dict = args or {}
1551
+ if not isinstance(args_dict, dict):
1552
+ raise TypeError("Tool args must be an object/table")
1553
+
1554
+ if asyncio.iscoroutinefunction(tool_fn):
1555
+
1556
+ def _run_coro(coro):
1557
+ try:
1558
+ asyncio.get_running_loop()
1559
+ except RuntimeError:
1560
+ return asyncio.run(coro)
1561
+
1562
+ result_container = {"value": None, "exception": None}
1563
+
1564
+ def run_in_thread():
1565
+ try:
1566
+ result_container["value"] = asyncio.run(coro)
1567
+ except Exception as e:
1568
+ result_container["exception"] = e
1569
+
1570
+ thread = threading.Thread(target=run_in_thread)
1571
+ thread.start()
1572
+ thread.join()
1573
+
1574
+ if result_container["exception"] is not None:
1575
+ raise result_container["exception"]
1576
+ return result_container["value"]
1577
+
1578
+ try:
1579
+ return _run_coro(tool_fn(**args_dict))
1580
+ except TypeError:
1581
+ return _run_coro(tool_fn(args_dict))
1582
+
1583
+ try:
1584
+ return tool_fn(**args_dict)
1585
+ except TypeError:
1586
+ return tool_fn(args_dict)
1587
+
1588
+ handler_fn = source_tool_handler
1589
+
1590
+ # Register tool
1591
+ builder.register_tool(temp_name, config_dict, handler_fn)
1592
+ handle = ToolHandle(
1593
+ temp_name,
1594
+ handler_fn,
1595
+ tool_primitive,
1596
+ record_calls=not is_source_tool,
1597
+ )
1598
+
1599
+ # Store in registry with temp name
1600
+ _tool_registry[temp_name] = handle
1601
+
1602
+ if is_source_tool:
1603
+ handle_ref["handle"] = handle
1604
+
1605
+ return handle
1606
+
1607
+ def _process_agent_config(agent_name, config):
1608
+ """
1609
+ Process agent configuration for both curried and direct syntax.
1610
+
1611
+ Args:
1612
+ agent_name: Name of the agent
1613
+ config: Configuration table
1614
+
1615
+ Returns:
1616
+ AgentHandle
1617
+ """
1618
+ config_dict = lua_table_to_dict(config)
1619
+
1620
+ # No alias support: toolsets -> tools is not supported.
1621
+ if "toolsets" in config_dict:
1622
+ raise ValueError(
1623
+ f"Agent '{agent_name}': 'toolsets' is not supported. Use 'tools' for tool/toolset references."
1624
+ )
1625
+
1626
+ # inline_tools: inline tool definitions only (list of dicts with "handler")
1627
+ if "inline_tools" in config_dict:
1628
+ inline_tools = config_dict["inline_tools"]
1629
+ if isinstance(inline_tools, (list, tuple)):
1630
+ non_dict_items = [t for t in inline_tools if not isinstance(t, dict)]
1631
+ if non_dict_items:
1632
+ raise ValueError(
1633
+ f"Agent '{agent_name}': 'inline_tools' must be a list of inline tool definitions."
1634
+ )
1635
+ elif inline_tools is not None:
1636
+ raise ValueError(
1637
+ f"Agent '{agent_name}': 'inline_tools' must be a list of inline tool definitions."
1638
+ )
1639
+
1640
+ # tools: tool/toolset references and toolset expressions (filter dicts)
1641
+ if "tools" in config_dict:
1642
+ tools = config_dict["tools"]
1643
+ if isinstance(tools, (list, tuple)):
1644
+ normalized = []
1645
+ for t in tools:
1646
+ if isinstance(t, dict):
1647
+ if "handler" in t:
1648
+ raise ValueError(
1649
+ f"Agent '{agent_name}': inline tool definitions must be in 'inline_tools', not 'tools'."
1650
+ )
1651
+ normalized.append(t)
1652
+ continue
1653
+ if hasattr(t, "name"): # ToolHandle or ToolsetHandle
1654
+ normalized.append(t.name)
1655
+ else:
1656
+ normalized.append(t)
1657
+ config_dict["tools"] = normalized
1658
+
1659
+ # Extract input schema if present
1660
+ input_schema = None
1661
+ if "input" in config_dict:
1662
+ input_config = config_dict["input"]
1663
+ if isinstance(input_config, dict):
1664
+ input_schema = input_config
1665
+ config_dict["input_schema"] = input_schema
1666
+ del config_dict["input"]
1667
+
1668
+ # Extract output schema if present
1669
+ output_schema = None
1670
+ if "output" in config_dict:
1671
+ output_config = config_dict["output"]
1672
+ if isinstance(output_config, dict):
1673
+ output_schema = output_config
1674
+ config_dict["output_schema"] = output_schema
1675
+ del config_dict["output"]
1676
+
1677
+ # No compatibility aliases: session -> message_history is not supported.
1678
+ if "session" in config_dict:
1679
+ raise ValueError(
1680
+ f"Agent '{agent_name}': 'session' is not supported. Use 'message_history'."
1681
+ )
1682
+
1683
+ # Register agent with provided name
1684
+ builder.register_agent(agent_name, config_dict, output_schema)
1685
+
1686
+ # Create handle
1687
+ handle = AgentHandle(agent_name)
1688
+
1689
+ # If we have runtime context, create the agent primitive immediately
1690
+ import logging
1691
+
1692
+ logger = logging.getLogger(__name__)
1693
+
1694
+ logger.debug(
1695
+ f"[AGENT_CREATION] Agent '{agent_name}': runtime_context={bool(_runtime_context)}, has_log_handler={('log_handler' in _runtime_context) if _runtime_context else False}"
1696
+ )
1697
+
1698
+ if _runtime_context:
1699
+ from tactus.dspy.agent import create_dspy_agent
1700
+
1701
+ logger.debug(f"[AGENT_CREATION] Attempting immediate creation for agent '{agent_name}'")
1702
+
1703
+ try:
1704
+ # Create the actual agent primitive NOW
1705
+ # Note: builder.register_agent adds 'name' to config_dict, but create_dspy_agent
1706
+ # expects name as a separate parameter. We need to pass config without 'name'.
1707
+ agent_config = {k: v for k, v in config_dict.items() if k != "name"}
1708
+
1709
+ # Agent DSL uses `tools` for tool/toolset references; the DSPy agent config uses
1710
+ # `toolsets` for the resolved toolsets list.
1711
+ if "tools" in agent_config and "toolsets" not in agent_config:
1712
+ agent_config["toolsets"] = agent_config["tools"]
1713
+ del agent_config["tools"]
1714
+
1715
+ # Pre-process model format: combine provider and model into "provider:model"
1716
+ # This matches what _setup_agents does
1717
+ if "provider" in agent_config and "model" in agent_config:
1718
+ provider = agent_config["provider"]
1719
+ model_id = agent_config["model"]
1720
+ agent_config["model"] = f"{provider}:{model_id}"
1721
+
1722
+ # Add log_handler from runtime context
1723
+ if "log_handler" in _runtime_context:
1724
+ agent_config["log_handler"] = _runtime_context["log_handler"]
1725
+
1726
+ agent_primitive = create_dspy_agent(
1727
+ agent_name,
1728
+ agent_config,
1729
+ registry=builder.registry,
1730
+ mock_manager=_runtime_context.get("mock_manager"),
1731
+ )
1732
+
1733
+ # Set tool_primitive for mock tool call recording
1734
+ tool_primitive = _runtime_context.get("tool_primitive")
1735
+ if tool_primitive:
1736
+ agent_primitive._tool_primitive = tool_primitive
1737
+
1738
+ # Connect handle to primitive immediately
1739
+ handle._set_primitive(
1740
+ agent_primitive, execution_context=_runtime_context.get("execution_context")
1741
+ )
1742
+ logger.debug(
1743
+ f"[AGENT_CREATION] Agent '{agent_name}' created immediately during declaration, has_log_handler={hasattr(agent_primitive, 'log_handler') and agent_primitive.log_handler is not None}"
1744
+ )
1745
+
1746
+ # Store primitive in a dict so runtime can access it later
1747
+ if "_created_agents" not in _runtime_context:
1748
+ _runtime_context["_created_agents"] = {}
1749
+ _runtime_context["_created_agents"][agent_name] = agent_primitive
1750
+ logger.debug(
1751
+ f"[AGENT_CREATION] Stored agent '{agent_name}' in _created_agents dict"
1752
+ )
1753
+
1754
+ except Exception as e:
1755
+ logger.error(
1756
+ f"[AGENT_CREATION] Failed to create agent '{agent_name}' immediately: {e}",
1757
+ exc_info=True,
1758
+ )
1759
+ # Fall back to two-phase initialization if immediate creation fails
1760
+
1761
+ # Register handle for lookup
1762
+ _agent_registry[agent_name] = handle
1763
+
1764
+ return handle
1765
+
1766
+ def _new_agent(name_or_config=None):
1767
+ """
1768
+ New Agent factory for assignment-based and curried syntax.
1769
+
1770
+ Supports both:
1771
+ 1. Assignment syntax: greeter = Agent { ... }
1772
+ 2. Curried syntax: Agent "calculator" { ... }
1773
+
1774
+ New syntax:
1775
+ greeter = Agent {
1776
+ provider = "openai",
1777
+ system_prompt = "...",
1778
+ tools = {done, multiply},
1779
+ }
1780
+
1781
+ The name is captured via assignment interception.
1782
+
1783
+ Args:
1784
+ name_or_config: Either a string name (curried) or configuration table
1785
+
1786
+ Returns:
1787
+ AgentHandle that will be assigned to variable, or curried function
1788
+ """
1789
+ # Handle string argument: either curried declaration or lookup
1790
+ if isinstance(name_or_config, str):
1791
+ agent_name = name_or_config
1792
+ # Check if agent already exists - if so, it's a lookup
1793
+ if agent_name in _agent_registry:
1794
+ return _agent_registry[agent_name]
1795
+
1796
+ # Otherwise, return curried function for declaration
1797
+ def accept_config(config):
1798
+ return _process_agent_config(agent_name, config)
1799
+
1800
+ return accept_config
1801
+
1802
+ # Handle direct config syntax: greeter = Agent { ... }
1803
+ config = name_or_config
1804
+ if config is None:
1805
+ raise TypeError("Agent requires a configuration table")
1806
+
1807
+ config_dict = lua_table_to_dict(config)
1808
+
1809
+ # No alias support: toolsets -> tools is not supported.
1810
+ if "toolsets" in config_dict:
1811
+ raise ValueError("Agent: 'toolsets' is not supported. Use 'tools'.")
1812
+
1813
+ # inline_tools: inline tool definitions only (list of dicts with "handler")
1814
+ if "inline_tools" in config_dict:
1815
+ inline_tools = config_dict["inline_tools"]
1816
+ if isinstance(inline_tools, (list, tuple)):
1817
+ non_dict_items = [t for t in inline_tools if not isinstance(t, dict)]
1818
+ if non_dict_items:
1819
+ raise ValueError(
1820
+ "Agent: 'inline_tools' must be a list of inline tool definitions."
1821
+ )
1822
+ elif inline_tools is not None:
1823
+ raise ValueError("Agent: 'inline_tools' must be a list of inline tool definitions.")
1824
+
1825
+ # tools: tool/toolset references and toolset expressions (filter dicts)
1826
+ if "tools" in config_dict:
1827
+ tools = config_dict["tools"]
1828
+ if isinstance(tools, (list, tuple)):
1829
+ normalized = []
1830
+ for t in tools:
1831
+ if isinstance(t, dict):
1832
+ if "handler" in t:
1833
+ raise ValueError(
1834
+ "Agent: inline tool definitions must be in 'inline_tools', not 'tools'."
1835
+ )
1836
+ normalized.append(t)
1837
+ continue
1838
+ if hasattr(t, "name"): # ToolHandle or ToolsetHandle
1839
+ normalized.append(t.name)
1840
+ else:
1841
+ normalized.append(t)
1842
+ config_dict["tools"] = normalized
1843
+
1844
+ # Extract input schema if present
1845
+ input_schema = None
1846
+ if "input" in config_dict:
1847
+ input_config = config_dict["input"]
1848
+ if isinstance(input_config, dict):
1849
+ input_schema = input_config
1850
+ config_dict["input_schema"] = input_schema
1851
+ del config_dict["input"]
1852
+
1853
+ # Extract output schema if present
1854
+ output_schema = None
1855
+ if "output" in config_dict:
1856
+ output_config = config_dict["output"]
1857
+ if isinstance(output_config, dict):
1858
+ output_schema = output_config
1859
+ config_dict["output_schema"] = output_schema
1860
+ del config_dict["output"]
1861
+
1862
+ # No compatibility aliases: session -> message_history is not supported.
1863
+ if "session" in config_dict:
1864
+ raise ValueError("Agent: 'session' is not supported. Use 'message_history'.")
1865
+
1866
+ # Generate a temporary name - will be replaced when assigned
1867
+ import uuid
1868
+
1869
+ temp_name = f"_temp_agent_{uuid.uuid4().hex[:8]}"
1870
+
1871
+ # Register agent
1872
+ builder.register_agent(temp_name, config_dict, output_schema)
1873
+
1874
+ # Create handle
1875
+ handle = AgentHandle(temp_name)
1876
+
1877
+ # If we have runtime context, create the agent primitive immediately
1878
+ import logging
1879
+
1880
+ logger = logging.getLogger(__name__)
1881
+
1882
+ logger.debug(
1883
+ f"[AGENT_CREATION] Agent '{temp_name}': runtime_context={bool(_runtime_context)}, has_log_handler={('log_handler' in _runtime_context) if _runtime_context else False}"
1884
+ )
1885
+
1886
+ if _runtime_context:
1887
+ from tactus.dspy.agent import create_dspy_agent
1888
+
1889
+ logger.debug(f"[AGENT_CREATION] Attempting immediate creation for agent '{temp_name}'")
1890
+
1891
+ try:
1892
+ # Create the actual agent primitive NOW
1893
+ # Note: builder.register_agent adds 'name' to config_dict, but create_dspy_agent
1894
+ # expects name as a separate parameter. We need to pass config without 'name'.
1895
+ agent_config = {k: v for k, v in config_dict.items() if k != "name"}
1896
+
1897
+ # Pre-process model format: combine provider and model into "provider:model"
1898
+ # This matches what _setup_agents does
1899
+ if "provider" in agent_config and "model" in agent_config:
1900
+ provider = agent_config["provider"]
1901
+ model_id = agent_config["model"]
1902
+ agent_config["model"] = f"{provider}:{model_id}"
1903
+
1904
+ # Add log_handler from runtime context
1905
+ if "log_handler" in _runtime_context:
1906
+ agent_config["log_handler"] = _runtime_context["log_handler"]
1907
+
1908
+ logger.debug(
1909
+ f"[AGENT_CREATION] Creating agent immediately: name={temp_name}, has_log_handler={'log_handler' in agent_config}"
1910
+ )
1911
+ agent_primitive = create_dspy_agent(
1912
+ temp_name,
1913
+ agent_config,
1914
+ registry=builder.registry,
1915
+ mock_manager=_runtime_context.get("mock_manager"),
1916
+ )
1917
+
1918
+ # Set tool_primitive for mock tool call recording
1919
+ tool_primitive = _runtime_context.get("tool_primitive")
1920
+ if tool_primitive:
1921
+ agent_primitive._tool_primitive = tool_primitive
1922
+
1923
+ # Connect handle to primitive immediately
1924
+ handle._set_primitive(
1925
+ agent_primitive, execution_context=_runtime_context.get("execution_context")
1926
+ )
1927
+ logger.debug(
1928
+ f"[AGENT_CREATION] Agent '{temp_name}' created immediately during declaration, has_log_handler={hasattr(agent_primitive, 'log_handler') and agent_primitive.log_handler is not None}"
1929
+ )
1930
+
1931
+ # Store primitive in a dict so runtime can access it later
1932
+ if "_created_agents" not in _runtime_context:
1933
+ _runtime_context["_created_agents"] = {}
1934
+ _runtime_context["_created_agents"][temp_name] = agent_primitive
1935
+ logger.debug(f"[AGENT_CREATION] Stored agent '{temp_name}' in _created_agents dict")
1936
+
1937
+ except Exception as e:
1938
+ import traceback
1939
+
1940
+ logger.error(
1941
+ f"[AGENT_CREATION] Failed to create agent '{temp_name}' immediately: {e}",
1942
+ exc_info=True,
1943
+ )
1944
+ logger.debug(f"Full traceback: {traceback.format_exc()}")
1945
+ # Fall back to two-phase initialization if immediate creation fails
1946
+
1947
+ # Register handle for lookup
1948
+ _agent_registry[temp_name] = handle
1949
+
1950
+ return handle
1951
+
1952
+ return {
1953
+ # NEW SYNTAX (Phase B+)
1954
+ # Note: stdlib tools are accessed via require("tactus.tools.done") etc.
1955
+ "mcp": _mcp_namespace, # Namespace: mcp.filesystem.read_file
1956
+ # Core declarations (CamelCase - for definitions AND lookups)
1957
+ "Agent": _new_agent, # NEW syntax - assignment based
1958
+ "Model": HybridModel(_model, _Model),
1959
+ "Procedure": _procedure,
1960
+ "Prompt": _prompt,
1961
+ "Toolset": _toolset,
1962
+ "Tool": _new_tool, # NEW syntax - assignment based
1963
+ "Hitl": _hitl,
1964
+ "Specification": _specification,
1965
+ # BDD Testing
1966
+ "Specifications": _specifications,
1967
+ "Step": _step,
1968
+ "Evaluation": _evaluation,
1969
+ # Pydantic Evals Integration
1970
+ "Evaluations": _evaluations,
1971
+ # Mocking
1972
+ "Mocks": _mocks,
1973
+ # DSPy Integration
1974
+ "LM": _lm,
1975
+ "get_current_lm": _get_current_lm,
1976
+ "Signature": _signature,
1977
+ "Module": _module,
1978
+ "History": _history,
1979
+ "Message": _message,
1980
+ "DSPyAgent": _dspy_agent,
1981
+ # Script mode (top-level declarations)
1982
+ "input": _input,
1983
+ "output": _output,
1984
+ # Settings
1985
+ "default_provider": _default_provider,
1986
+ "default_model": _default_model,
1987
+ "return_prompt": _return_prompt,
1988
+ "error_prompt": _error_prompt,
1989
+ "status_prompt": _status_prompt,
1990
+ "async": _async,
1991
+ "max_depth": _max_depth,
1992
+ "max_turns": _max_turns,
1993
+ # Built-in filters (exposed as a table)
1994
+ "filters": {
1995
+ "last_n": _last_n,
1996
+ "token_budget": _token_budget,
1997
+ "by_role": _by_role,
1998
+ "compose": _compose,
1999
+ },
2000
+ # Built-in matchers
2001
+ "contains": _contains,
2002
+ "equals": _equals,
2003
+ "matches": _matches,
2004
+ # New field builder pattern
2005
+ "field": field,
2006
+ # Note: Old type functions (string, number, etc.) removed to avoid
2007
+ # shadowing Lua built-ins. Use field.string{}, field.number{}, etc.
2008
+ # Registries (for runtime to enhance handles)
2009
+ "_registries": {
2010
+ "agent": _agent_registry,
2011
+ "tool": _tool_registry,
2012
+ "model": _model_registry,
2013
+ },
2014
+ # Assignment interception callback
2015
+ "_tactus_register_binding": _make_binding_callback(
2016
+ builder, _tool_registry, _agent_registry, _runtime_context
2017
+ ),
2018
+ }
2019
+
2020
+
2021
+ def _make_binding_callback(
2022
+ builder: RegistryBuilder, tool_registry: dict, agent_registry: dict, runtime_context: dict
2023
+ ):
2024
+ """
2025
+ Factory to create the binding callback with closure over builder/registries/runtime_context.
2026
+
2027
+ This callback is called by Lua's __newindex metatable when assignments happen.
2028
+ """
2029
+ import logging
2030
+ from tactus.primitives.tool_handle import ToolHandle
2031
+
2032
+ callback_logger = logging.getLogger(__name__)
2033
+
2034
+ def _tactus_register_binding(name: str, value: Any) -> None:
2035
+ """
2036
+ Callback for assignment interception in new syntax.
2037
+
2038
+ Called by Lua's __newindex metatable when assignments like:
2039
+ multiply = Tool {...}
2040
+ greeter = Agent {...}
2041
+
2042
+ For handles with temp names, this renames them to the assigned name
2043
+ and re-registers them in the appropriate registry.
2044
+
2045
+ Args:
2046
+ name: Variable name being assigned
2047
+ value: Value being assigned
2048
+ """
2049
+ # Check if this is a ToolHandle with a temp name
2050
+ if isinstance(value, ToolHandle):
2051
+ old_name = value.name
2052
+ if old_name.startswith("_temp_tool_"):
2053
+ # Rename the tool
2054
+ callback_logger.debug(f"Renaming tool '{old_name}' to '{name}'")
2055
+ value.name = name
2056
+
2057
+ # Remove old registry entry, add new one
2058
+ if old_name in tool_registry:
2059
+ del tool_registry[old_name]
2060
+ tool_registry[name] = value
2061
+
2062
+ # Re-register in builder with correct name
2063
+ # Tools are stored in builder.registry.lua_tools
2064
+ if hasattr(builder, "registry") and old_name in builder.registry.lua_tools:
2065
+ tool_data = builder.registry.lua_tools.pop(old_name)
2066
+ builder.registry.lua_tools[name] = tool_data
2067
+ callback_logger.debug(
2068
+ f"Re-registered tool '{name}' in builder.registry.lua_tools"
2069
+ )
2070
+ elif old_name != name:
2071
+ raise RuntimeError(
2072
+ f"Tool name mismatch: assigned to '{name}' but tool is named '{old_name}'. "
2073
+ "Remove the Tool config 'name' field or make it match the assigned variable."
2074
+ )
2075
+
2076
+ # Check if this is an AgentHandle with a temp name
2077
+ if isinstance(value, AgentHandle):
2078
+ old_name = value.name
2079
+ if old_name.startswith("_temp_agent_"):
2080
+ # Rename the agent handle
2081
+ callback_logger.debug(f"[AGENT_RENAME] Renaming agent '{old_name}' to '{name}'")
2082
+ value.name = name
2083
+
2084
+ # Also rename the underlying primitive if it exists
2085
+ if value._primitive is not None:
2086
+ value._primitive.name = name
2087
+ callback_logger.debug(
2088
+ f"[AGENT_RENAME] Updated primitive name: '{old_name}' -> '{name}'"
2089
+ )
2090
+
2091
+ # Remove old registry entry, add new one
2092
+ if old_name in agent_registry:
2093
+ del agent_registry[old_name]
2094
+ agent_registry[name] = value
2095
+
2096
+ # Re-register in builder with correct name
2097
+ # Agents are stored in builder.registry.agents
2098
+ if hasattr(builder, "registry") and old_name in builder.registry.agents:
2099
+ agent_data = builder.registry.agents.pop(old_name)
2100
+ builder.registry.agents[name] = agent_data
2101
+ callback_logger.debug(
2102
+ f"[AGENT_RENAME] Re-registered agent '{name}' in builder.registry.agents"
2103
+ )
2104
+
2105
+ # Update _created_agents dict if this agent was immediately created
2106
+ if runtime_context and "_created_agents" in runtime_context:
2107
+ if old_name in runtime_context["_created_agents"]:
2108
+ agent_primitive = runtime_context["_created_agents"].pop(old_name)
2109
+ runtime_context["_created_agents"][name] = agent_primitive
2110
+ callback_logger.debug(
2111
+ f"[AGENT_RENAME] Updated _created_agents dict: '{old_name}' -> '{name}'"
2112
+ )
2113
+
2114
+ # Log all assignments for debugging (only at trace level to avoid noise)
2115
+ callback_logger.debug(f"Assignment captured: {name} = {type(value).__name__}")
2116
+
2117
+ return _tactus_register_binding