tactus 0.31.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. tactus/__init__.py +49 -0
  2. tactus/adapters/__init__.py +9 -0
  3. tactus/adapters/broker_log.py +76 -0
  4. tactus/adapters/cli_hitl.py +189 -0
  5. tactus/adapters/cli_log.py +223 -0
  6. tactus/adapters/cost_collector_log.py +56 -0
  7. tactus/adapters/file_storage.py +367 -0
  8. tactus/adapters/http_callback_log.py +109 -0
  9. tactus/adapters/ide_log.py +71 -0
  10. tactus/adapters/lua_tools.py +336 -0
  11. tactus/adapters/mcp.py +289 -0
  12. tactus/adapters/mcp_manager.py +196 -0
  13. tactus/adapters/memory.py +53 -0
  14. tactus/adapters/plugins.py +419 -0
  15. tactus/backends/http_backend.py +58 -0
  16. tactus/backends/model_backend.py +35 -0
  17. tactus/backends/pytorch_backend.py +110 -0
  18. tactus/broker/__init__.py +12 -0
  19. tactus/broker/client.py +247 -0
  20. tactus/broker/protocol.py +183 -0
  21. tactus/broker/server.py +1123 -0
  22. tactus/broker/stdio.py +12 -0
  23. tactus/cli/__init__.py +7 -0
  24. tactus/cli/app.py +2245 -0
  25. tactus/cli/commands/__init__.py +0 -0
  26. tactus/core/__init__.py +32 -0
  27. tactus/core/config_manager.py +790 -0
  28. tactus/core/dependencies/__init__.py +14 -0
  29. tactus/core/dependencies/registry.py +180 -0
  30. tactus/core/dsl_stubs.py +2117 -0
  31. tactus/core/exceptions.py +66 -0
  32. tactus/core/execution_context.py +480 -0
  33. tactus/core/lua_sandbox.py +508 -0
  34. tactus/core/message_history_manager.py +236 -0
  35. tactus/core/mocking.py +286 -0
  36. tactus/core/output_validator.py +291 -0
  37. tactus/core/registry.py +499 -0
  38. tactus/core/runtime.py +2907 -0
  39. tactus/core/template_resolver.py +142 -0
  40. tactus/core/yaml_parser.py +301 -0
  41. tactus/docker/Dockerfile +61 -0
  42. tactus/docker/entrypoint.sh +69 -0
  43. tactus/dspy/__init__.py +39 -0
  44. tactus/dspy/agent.py +1144 -0
  45. tactus/dspy/broker_lm.py +181 -0
  46. tactus/dspy/config.py +212 -0
  47. tactus/dspy/history.py +196 -0
  48. tactus/dspy/module.py +405 -0
  49. tactus/dspy/prediction.py +318 -0
  50. tactus/dspy/signature.py +185 -0
  51. tactus/formatting/__init__.py +7 -0
  52. tactus/formatting/formatter.py +437 -0
  53. tactus/ide/__init__.py +9 -0
  54. tactus/ide/coding_assistant.py +343 -0
  55. tactus/ide/server.py +2223 -0
  56. tactus/primitives/__init__.py +49 -0
  57. tactus/primitives/control.py +168 -0
  58. tactus/primitives/file.py +229 -0
  59. tactus/primitives/handles.py +378 -0
  60. tactus/primitives/host.py +94 -0
  61. tactus/primitives/human.py +342 -0
  62. tactus/primitives/json.py +189 -0
  63. tactus/primitives/log.py +187 -0
  64. tactus/primitives/message_history.py +157 -0
  65. tactus/primitives/model.py +163 -0
  66. tactus/primitives/procedure.py +564 -0
  67. tactus/primitives/procedure_callable.py +318 -0
  68. tactus/primitives/retry.py +155 -0
  69. tactus/primitives/session.py +152 -0
  70. tactus/primitives/state.py +182 -0
  71. tactus/primitives/step.py +209 -0
  72. tactus/primitives/system.py +93 -0
  73. tactus/primitives/tool.py +375 -0
  74. tactus/primitives/tool_handle.py +279 -0
  75. tactus/primitives/toolset.py +229 -0
  76. tactus/protocols/__init__.py +38 -0
  77. tactus/protocols/chat_recorder.py +81 -0
  78. tactus/protocols/config.py +97 -0
  79. tactus/protocols/cost.py +31 -0
  80. tactus/protocols/hitl.py +71 -0
  81. tactus/protocols/log_handler.py +27 -0
  82. tactus/protocols/models.py +355 -0
  83. tactus/protocols/result.py +33 -0
  84. tactus/protocols/storage.py +90 -0
  85. tactus/providers/__init__.py +13 -0
  86. tactus/providers/base.py +92 -0
  87. tactus/providers/bedrock.py +117 -0
  88. tactus/providers/google.py +105 -0
  89. tactus/providers/openai.py +98 -0
  90. tactus/sandbox/__init__.py +63 -0
  91. tactus/sandbox/config.py +171 -0
  92. tactus/sandbox/container_runner.py +1099 -0
  93. tactus/sandbox/docker_manager.py +433 -0
  94. tactus/sandbox/entrypoint.py +227 -0
  95. tactus/sandbox/protocol.py +213 -0
  96. tactus/stdlib/__init__.py +10 -0
  97. tactus/stdlib/io/__init__.py +13 -0
  98. tactus/stdlib/io/csv.py +88 -0
  99. tactus/stdlib/io/excel.py +136 -0
  100. tactus/stdlib/io/file.py +90 -0
  101. tactus/stdlib/io/fs.py +154 -0
  102. tactus/stdlib/io/hdf5.py +121 -0
  103. tactus/stdlib/io/json.py +109 -0
  104. tactus/stdlib/io/parquet.py +83 -0
  105. tactus/stdlib/io/tsv.py +88 -0
  106. tactus/stdlib/loader.py +274 -0
  107. tactus/stdlib/tac/tactus/tools/done.tac +33 -0
  108. tactus/stdlib/tac/tactus/tools/log.tac +50 -0
  109. tactus/testing/README.md +273 -0
  110. tactus/testing/__init__.py +61 -0
  111. tactus/testing/behave_integration.py +380 -0
  112. tactus/testing/context.py +486 -0
  113. tactus/testing/eval_models.py +114 -0
  114. tactus/testing/evaluation_runner.py +222 -0
  115. tactus/testing/evaluators.py +634 -0
  116. tactus/testing/events.py +94 -0
  117. tactus/testing/gherkin_parser.py +134 -0
  118. tactus/testing/mock_agent.py +315 -0
  119. tactus/testing/mock_dependencies.py +234 -0
  120. tactus/testing/mock_hitl.py +171 -0
  121. tactus/testing/mock_registry.py +168 -0
  122. tactus/testing/mock_tools.py +133 -0
  123. tactus/testing/models.py +115 -0
  124. tactus/testing/pydantic_eval_runner.py +508 -0
  125. tactus/testing/steps/__init__.py +13 -0
  126. tactus/testing/steps/builtin.py +902 -0
  127. tactus/testing/steps/custom.py +69 -0
  128. tactus/testing/steps/registry.py +68 -0
  129. tactus/testing/test_runner.py +489 -0
  130. tactus/tracing/__init__.py +5 -0
  131. tactus/tracing/trace_manager.py +417 -0
  132. tactus/utils/__init__.py +1 -0
  133. tactus/utils/cost_calculator.py +72 -0
  134. tactus/utils/model_pricing.py +132 -0
  135. tactus/utils/safe_file_library.py +502 -0
  136. tactus/utils/safe_libraries.py +234 -0
  137. tactus/validation/LuaLexerBase.py +66 -0
  138. tactus/validation/LuaParserBase.py +23 -0
  139. tactus/validation/README.md +224 -0
  140. tactus/validation/__init__.py +7 -0
  141. tactus/validation/error_listener.py +21 -0
  142. tactus/validation/generated/LuaLexer.interp +231 -0
  143. tactus/validation/generated/LuaLexer.py +5548 -0
  144. tactus/validation/generated/LuaLexer.tokens +124 -0
  145. tactus/validation/generated/LuaLexerBase.py +66 -0
  146. tactus/validation/generated/LuaParser.interp +173 -0
  147. tactus/validation/generated/LuaParser.py +6439 -0
  148. tactus/validation/generated/LuaParser.tokens +124 -0
  149. tactus/validation/generated/LuaParserBase.py +23 -0
  150. tactus/validation/generated/LuaParserVisitor.py +118 -0
  151. tactus/validation/generated/__init__.py +7 -0
  152. tactus/validation/grammar/LuaLexer.g4 +123 -0
  153. tactus/validation/grammar/LuaParser.g4 +178 -0
  154. tactus/validation/semantic_visitor.py +817 -0
  155. tactus/validation/validator.py +157 -0
  156. tactus-0.31.0.dist-info/METADATA +1809 -0
  157. tactus-0.31.0.dist-info/RECORD +160 -0
  158. tactus-0.31.0.dist-info/WHEEL +4 -0
  159. tactus-0.31.0.dist-info/entry_points.txt +2 -0
  160. tactus-0.31.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,142 @@
