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.
- tactus/__init__.py +49 -0
- tactus/adapters/__init__.py +9 -0
- tactus/adapters/broker_log.py +76 -0
- tactus/adapters/cli_hitl.py +189 -0
- tactus/adapters/cli_log.py +223 -0
- tactus/adapters/cost_collector_log.py +56 -0
- tactus/adapters/file_storage.py +367 -0
- tactus/adapters/http_callback_log.py +109 -0
- tactus/adapters/ide_log.py +71 -0
- tactus/adapters/lua_tools.py +336 -0
- tactus/adapters/mcp.py +289 -0
- tactus/adapters/mcp_manager.py +196 -0
- tactus/adapters/memory.py +53 -0
- tactus/adapters/plugins.py +419 -0
- tactus/backends/http_backend.py +58 -0
- tactus/backends/model_backend.py +35 -0
- tactus/backends/pytorch_backend.py +110 -0
- tactus/broker/__init__.py +12 -0
- tactus/broker/client.py +247 -0
- tactus/broker/protocol.py +183 -0
- tactus/broker/server.py +1123 -0
- tactus/broker/stdio.py +12 -0
- tactus/cli/__init__.py +7 -0
- tactus/cli/app.py +2245 -0
- tactus/cli/commands/__init__.py +0 -0
- tactus/core/__init__.py +32 -0
- tactus/core/config_manager.py +790 -0
- tactus/core/dependencies/__init__.py +14 -0
- tactus/core/dependencies/registry.py +180 -0
- tactus/core/dsl_stubs.py +2117 -0
- tactus/core/exceptions.py +66 -0
- tactus/core/execution_context.py +480 -0
- tactus/core/lua_sandbox.py +508 -0
- tactus/core/message_history_manager.py +236 -0
- tactus/core/mocking.py +286 -0
- tactus/core/output_validator.py +291 -0
- tactus/core/registry.py +499 -0
- tactus/core/runtime.py +2907 -0
- tactus/core/template_resolver.py +142 -0
- tactus/core/yaml_parser.py +301 -0
- tactus/docker/Dockerfile +61 -0
- tactus/docker/entrypoint.sh +69 -0
- tactus/dspy/__init__.py +39 -0
- tactus/dspy/agent.py +1144 -0
- tactus/dspy/broker_lm.py +181 -0
- tactus/dspy/config.py +212 -0
- tactus/dspy/history.py +196 -0
- tactus/dspy/module.py +405 -0
- tactus/dspy/prediction.py +318 -0
- tactus/dspy/signature.py +185 -0
- tactus/formatting/__init__.py +7 -0
- tactus/formatting/formatter.py +437 -0
- tactus/ide/__init__.py +9 -0
- tactus/ide/coding_assistant.py +343 -0
- tactus/ide/server.py +2223 -0
- tactus/primitives/__init__.py +49 -0
- tactus/primitives/control.py +168 -0
- tactus/primitives/file.py +229 -0
- tactus/primitives/handles.py +378 -0
- tactus/primitives/host.py +94 -0
- tactus/primitives/human.py +342 -0
- tactus/primitives/json.py +189 -0
- tactus/primitives/log.py +187 -0
- tactus/primitives/message_history.py +157 -0
- tactus/primitives/model.py +163 -0
- tactus/primitives/procedure.py +564 -0
- tactus/primitives/procedure_callable.py +318 -0
- tactus/primitives/retry.py +155 -0
- tactus/primitives/session.py +152 -0
- tactus/primitives/state.py +182 -0
- tactus/primitives/step.py +209 -0
- tactus/primitives/system.py +93 -0
- tactus/primitives/tool.py +375 -0
- tactus/primitives/tool_handle.py +279 -0
- tactus/primitives/toolset.py +229 -0
- tactus/protocols/__init__.py +38 -0
- tactus/protocols/chat_recorder.py +81 -0
- tactus/protocols/config.py +97 -0
- tactus/protocols/cost.py +31 -0
- tactus/protocols/hitl.py +71 -0
- tactus/protocols/log_handler.py +27 -0
- tactus/protocols/models.py +355 -0
- tactus/protocols/result.py +33 -0
- tactus/protocols/storage.py +90 -0
- tactus/providers/__init__.py +13 -0
- tactus/providers/base.py +92 -0
- tactus/providers/bedrock.py +117 -0
- tactus/providers/google.py +105 -0
- tactus/providers/openai.py +98 -0
- tactus/sandbox/__init__.py +63 -0
- tactus/sandbox/config.py +171 -0
- tactus/sandbox/container_runner.py +1099 -0
- tactus/sandbox/docker_manager.py +433 -0
- tactus/sandbox/entrypoint.py +227 -0
- tactus/sandbox/protocol.py +213 -0
- tactus/stdlib/__init__.py +10 -0
- tactus/stdlib/io/__init__.py +13 -0
- tactus/stdlib/io/csv.py +88 -0
- tactus/stdlib/io/excel.py +136 -0
- tactus/stdlib/io/file.py +90 -0
- tactus/stdlib/io/fs.py +154 -0
- tactus/stdlib/io/hdf5.py +121 -0
- tactus/stdlib/io/json.py +109 -0
- tactus/stdlib/io/parquet.py +83 -0
- tactus/stdlib/io/tsv.py +88 -0
- tactus/stdlib/loader.py +274 -0
- tactus/stdlib/tac/tactus/tools/done.tac +33 -0
- tactus/stdlib/tac/tactus/tools/log.tac +50 -0
- tactus/testing/README.md +273 -0
- tactus/testing/__init__.py +61 -0
- tactus/testing/behave_integration.py +380 -0
- tactus/testing/context.py +486 -0
- tactus/testing/eval_models.py +114 -0
- tactus/testing/evaluation_runner.py +222 -0
- tactus/testing/evaluators.py +634 -0
- tactus/testing/events.py +94 -0
- tactus/testing/gherkin_parser.py +134 -0
- tactus/testing/mock_agent.py +315 -0
- tactus/testing/mock_dependencies.py +234 -0
- tactus/testing/mock_hitl.py +171 -0
- tactus/testing/mock_registry.py +168 -0
- tactus/testing/mock_tools.py +133 -0
- tactus/testing/models.py +115 -0
- tactus/testing/pydantic_eval_runner.py +508 -0
- tactus/testing/steps/__init__.py +13 -0
- tactus/testing/steps/builtin.py +902 -0
- tactus/testing/steps/custom.py +69 -0
- tactus/testing/steps/registry.py +68 -0
- tactus/testing/test_runner.py +489 -0
- tactus/tracing/__init__.py +5 -0
- tactus/tracing/trace_manager.py +417 -0
- tactus/utils/__init__.py +1 -0
- tactus/utils/cost_calculator.py +72 -0
- tactus/utils/model_pricing.py +132 -0
- tactus/utils/safe_file_library.py +502 -0
- tactus/utils/safe_libraries.py +234 -0
- tactus/validation/LuaLexerBase.py +66 -0
- tactus/validation/LuaParserBase.py +23 -0
- tactus/validation/README.md +224 -0
- tactus/validation/__init__.py +7 -0
- tactus/validation/error_listener.py +21 -0
- tactus/validation/generated/LuaLexer.interp +231 -0
- tactus/validation/generated/LuaLexer.py +5548 -0
- tactus/validation/generated/LuaLexer.tokens +124 -0
- tactus/validation/generated/LuaLexerBase.py +66 -0
- tactus/validation/generated/LuaParser.interp +173 -0
- tactus/validation/generated/LuaParser.py +6439 -0
- tactus/validation/generated/LuaParser.tokens +124 -0
- tactus/validation/generated/LuaParserBase.py +23 -0
- tactus/validation/generated/LuaParserVisitor.py +118 -0
- tactus/validation/generated/__init__.py +7 -0
- tactus/validation/grammar/LuaLexer.g4 +123 -0
- tactus/validation/grammar/LuaParser.g4 +178 -0
- tactus/validation/semantic_visitor.py +817 -0
- tactus/validation/validator.py +157 -0
- tactus-0.31.2.dist-info/METADATA +1809 -0
- tactus-0.31.2.dist-info/RECORD +160 -0
- tactus-0.31.2.dist-info/WHEEL +4 -0
- tactus-0.31.2.dist-info/entry_points.txt +2 -0
- tactus-0.31.2.dist-info/licenses/LICENSE +21 -0
tactus/core/dsl_stubs.py
ADDED
|
@@ -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
|