tactus 0.34.1__py3-none-any.whl → 0.35.1__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 +1 -1
- tactus/adapters/broker_log.py +17 -14
- tactus/adapters/channels/__init__.py +17 -15
- tactus/adapters/channels/base.py +16 -7
- tactus/adapters/channels/broker.py +43 -13
- tactus/adapters/channels/cli.py +19 -15
- tactus/adapters/channels/host.py +40 -25
- tactus/adapters/channels/ipc.py +82 -31
- tactus/adapters/channels/sse.py +41 -23
- tactus/adapters/cli_hitl.py +19 -19
- tactus/adapters/cli_log.py +4 -4
- tactus/adapters/control_loop.py +138 -99
- tactus/adapters/cost_collector_log.py +9 -9
- tactus/adapters/file_storage.py +56 -52
- tactus/adapters/http_callback_log.py +23 -13
- tactus/adapters/ide_log.py +17 -9
- tactus/adapters/lua_tools.py +4 -5
- tactus/adapters/mcp.py +16 -19
- tactus/adapters/mcp_manager.py +46 -30
- tactus/adapters/memory.py +9 -9
- tactus/adapters/plugins.py +42 -42
- tactus/broker/client.py +75 -78
- tactus/broker/protocol.py +57 -57
- tactus/broker/server.py +252 -197
- tactus/cli/app.py +3 -1
- tactus/cli/control.py +2 -2
- tactus/core/config_manager.py +181 -135
- tactus/core/dependencies/registry.py +66 -48
- tactus/core/dsl_stubs.py +222 -163
- tactus/core/exceptions.py +10 -1
- tactus/core/execution_context.py +152 -112
- tactus/core/lua_sandbox.py +72 -64
- tactus/core/message_history_manager.py +138 -43
- tactus/core/mocking.py +41 -27
- tactus/core/output_validator.py +49 -44
- tactus/core/registry.py +94 -80
- tactus/core/runtime.py +211 -176
- tactus/core/template_resolver.py +16 -16
- tactus/core/yaml_parser.py +55 -45
- tactus/docs/extractor.py +7 -6
- tactus/ide/server.py +119 -78
- tactus/primitives/control.py +10 -6
- tactus/primitives/file.py +48 -46
- tactus/primitives/handles.py +47 -35
- tactus/primitives/host.py +29 -27
- tactus/primitives/human.py +154 -137
- tactus/primitives/json.py +22 -23
- tactus/primitives/log.py +26 -26
- tactus/primitives/message_history.py +285 -31
- tactus/primitives/model.py +15 -9
- tactus/primitives/procedure.py +86 -64
- tactus/primitives/procedure_callable.py +58 -51
- tactus/primitives/retry.py +31 -29
- tactus/primitives/session.py +42 -29
- tactus/primitives/state.py +54 -43
- tactus/primitives/step.py +9 -13
- tactus/primitives/system.py +34 -21
- tactus/primitives/tool.py +44 -31
- tactus/primitives/tool_handle.py +76 -54
- tactus/primitives/toolset.py +25 -22
- tactus/sandbox/config.py +4 -4
- tactus/sandbox/container_runner.py +161 -107
- tactus/sandbox/docker_manager.py +20 -20
- tactus/sandbox/entrypoint.py +16 -14
- tactus/sandbox/protocol.py +15 -15
- tactus/stdlib/classify/llm.py +1 -3
- tactus/stdlib/core/validation.py +0 -3
- tactus/testing/pydantic_eval_runner.py +1 -1
- tactus/utils/asyncio_helpers.py +27 -0
- tactus/utils/cost_calculator.py +7 -7
- tactus/utils/model_pricing.py +11 -12
- tactus/utils/safe_file_library.py +156 -132
- tactus/utils/safe_libraries.py +27 -27
- tactus/validation/error_listener.py +18 -5
- tactus/validation/semantic_visitor.py +392 -333
- tactus/validation/validator.py +89 -49
- {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/METADATA +15 -3
- {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/RECORD +81 -80
- {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/WHEEL +0 -0
- {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/entry_points.txt +0 -0
- {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/licenses/LICENSE +0 -0
tactus/core/mocking.py
CHANGED
|
@@ -8,9 +8,9 @@ Provides comprehensive mocking capabilities including:
|
|
|
8
8
|
- Mock state tracking and assertions
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
import logging
|
|
12
|
-
from typing import Any, Dict, List, Optional, Union
|
|
13
11
|
from dataclasses import dataclass, field
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any, Optional, Union
|
|
14
14
|
|
|
15
15
|
logger = logging.getLogger(__name__)
|
|
16
16
|
|
|
@@ -20,7 +20,7 @@ class MockCall:
|
|
|
20
20
|
"""Record of a mock tool call."""
|
|
21
21
|
|
|
22
22
|
tool_name: str
|
|
23
|
-
args:
|
|
23
|
+
args: dict[str, Any]
|
|
24
24
|
result: Any
|
|
25
25
|
call_number: int
|
|
26
26
|
timestamp: float
|
|
@@ -36,10 +36,10 @@ class MockConfig:
|
|
|
36
36
|
static_result: Optional[Any] = None
|
|
37
37
|
|
|
38
38
|
# Temporal mocks - return different values per call
|
|
39
|
-
temporal_results:
|
|
39
|
+
temporal_results: list[Any] = field(default_factory=list)
|
|
40
40
|
|
|
41
41
|
# Conditional mocks - return based on args
|
|
42
|
-
conditional_mocks:
|
|
42
|
+
conditional_mocks: list[dict[str, Any]] = field(default_factory=list)
|
|
43
43
|
|
|
44
44
|
# Error simulation
|
|
45
45
|
error: Optional[str] = None
|
|
@@ -58,12 +58,12 @@ class MockManager:
|
|
|
58
58
|
|
|
59
59
|
def __init__(self):
|
|
60
60
|
"""Initialize mock manager."""
|
|
61
|
-
self.mocks:
|
|
62
|
-
self.call_history:
|
|
63
|
-
self.call_counts:
|
|
61
|
+
self.mocks: dict[str, MockConfig] = {}
|
|
62
|
+
self.call_history: dict[str, list[MockCall]] = {}
|
|
63
|
+
self.call_counts: dict[str, int] = {}
|
|
64
64
|
self.enabled = True # Global mock enable/disable
|
|
65
65
|
|
|
66
|
-
def register_mock(self, tool_name: str, config: Union[MockConfig,
|
|
66
|
+
def register_mock(self, tool_name: str, config: Union[MockConfig, dict[str, Any]]) -> None:
|
|
67
67
|
"""
|
|
68
68
|
Register a mock configuration for a tool.
|
|
69
69
|
|
|
@@ -89,9 +89,9 @@ class MockManager:
|
|
|
89
89
|
mock_config = config
|
|
90
90
|
|
|
91
91
|
self.mocks[tool_name] = mock_config
|
|
92
|
-
logger.info(
|
|
92
|
+
logger.info("Registered mock for tool '%s'", tool_name)
|
|
93
93
|
|
|
94
|
-
def get_mock_response(self, tool_name: str, args:
|
|
94
|
+
def get_mock_response(self, tool_name: str, args: dict[str, Any]) -> Optional[Any]:
|
|
95
95
|
"""
|
|
96
96
|
Get mock response for a tool call.
|
|
97
97
|
|
|
@@ -125,14 +125,20 @@ class MockManager:
|
|
|
125
125
|
if call_number <= len(mock_config.temporal_results):
|
|
126
126
|
result = mock_config.temporal_results[call_number - 1]
|
|
127
127
|
logger.debug(
|
|
128
|
-
|
|
128
|
+
"Mock '%s' returning temporal result for call %s: %s",
|
|
129
|
+
tool_name,
|
|
130
|
+
call_number,
|
|
131
|
+
result,
|
|
129
132
|
)
|
|
130
133
|
return result
|
|
131
134
|
else:
|
|
132
135
|
# Fallback to last result if we've exceeded temporal results
|
|
133
136
|
result = mock_config.temporal_results[-1]
|
|
134
137
|
logger.debug(
|
|
135
|
-
|
|
138
|
+
"Mock '%s' returning last temporal result (call %s): %s",
|
|
139
|
+
tool_name,
|
|
140
|
+
call_number,
|
|
141
|
+
result,
|
|
136
142
|
)
|
|
137
143
|
return result
|
|
138
144
|
|
|
@@ -141,18 +147,26 @@ class MockManager:
|
|
|
141
147
|
condition = conditional.get("when", {})
|
|
142
148
|
if self._matches_condition(args, condition):
|
|
143
149
|
result = conditional.get("return")
|
|
144
|
-
logger.debug(
|
|
150
|
+
logger.debug(
|
|
151
|
+
"Mock '%s' matched condition, returning: %s",
|
|
152
|
+
tool_name,
|
|
153
|
+
result,
|
|
154
|
+
)
|
|
145
155
|
return result
|
|
146
156
|
|
|
147
157
|
# Return static result if configured
|
|
148
158
|
if mock_config.static_result is not None:
|
|
149
|
-
logger.debug(
|
|
159
|
+
logger.debug(
|
|
160
|
+
"Mock '%s' returning static result: %s",
|
|
161
|
+
tool_name,
|
|
162
|
+
mock_config.static_result,
|
|
163
|
+
)
|
|
150
164
|
return mock_config.static_result
|
|
151
165
|
|
|
152
166
|
# No mock response configured
|
|
153
167
|
return None
|
|
154
168
|
|
|
155
|
-
def record_call(self, tool_name: str, args:
|
|
169
|
+
def record_call(self, tool_name: str, args: dict[str, Any], result: Any) -> None:
|
|
156
170
|
"""
|
|
157
171
|
Record a tool call for assertions.
|
|
158
172
|
|
|
@@ -180,7 +194,7 @@ class MockManager:
|
|
|
180
194
|
self.call_history[tool_name] = []
|
|
181
195
|
self.call_history[tool_name].append(call)
|
|
182
196
|
|
|
183
|
-
logger.debug(
|
|
197
|
+
logger.debug("Recorded call to '%s' (call #%s)", tool_name, call.call_number)
|
|
184
198
|
|
|
185
199
|
def get_call_count(self, tool_name: str) -> int:
|
|
186
200
|
"""
|
|
@@ -194,7 +208,7 @@ class MockManager:
|
|
|
194
208
|
"""
|
|
195
209
|
return self.call_counts.get(tool_name, 0)
|
|
196
210
|
|
|
197
|
-
def get_call_history(self, tool_name: str) ->
|
|
211
|
+
def get_call_history(self, tool_name: str) -> list[MockCall]:
|
|
198
212
|
"""
|
|
199
213
|
Get the call history for a tool.
|
|
200
214
|
|
|
@@ -212,7 +226,7 @@ class MockManager:
|
|
|
212
226
|
self.call_counts.clear()
|
|
213
227
|
logger.debug("Mock manager state reset")
|
|
214
228
|
|
|
215
|
-
def _matches_condition(self, args:
|
|
229
|
+
def _matches_condition(self, args: dict[str, Any], condition: dict[str, Any]) -> bool:
|
|
216
230
|
"""
|
|
217
231
|
Check if args match a condition.
|
|
218
232
|
|
|
@@ -227,30 +241,30 @@ class MockManager:
|
|
|
227
241
|
if key not in args:
|
|
228
242
|
return False
|
|
229
243
|
|
|
230
|
-
|
|
244
|
+
argument_value = args[key]
|
|
231
245
|
|
|
232
246
|
# Handle different pattern types
|
|
233
247
|
if isinstance(pattern, str):
|
|
234
248
|
# Check for operators
|
|
235
249
|
if pattern.startswith("contains:"):
|
|
236
250
|
substring = pattern[9:].strip()
|
|
237
|
-
if substring not in str(
|
|
251
|
+
if substring not in str(argument_value):
|
|
238
252
|
return False
|
|
239
253
|
elif pattern.startswith("startswith:"):
|
|
240
254
|
prefix = pattern[11:].strip()
|
|
241
|
-
if not str(
|
|
255
|
+
if not str(argument_value).startswith(prefix):
|
|
242
256
|
return False
|
|
243
257
|
elif pattern.startswith("endswith:"):
|
|
244
258
|
suffix = pattern[9:].strip()
|
|
245
|
-
if not str(
|
|
259
|
+
if not str(argument_value).endswith(suffix):
|
|
246
260
|
return False
|
|
247
261
|
else:
|
|
248
262
|
# Exact match
|
|
249
|
-
if
|
|
263
|
+
if argument_value != pattern:
|
|
250
264
|
return False
|
|
251
265
|
else:
|
|
252
266
|
# Direct comparison
|
|
253
|
-
if
|
|
267
|
+
if argument_value != pattern:
|
|
254
268
|
return False
|
|
255
269
|
|
|
256
270
|
return True
|
|
@@ -265,7 +279,7 @@ class MockManager:
|
|
|
265
279
|
if tool_name:
|
|
266
280
|
if tool_name in self.mocks:
|
|
267
281
|
self.mocks[tool_name].enabled = True
|
|
268
|
-
logger.info(
|
|
282
|
+
logger.info("Enabled mock for tool '%s'", tool_name)
|
|
269
283
|
else:
|
|
270
284
|
self.enabled = True
|
|
271
285
|
logger.info("Enabled all mocks")
|
|
@@ -280,7 +294,7 @@ class MockManager:
|
|
|
280
294
|
if tool_name:
|
|
281
295
|
if tool_name in self.mocks:
|
|
282
296
|
self.mocks[tool_name].enabled = False
|
|
283
|
-
logger.info(
|
|
297
|
+
logger.info("Disabled mock for tool '%s'", tool_name)
|
|
284
298
|
else:
|
|
285
299
|
self.enabled = False
|
|
286
300
|
logger.info("Disabled all mocks")
|
tactus/core/output_validator.py
CHANGED
|
@@ -6,7 +6,7 @@ Enables type safety and composability for sub-agent workflows.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
|
-
from typing import Any, Optional
|
|
9
|
+
from typing import Any, Optional
|
|
10
10
|
|
|
11
11
|
logger = logging.getLogger(__name__)
|
|
12
12
|
|
|
@@ -74,7 +74,22 @@ class OutputValidator:
|
|
|
74
74
|
field_count = len(self.schema)
|
|
75
75
|
except TypeError:
|
|
76
76
|
field_count = 0
|
|
77
|
-
logger.debug(
|
|
77
|
+
logger.debug("OutputValidator initialized with %s output fields", field_count)
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _unwrap_result(output: Any) -> tuple[Any, Any | None]:
|
|
81
|
+
from tactus.protocols.result import TactusResult
|
|
82
|
+
|
|
83
|
+
wrapped_result = output if isinstance(output, TactusResult) else None
|
|
84
|
+
return (wrapped_result.output if wrapped_result is not None else output, wrapped_result)
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def _normalize_unstructured_output(output: Any) -> Any:
|
|
88
|
+
if isinstance(output, dict):
|
|
89
|
+
return output
|
|
90
|
+
if hasattr(output, "items"):
|
|
91
|
+
return dict(output.items())
|
|
92
|
+
return output
|
|
78
93
|
|
|
79
94
|
def validate(self, output: Any) -> Any:
|
|
80
95
|
"""
|
|
@@ -91,22 +106,12 @@ class OutputValidator:
|
|
|
91
106
|
"""
|
|
92
107
|
# If a procedure returns a Result wrapper, validate its `.output` payload
|
|
93
108
|
# while preserving the wrapper (so callers can still access usage/cost/etc.).
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
wrapped_result: TactusResult | None = output if isinstance(output, TactusResult) else None
|
|
97
|
-
if wrapped_result is not None:
|
|
98
|
-
output = wrapped_result.output
|
|
109
|
+
output, wrapped_result = self._unwrap_result(output)
|
|
99
110
|
|
|
100
111
|
# If no schema defined, accept any output
|
|
101
112
|
if not self.schema:
|
|
102
113
|
logger.debug("No output schema defined, skipping validation")
|
|
103
|
-
|
|
104
|
-
validated_payload = output
|
|
105
|
-
elif hasattr(output, "items"):
|
|
106
|
-
# Lua table - convert to dict
|
|
107
|
-
validated_payload = dict(output.items())
|
|
108
|
-
else:
|
|
109
|
-
validated_payload = output
|
|
114
|
+
validated_payload = self._normalize_unstructured_output(output)
|
|
110
115
|
|
|
111
116
|
if wrapped_result is not None:
|
|
112
117
|
return wrapped_result.model_copy(update={"output": validated_payload})
|
|
@@ -151,13 +156,13 @@ class OutputValidator:
|
|
|
151
156
|
f"Output must be an object/table, got {type(output).__name__}"
|
|
152
157
|
)
|
|
153
158
|
|
|
154
|
-
|
|
155
|
-
validated_output = {}
|
|
159
|
+
validation_errors: list[str] = []
|
|
160
|
+
validated_output: dict[str, Any] = {}
|
|
156
161
|
|
|
157
162
|
# Check required fields and validate types
|
|
158
163
|
for field_name, field_def in self.schema.items():
|
|
159
164
|
if not isinstance(field_def, dict) or "type" not in field_def:
|
|
160
|
-
|
|
165
|
+
validation_errors.append(
|
|
161
166
|
f"Field '{field_name}' uses old type syntax. "
|
|
162
167
|
f"Use field.{field_def.get('type', 'string')}{{}} instead."
|
|
163
168
|
)
|
|
@@ -165,7 +170,7 @@ class OutputValidator:
|
|
|
165
170
|
is_required = bool(field_def.get("required", False))
|
|
166
171
|
|
|
167
172
|
if is_required and field_name not in output:
|
|
168
|
-
|
|
173
|
+
validation_errors.append(f"Required field '{field_name}' is missing")
|
|
169
174
|
continue
|
|
170
175
|
|
|
171
176
|
# Skip validation if field not present and not required
|
|
@@ -179,7 +184,7 @@ class OutputValidator:
|
|
|
179
184
|
if expected_type:
|
|
180
185
|
if not self._check_type(value, expected_type):
|
|
181
186
|
actual_type = type(value).__name__
|
|
182
|
-
|
|
187
|
+
validation_errors.append(
|
|
183
188
|
f"Field '{field_name}' should be {expected_type}, got {actual_type}"
|
|
184
189
|
)
|
|
185
190
|
|
|
@@ -187,7 +192,7 @@ class OutputValidator:
|
|
|
187
192
|
if "enum" in field_def and field_def["enum"]:
|
|
188
193
|
allowed_values = field_def["enum"]
|
|
189
194
|
if value not in allowed_values:
|
|
190
|
-
|
|
195
|
+
validation_errors.append(
|
|
191
196
|
f"Field '{field_name}' has invalid value '{value}'. "
|
|
192
197
|
f"Allowed values: {allowed_values}"
|
|
193
198
|
)
|
|
@@ -198,13 +203,13 @@ class OutputValidator:
|
|
|
198
203
|
# Filter undeclared fields (only return declared fields)
|
|
199
204
|
for field_name in output:
|
|
200
205
|
if field_name not in self.schema:
|
|
201
|
-
logger.debug(
|
|
206
|
+
logger.debug("Filtering undeclared field '%s' from output", field_name)
|
|
202
207
|
|
|
203
|
-
if
|
|
204
|
-
|
|
205
|
-
raise OutputValidationError(
|
|
208
|
+
if validation_errors:
|
|
209
|
+
error_message = "Output validation failed:\n " + "\n ".join(validation_errors)
|
|
210
|
+
raise OutputValidationError(error_message)
|
|
206
211
|
|
|
207
|
-
logger.info(
|
|
212
|
+
logger.info("Output validation passed for %s fields", len(validated_output))
|
|
208
213
|
if wrapped_result is not None:
|
|
209
214
|
return wrapped_result.model_copy(update={"output": validated_output})
|
|
210
215
|
return validated_output
|
|
@@ -226,7 +231,7 @@ class OutputValidator:
|
|
|
226
231
|
|
|
227
232
|
python_type = self.TYPE_MAP.get(expected_type)
|
|
228
233
|
if not python_type:
|
|
229
|
-
logger.warning(
|
|
234
|
+
logger.warning("Unknown type '%s', skipping validation", expected_type)
|
|
230
235
|
return True
|
|
231
236
|
|
|
232
237
|
# Handle Lua tables as dicts/arrays
|
|
@@ -248,44 +253,44 @@ class OutputValidator:
|
|
|
248
253
|
"""
|
|
249
254
|
# Handle Lua tables (have .items() method)
|
|
250
255
|
if hasattr(obj, "items") and not isinstance(obj, dict):
|
|
251
|
-
return {
|
|
256
|
+
return {key: self._convert_lua_tables(value) for key, value in obj.items()}
|
|
252
257
|
|
|
253
258
|
# Handle lists
|
|
254
|
-
|
|
259
|
+
if isinstance(obj, (list, tuple)):
|
|
255
260
|
return [self._convert_lua_tables(item) for item in obj]
|
|
256
261
|
|
|
257
262
|
# Handle dicts
|
|
258
|
-
|
|
259
|
-
return {
|
|
263
|
+
if isinstance(obj, dict):
|
|
264
|
+
return {key: self._convert_lua_tables(value) for key, value in obj.items()}
|
|
260
265
|
|
|
261
266
|
# Return as-is for primitives
|
|
262
|
-
|
|
263
|
-
return obj
|
|
267
|
+
return obj
|
|
264
268
|
|
|
265
269
|
def get_field_description(self, field_name: str) -> Optional[str]:
|
|
266
270
|
"""Get description for an output field."""
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
return field_def.get("description")
|
|
271
|
+
field_definition = self.schema.get(field_name)
|
|
272
|
+
if isinstance(field_definition, dict):
|
|
273
|
+
return field_definition.get("description")
|
|
271
274
|
return None
|
|
272
275
|
|
|
273
|
-
def get_required_fields(self) ->
|
|
276
|
+
def get_required_fields(self) -> list[str]:
|
|
274
277
|
"""Get list of required output fields."""
|
|
275
278
|
from tactus.core.dsl_stubs import FieldDefinition
|
|
276
279
|
|
|
277
280
|
return [
|
|
278
|
-
|
|
279
|
-
for
|
|
280
|
-
if isinstance(
|
|
281
|
+
field_name
|
|
282
|
+
for field_name, field_definition in self.schema.items()
|
|
283
|
+
if isinstance(field_definition, FieldDefinition)
|
|
284
|
+
and field_definition.get("required", False)
|
|
281
285
|
]
|
|
282
286
|
|
|
283
|
-
def get_optional_fields(self) ->
|
|
287
|
+
def get_optional_fields(self) -> list[str]:
|
|
284
288
|
"""Get list of optional output fields."""
|
|
285
289
|
from tactus.core.dsl_stubs import FieldDefinition
|
|
286
290
|
|
|
287
291
|
return [
|
|
288
|
-
|
|
289
|
-
for
|
|
290
|
-
if isinstance(
|
|
292
|
+
field_name
|
|
293
|
+
for field_name, field_definition in self.schema.items()
|
|
294
|
+
if isinstance(field_definition, FieldDefinition)
|
|
295
|
+
and not field_definition.get("required", False)
|
|
291
296
|
]
|