1
+ """
2
+ Template variable resolution for DSL strings.
3
+
4
+ Resolves template markers like {params.topic}, {state.count}, etc.
5
+ in system prompts, HITL messages, and other template strings.
6
+ """
7
+
8
+ import re
9
+ from typing import Any, Optional
10
+
11
+
12
+ class TemplateResolver:
13
+ """Resolves template variables in strings."""
14
+
15
+ # Pattern matches {namespace.key} or {namespace.key.nested}
16
+ TEMPLATE_PATTERN = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\}")
17
+
18
+ def __init__(
19
+ self,
20
+ params: Optional[dict[str, Any]] = None,
21
+ state: Optional[dict[str, Any]] = None,
22
+ outputs: Optional[dict[str, Any]] = None,
23
+ context: Optional[dict[str, Any]] = None,
24
+ prepared: Optional[dict[str, Any]] = None,
25
+ env: Optional[dict[str, str]] = None,
26
+ ):
27
+ """
28
+ Initialize template resolver with available namespaces.
29
+
30
+ Args:
31
+ params: Input parameters
32
+ state: Procedure state
33
+ outputs: Output values (for return_prompt)
34
+ context: Runtime context
35
+ prepared: Agent prepare hook output
36
+ env: Environment variables
37
+ """
38
+ self.namespaces = {
39
+ "params": params or {},
40
+ "state": state or {},
41
+ "output": outputs or {},
42
+ "context": context or {},
43
+ "prepared": prepared or {},
44
+ "env": env or {},
45
+ }
46
+
47
+ def resolve(self, template: str) -> str:
48
+ """
49
+ Resolve all template variables in a string.
50
+
51
+ Args:
52
+ template: String with {namespace.key} markers
53
+
54
+ Returns:
55
+ String with markers replaced by values
56
+
57
+ Example:
58
+ >>> resolver = TemplateResolver(params={"topic": "AI"})
59
+ >>> resolver.resolve("Research: {params.topic}")
60
+ "Research: AI"
61
+ """
62
+ if not template:
63
+ return template
64
+
65
+ def replace_match(match):
66
+ path = match.group(1)
67
+ value = self._get_value(path)
68
+ if value is None:
69
+ # Keep the marker if value not found
70
+ return match.group(0)
71
+ return str(value)
72
+
73
+ return self.TEMPLATE_PATTERN.sub(replace_match, template)
74
+
75
+ def _get_value(self, path: str) -> Any:
76
+ """
77
+ Get value from namespaces using dot notation.
78
+
79
+ Args:
80
+ path: Dot-separated path like "params.topic" or "state.count"
81
+
82
+ Returns:
83
+ Value at path, or None if not found
84
+ """
85
+ parts = path.split(".")
86
+ if not parts:
87
+ return None
88
+
89
+ # First part is the namespace
90
+ namespace_name = parts[0]
91
+ namespace = self.namespaces.get(namespace_name)
92
+ if namespace is None:
93
+ return None
94
+
95
+ # Navigate nested keys
96
+ current = namespace
97
+ for part in parts[1:]:
98
+ if isinstance(current, dict):
99
+ current = current.get(part)
100
+ else:
101
+ # Can't navigate further
102
+ return None
103
+
104
+ if current is None:
105
+ return None
106
+
107
+ return current
108
+
109
+
110
+ def resolve_template(
111
+ template: str,
112
+ params: Optional[dict[str, Any]] = None,
113
+ state: Optional[dict[str, Any]] = None,
114
+ outputs: Optional[dict[str, Any]] = None,
115
+ context: Optional[dict[str, Any]] = None,
116
+ prepared: Optional[dict[str, Any]] = None,
117
+ env: Optional[dict[str, str]] = None,
118
+ ) -> str:
119
+ """
120
+ Convenience function to resolve a template string.
121
+
122
+ Args:
123
+ template: String with {namespace.key} markers
124
+ params: Input parameters
125
+ state: Procedure state
126
+ outputs: Output values
127
+ context: Runtime context
128
+ prepared: Agent prepare hook output
129
+ env: Environment variables
130
+
131
+ Returns:
132
+ Resolved string
133
+ """
134
+ resolver = TemplateResolver(
135
+ params=params,
136
+ state=state,
137
+ outputs=outputs,
138
+ context=context,
139
+ prepared=prepared,
140
+ env=env,
141
+ )
142
+ return resolver.resolve(template)
@@ -0,0 +1,301 @@
1
+ """
2
+ YAML Parser and Validator for Lua DSL Procedures.
3
+
4
+ Parses procedure YAML configurations and validates required structure.
5
+ """
6
+
7
+ import yaml
8
+ import logging
9
+ from typing import Dict, Any, Optional, List
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class ProcedureConfigError(Exception):
15
+ """Raised when procedure configuration is invalid."""
16
+
17
+ pass
18
+
19
+
20
+ class ProcedureYAMLParser:
21
+ """Parses and validates Lua DSL procedure YAML configurations."""
22
+
23
+ @staticmethod
24
+ def parse(yaml_content: str) -> Dict[str, Any]:
25
+ """
26
+ Parse YAML content into a validated procedure configuration.
27
+
28
+ Args:
29
+ yaml_content: YAML string to parse
30
+
31
+ Returns:
32
+ Validated configuration dictionary
33
+
34
+ Raises:
35
+ ProcedureConfigError: If YAML is invalid or missing required fields
36
+ """
37
+ try:
38
+ config = yaml.safe_load(yaml_content)
39
+ except yaml.YAMLError as e:
40
+ raise ProcedureConfigError(f"Invalid YAML syntax: {e}")
41
+
42
+ if not isinstance(config, dict):
43
+ raise ProcedureConfigError("YAML root must be a dictionary")
44
+
45
+ # Validate required top-level fields
46
+ ProcedureYAMLParser._validate_required_fields(config)
47
+
48
+ # Validate specific sections
49
+ ProcedureYAMLParser._validate_params(config.get("params", {}))
50
+ ProcedureYAMLParser._validate_outputs(config.get("output", {}))
51
+ ProcedureYAMLParser._validate_default_model(config.get("default_model"))
52
+ ProcedureYAMLParser._validate_default_provider(config.get("default_provider"))
53
+ ProcedureYAMLParser._validate_agents(config.get("agents", {}), config)
54
+ ProcedureYAMLParser._validate_procedure(config.get("procedure"))
55
+
56
+ logger.info(f"Successfully parsed procedure: {config.get('name')}")
57
+ return config
58
+
59
+ @staticmethod
60
+ def _validate_required_fields(config: Dict[str, Any]) -> None:
61
+ """Validate that required top-level fields are present."""
62
+ required = ["name", "version", "procedure"]
63
+ missing = [field for field in required if field not in config]
64
+
65
+ if missing:
66
+ raise ProcedureConfigError(f"Missing required fields: {', '.join(missing)}")
67
+
68
+ # Validate class field if present (for routing)
69
+ if "class" in config:
70
+ if config["class"] != "LuaDSL":
71
+ logger.warning(
72
+ f"Procedure class '{config['class']}' may not be compatible "
73
+ "with Lua DSL runtime"
74
+ )
75
+
76
+ @staticmethod
77
+ def _validate_params(params: Dict[str, Any]) -> None:
78
+ """Validate parameter definitions."""
79
+ if not isinstance(params, dict):
80
+ raise ProcedureConfigError("'params' must be a dictionary")
81
+
82
+ for param_name, param_def in params.items():
83
+ if not isinstance(param_def, dict):
84
+ raise ProcedureConfigError(
85
+ f"Parameter '{param_name}' definition must be a dictionary"
86
+ )
87
+
88
+ # Validate type field if present
89
+ if "type" in param_def:
90
+ valid_types = ["string", "number", "boolean", "array", "object"]
91
+ if param_def["type"] not in valid_types:
92
+ raise ProcedureConfigError(
93
+ f"Parameter '{param_name}' has invalid type: {param_def['type']}. "
94
+ f"Must be one of: {', '.join(valid_types)}"
95
+ )
96
+
97
+ @staticmethod
98
+ def _validate_outputs(outputs: Dict[str, Any]) -> None:
99
+ """Validate output definitions."""
100
+ if not isinstance(outputs, dict):
101
+ raise ProcedureConfigError("'output' must be a dictionary")
102
+
103
+ for output_name, output_def in outputs.items():
104
+ if not isinstance(output_def, dict):
105
+ raise ProcedureConfigError(
106
+ f"Output '{output_name}' definition must be a dictionary"
107
+ )
108
+
109
+ # Validate type field if present
110
+ if "type" in output_def:
111
+ valid_types = ["string", "number", "boolean", "array", "object"]
112
+ if output_def["type"] not in valid_types:
113
+ raise ProcedureConfigError(
114
+ f"Output '{output_name}' has invalid type: {output_def['type']}. "
115
+ f"Must be one of: {', '.join(valid_types)}"
116
+ )
117
+
118
+ @staticmethod
119
+ def _validate_default_model(default_model: Optional[str]) -> None:
120
+ """Validate default_model field if present."""
121
+ if default_model is not None:
122
+ if not isinstance(default_model, str):
123
+ raise ProcedureConfigError("'default_model' must be a string")
124
+ if not default_model.strip():
125
+ raise ProcedureConfigError("'default_model' cannot be empty")
126
+
127
+ @staticmethod
128
+ def _validate_default_provider(default_provider: Optional[str]) -> None:
129
+ """Validate default_provider field if present."""
130
+ if default_provider is not None:
131
+ if not isinstance(default_provider, str):
132
+ raise ProcedureConfigError("'default_provider' must be a string")
133
+ if not default_provider.strip():
134
+ raise ProcedureConfigError("'default_provider' cannot be empty")
135
+ # Validate it's a known provider
136
+ valid_providers = ["openai", "bedrock"]
137
+ if default_provider not in valid_providers:
138
+ raise ProcedureConfigError(
139
+ f"'default_provider' must be one of: {', '.join(valid_providers)}. "
140
+ f"Got: {default_provider}"
141
+ )
142
+
143
+ @staticmethod
144
+ def _validate_agents(agents: Dict[str, Any], config: Dict[str, Any]) -> None:
145
+ """Validate agent definitions."""
146
+ if not isinstance(agents, dict):
147
+ raise ProcedureConfigError("'agents' must be a dictionary")
148
+
149
+ if not agents:
150
+ raise ProcedureConfigError("At least one agent must be defined")
151
+
152
+ for agent_name, agent_def in agents.items():
153
+ if not isinstance(agent_def, dict):
154
+ raise ProcedureConfigError(f"Agent '{agent_name}' definition must be a dictionary")
155
+
156
+ # Validate required agent fields
157
+ required_agent_fields = ["system_prompt", "initial_message"]
158
+ missing = [field for field in required_agent_fields if field not in agent_def]
159
+
160
+ if missing:
161
+ raise ProcedureConfigError(
162
+ f"Agent '{agent_name}' missing required fields: {', '.join(missing)}"
163
+ )
164
+
165
+ # Validate model field if present
166
+ if "model" in agent_def:
167
+ model_value = agent_def["model"]
168
+
169
+ # Model can be either a string or a dict with settings
170
+ if isinstance(model_value, str):
171
+ if not model_value.strip():
172
+ raise ProcedureConfigError(f"Agent '{agent_name}' model cannot be empty")
173
+ elif isinstance(model_value, dict):
174
+ # Model is a dict - must have 'name' key
175
+ if "name" not in model_value:
176
+ raise ProcedureConfigError(
177
+ f"Agent '{agent_name}' model dict must have a 'name' key"
178
+ )
179
+ if not isinstance(model_value["name"], str):
180
+ raise ProcedureConfigError(
181
+ f"Agent '{agent_name}' model name must be a string"
182
+ )
183
+ if not model_value["name"].strip():
184
+ raise ProcedureConfigError(
185
+ f"Agent '{agent_name}' model name cannot be empty"
186
+ )
187
+
188
+ # Validate model settings in the dict
189
+ valid_settings = {
190
+ "name", # The model name itself
191
+ # Standard parameters (GPT-4 models)
192
+ "temperature",
193
+ "top_p",
194
+ "max_tokens",
195
+ "presence_penalty",
196
+ "frequency_penalty",
197
+ "logit_bias",
198
+ "stop_sequences",
199
+ "seed",
200
+ "parallel_tool_calls",
201
+ "timeout",
202
+ # OpenAI reasoning models (o1, GPT-5)
203
+ "openai_reasoning_effort",
204
+ # Extra fields
205
+ "extra_headers",
206
+ "extra_body",
207
+ }
208
+
209
+ for setting_key in model_value.keys():
210
+ if setting_key not in valid_settings:
211
+ raise ProcedureConfigError(
212
+ f"Agent '{agent_name}' has unknown model setting: '{setting_key}'. "
213
+ f"Valid keys: {', '.join(sorted(valid_settings))}"
214
+ )
215
+
216
+ # Validate specific field types
217
+ if "temperature" in model_value:
218
+ temp = model_value["temperature"]
219
+ if not isinstance(temp, (int, float)) or temp < 0 or temp > 2:
220
+ raise ProcedureConfigError(
221
+ f"Agent '{agent_name}' temperature must be a number between 0 and 2"
222
+ )
223
+
224
+ if "top_p" in model_value:
225
+ top_p = model_value["top_p"]
226
+ if not isinstance(top_p, (int, float)) or top_p < 0 or top_p > 1:
227
+ raise ProcedureConfigError(
228
+ f"Agent '{agent_name}' top_p must be a number between 0 and 1"
229
+ )
230
+
231
+ if "max_tokens" in model_value:
232
+ max_tok = model_value["max_tokens"]
233
+ if not isinstance(max_tok, int) or max_tok < 1:
234
+ raise ProcedureConfigError(
235
+ f"Agent '{agent_name}' max_tokens must be a positive integer"
236
+ )
237
+
238
+ if "openai_reasoning_effort" in model_value:
239
+ effort = model_value["openai_reasoning_effort"]
240
+ valid_efforts = ["low", "medium", "high"]
241
+ if effort not in valid_efforts:
242
+ raise ProcedureConfigError(
243
+ f"Agent '{agent_name}' openai_reasoning_effort must be one of: {', '.join(valid_efforts)}. "
244
+ f"Got: {effort}"
245
+ )
246
+ else:
247
+ raise ProcedureConfigError(
248
+ f"Agent '{agent_name}' model must be a string or dict with 'name' key"
249
+ )
250
+
251
+ # Validate provider field - required unless default_provider is set
252
+ has_default_provider = "default_provider" in config
253
+ if "provider" not in agent_def and not has_default_provider:
254
+ raise ProcedureConfigError(
255
+ f"Agent '{agent_name}' must specify 'provider' (or set 'default_provider' at procedure level)"
256
+ )
257
+
258
+ if "provider" in agent_def:
259
+ if not isinstance(agent_def["provider"], str):
260
+ raise ProcedureConfigError(f"Agent '{agent_name}' provider must be a string")
261
+ if not agent_def["provider"].strip():
262
+ raise ProcedureConfigError(f"Agent '{agent_name}' provider cannot be empty")
263
+ # Validate it's a known provider
264
+ valid_providers = ["openai", "bedrock"]
265
+ if agent_def["provider"] not in valid_providers:
266
+ raise ProcedureConfigError(
267
+ f"Agent '{agent_name}' provider must be one of: {', '.join(valid_providers)}. "
268
+ f"Got: {agent_def['provider']}"
269
+ )
270
+
271
+ # Validate tools field if present
272
+ if "tools" in agent_def:
273
+ if not isinstance(agent_def["tools"], list):
274
+ raise ProcedureConfigError(f"Agent '{agent_name}' tools must be a list")
275
+
276
+ @staticmethod
277
+ def _validate_procedure(procedure: Optional[str]) -> None:
278
+ """Validate procedure Lua code."""
279
+ if not procedure:
280
+ raise ProcedureConfigError("'procedure' field is required")
281
+
282
+ if not isinstance(procedure, str):
283
+ raise ProcedureConfigError("'procedure' must be a string")
284
+
285
+ if not procedure.strip():
286
+ raise ProcedureConfigError("'procedure' cannot be empty")
287
+
288
+ # Basic Lua syntax check (just verify it's not obviously broken)
289
+ # The actual Lua runtime will do full validation
290
+ if procedure.count("(") != procedure.count(")"):
291
+ logger.warning("Procedure has unmatched parentheses - may have syntax errors")
292
+
293
+ @staticmethod
294
+ def extract_agent_names(config: Dict[str, Any]) -> List[str]:
295
+ """Extract list of agent names from configuration."""
296
+ return list(config.get("agents", {}).keys())
297
+
298
+ @staticmethod
299
+ def get_agent_config(config: Dict[str, Any], agent_name: str) -> Optional[Dict[str, Any]]:
300
+ """Get configuration for a specific agent."""
301
+ return config.get("agents", {}).get(agent_name)
@@ -0,0 +1,61 @@
1
+ # Tactus Sandbox Container
2
+ #
3
+ # This Dockerfile creates an isolated environment for running Tactus procedures
4
+ # securely. The container includes all necessary runtimes for Tactus and MCP servers.
5
+ #
6
+ # Build: docker build -t tactus-sandbox:local -f tactus/docker/Dockerfile .
7
+ # Run: docker run -i --rm tactus-sandbox:local
8
+
9
+ FROM python:3.11-slim
10
+
11
+ # Labels for image management
12
+ ARG TACTUS_VERSION=dev
13
+ LABEL tactus.version="${TACTUS_VERSION}"
14
+ LABEL maintainer="Anthus <info@anthus.ai>"
15
+ LABEL description="Tactus sandbox container for secure procedure execution"
16
+
17
+ # Install system dependencies
18
+ # - Node.js for JavaScript/TypeScript MCP servers
19
+ # - git for any git operations
20
+ # - build-essential for native Python packages
21
+ RUN apt-get update && apt-get install -y --no-install-recommends \
22
+ curl \
23
+ git \
24
+ build-essential \
25
+ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
26
+ && apt-get install -y --no-install-recommends nodejs \
27
+ && apt-get clean \
28
+ && rm -rf /var/lib/apt/lists/*
29
+
30
+ # Create non-root user for security
31
+ RUN useradd -m -s /bin/bash tactus
32
+ WORKDIR /app
33
+
34
+ # Copy package files first (for better caching)
35
+ COPY pyproject.toml ./
36
+ COPY README.md ./
37
+ COPY tactus/ ./tactus/
38
+
39
+ # Ensure the non-root runtime user can read the codebase even if the host
40
+ # working tree has restrictive permissions (e.g., umask 077).
41
+ RUN chmod -R a+rX /app/tactus
42
+
43
+ # Install Tactus and its dependencies
44
+ RUN pip install --no-cache-dir -e .
45
+
46
+ # Create workspace and mcp-servers directories
47
+ RUN mkdir -p /workspace /mcp-servers && \
48
+ chown -R tactus:tactus /workspace /mcp-servers
49
+
50
+ # Copy entrypoint script
51
+ COPY tactus/docker/entrypoint.sh /entrypoint.sh
52
+ RUN chmod +x /entrypoint.sh
53
+
54
+ # Switch to non-root user
55
+ USER tactus
56
+
57
+ # Set working directory for procedure execution
58
+ WORKDIR /workspace
59
+
60
+ # Default entrypoint runs the sandbox entrypoint module
61
+ ENTRYPOINT ["/entrypoint.sh"]
@@ -0,0 +1,69 @@
1
+ #!/bin/bash
2
+ #
3
+ # Entrypoint script for Tactus sandbox container.
4
+ #
5
+ # This script:
6
+ # 1. Starts any configured MCP servers from /mcp-servers
7
+ # 2. Runs the Tactus sandbox entrypoint to execute the procedure
8
+ # 3. Cleans up MCP server processes on exit
9
+ #
10
+
11
+ set -e
12
+
13
+ # Cleanup function to kill MCP servers on exit
14
+ cleanup() {
15
+ if [ -n "$MCP_PIDS" ]; then
16
+ echo "[sandbox] Stopping MCP servers..." >&2
17
+ for pid in $MCP_PIDS; do
18
+ kill "$pid" 2>/dev/null || true
19
+ done
20
+ fi
21
+ }
22
+ trap cleanup EXIT
23
+
24
+ MCP_PIDS=""
25
+
26
+ # Start MCP servers if directory is mounted and contains servers
27
+ if [ -d "/mcp-servers" ] && [ "$(ls -A /mcp-servers 2>/dev/null)" ]; then
28
+ echo "[sandbox] Found MCP servers directory" >&2
29
+
30
+ for server_dir in /mcp-servers/*/; do
31
+ if [ ! -d "$server_dir" ]; then
32
+ continue
33
+ fi
34
+
35
+ server_name=$(basename "$server_dir")
36
+ echo "[sandbox] Starting MCP server: $server_name" >&2
37
+
38
+ # Check for Python server
39
+ if [ -f "${server_dir}server.py" ]; then
40
+ # Check for virtualenv
41
+ if [ -d "${server_dir}.venv" ]; then
42
+ source "${server_dir}.venv/bin/activate"
43
+ python "${server_dir}server.py" &
44
+ deactivate
45
+ else
46
+ python "${server_dir}server.py" &
47
+ fi
48
+ MCP_PIDS="$MCP_PIDS $!"
49
+
50
+ # Check for Node.js server
51
+ elif [ -f "${server_dir}server.js" ]; then
52
+ node "${server_dir}server.js" &
53
+ MCP_PIDS="$MCP_PIDS $!"
54
+
55
+ elif [ -f "${server_dir}index.js" ]; then
56
+ node "${server_dir}index.js" &
57
+ MCP_PIDS="$MCP_PIDS $!"
58
+ fi
59
+ done
60
+
61
+ # Give MCP servers a moment to start
62
+ if [ -n "$MCP_PIDS" ]; then
63
+ sleep 1
64
+ fi
65
+ fi
66
+
67
+ # Run the Tactus sandbox entrypoint with unbuffered I/O (-u flag)
68
+ # This ensures stdin/stdout/stderr are not buffered, enabling real-time streaming
69
+ exec python -u -m tactus.sandbox.entrypoint "$@"
@@ -0,0 +1,39 @@
1
+ """
2
+ Tactus DSPy Integration
3
+
4
+ This module provides the integration layer between Tactus and DSPy,
5
+ exposing DSPy primitives as first-class Tactus language constructs.
6
+
7
+ The integration follows a layered approach:
8
+ - Low-level primitives (Module, Signature, etc.) are thin wrappers over DSPy
9
+ - High-level constructs (Agent) are built in Tactus using these primitives
10
+ """
11
+
12
+ from tactus.dspy.agent import DSPyAgentHandle, create_dspy_agent
13
+ from tactus.dspy.config import configure_lm, get_current_lm, reset_lm_configuration
14
+ from tactus.dspy.history import TactusHistory, create_history
15
+ from tactus.dspy.module import TactusModule, create_module
16
+ from tactus.dspy.prediction import TactusPrediction, create_prediction, wrap_prediction
17
+ from tactus.dspy.signature import (
18
+ create_signature,
19
+ create_structured_signature,
20
+ parse_signature_string,
21
+ )
22
+
23
+ __all__ = [
24
+ "configure_lm",
25
+ "get_current_lm",
26
+ "reset_lm_configuration",
27
+ "create_signature",
28
+ "create_structured_signature",
29
+ "parse_signature_string",
30
+ "TactusModule",
31
+ "create_module",
32
+ "TactusHistory",
33
+ "create_history",
34
+ "TactusPrediction",
35
+ "create_prediction",
36
+ "wrap_prediction",
37
+ "DSPyAgentHandle",
38
+ "create_dspy_agent",
39
+ ]