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.
Files changed (81) hide show
  1. tactus/__init__.py +1 -1
  2. tactus/adapters/broker_log.py +17 -14
  3. tactus/adapters/channels/__init__.py +17 -15
  4. tactus/adapters/channels/base.py +16 -7
  5. tactus/adapters/channels/broker.py +43 -13
  6. tactus/adapters/channels/cli.py +19 -15
  7. tactus/adapters/channels/host.py +40 -25
  8. tactus/adapters/channels/ipc.py +82 -31
  9. tactus/adapters/channels/sse.py +41 -23
  10. tactus/adapters/cli_hitl.py +19 -19
  11. tactus/adapters/cli_log.py +4 -4
  12. tactus/adapters/control_loop.py +138 -99
  13. tactus/adapters/cost_collector_log.py +9 -9
  14. tactus/adapters/file_storage.py +56 -52
  15. tactus/adapters/http_callback_log.py +23 -13
  16. tactus/adapters/ide_log.py +17 -9
  17. tactus/adapters/lua_tools.py +4 -5
  18. tactus/adapters/mcp.py +16 -19
  19. tactus/adapters/mcp_manager.py +46 -30
  20. tactus/adapters/memory.py +9 -9
  21. tactus/adapters/plugins.py +42 -42
  22. tactus/broker/client.py +75 -78
  23. tactus/broker/protocol.py +57 -57
  24. tactus/broker/server.py +252 -197
  25. tactus/cli/app.py +3 -1
  26. tactus/cli/control.py +2 -2
  27. tactus/core/config_manager.py +181 -135
  28. tactus/core/dependencies/registry.py +66 -48
  29. tactus/core/dsl_stubs.py +222 -163
  30. tactus/core/exceptions.py +10 -1
  31. tactus/core/execution_context.py +152 -112
  32. tactus/core/lua_sandbox.py +72 -64
  33. tactus/core/message_history_manager.py +138 -43
  34. tactus/core/mocking.py +41 -27
  35. tactus/core/output_validator.py +49 -44
  36. tactus/core/registry.py +94 -80
  37. tactus/core/runtime.py +211 -176
  38. tactus/core/template_resolver.py +16 -16
  39. tactus/core/yaml_parser.py +55 -45
  40. tactus/docs/extractor.py +7 -6
  41. tactus/ide/server.py +119 -78
  42. tactus/primitives/control.py +10 -6
  43. tactus/primitives/file.py +48 -46
  44. tactus/primitives/handles.py +47 -35
  45. tactus/primitives/host.py +29 -27
  46. tactus/primitives/human.py +154 -137
  47. tactus/primitives/json.py +22 -23
  48. tactus/primitives/log.py +26 -26
  49. tactus/primitives/message_history.py +285 -31
  50. tactus/primitives/model.py +15 -9
  51. tactus/primitives/procedure.py +86 -64
  52. tactus/primitives/procedure_callable.py +58 -51
  53. tactus/primitives/retry.py +31 -29
  54. tactus/primitives/session.py +42 -29
  55. tactus/primitives/state.py +54 -43
  56. tactus/primitives/step.py +9 -13
  57. tactus/primitives/system.py +34 -21
  58. tactus/primitives/tool.py +44 -31
  59. tactus/primitives/tool_handle.py +76 -54
  60. tactus/primitives/toolset.py +25 -22
  61. tactus/sandbox/config.py +4 -4
  62. tactus/sandbox/container_runner.py +161 -107
  63. tactus/sandbox/docker_manager.py +20 -20
  64. tactus/sandbox/entrypoint.py +16 -14
  65. tactus/sandbox/protocol.py +15 -15
  66. tactus/stdlib/classify/llm.py +1 -3
  67. tactus/stdlib/core/validation.py +0 -3
  68. tactus/testing/pydantic_eval_runner.py +1 -1
  69. tactus/utils/asyncio_helpers.py +27 -0
  70. tactus/utils/cost_calculator.py +7 -7
  71. tactus/utils/model_pricing.py +11 -12
  72. tactus/utils/safe_file_library.py +156 -132
  73. tactus/utils/safe_libraries.py +27 -27
  74. tactus/validation/error_listener.py +18 -5
  75. tactus/validation/semantic_visitor.py +392 -333
  76. tactus/validation/validator.py +89 -49
  77. {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/METADATA +15 -3
  78. {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/RECORD +81 -80
  79. {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/WHEEL +0 -0
  80. {tactus-0.34.1.dist-info → tactus-0.35.1.dist-info}/entry_points.txt +0 -0
  81. {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: Dict[str, Any]
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: List[Any] = field(default_factory=list)
39
+ temporal_results: list[Any] = field(default_factory=list)
40
40
 
41
41
  # Conditional mocks - return based on args
42
- conditional_mocks: List[Dict[str, Any]] = field(default_factory=list)
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: Dict[str, MockConfig] = {}
62
- self.call_history: Dict[str, List[MockCall]] = {}
63
- self.call_counts: Dict[str, int] = {}
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, Dict[str, Any]]) -> None:
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(f"Registered mock for tool '{tool_name}'")
92
+ logger.info("Registered mock for tool '%s'", tool_name)
93
93
 
94
- def get_mock_response(self, tool_name: str, args: Dict[str, Any]) -> Optional[Any]:
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
- f"Mock '{tool_name}' returning temporal result for call {call_number}: {result}"
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
- f"Mock '{tool_name}' returning last temporal result (call {call_number}): {result}"
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(f"Mock '{tool_name}' matched condition, returning: {result}")
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(f"Mock '{tool_name}' returning static result: {mock_config.static_result}")
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: Dict[str, Any], result: Any) -> None:
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(f"Recorded call to '{tool_name}' (call #{call.call_number})")
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) -> List[MockCall]:
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: Dict[str, Any], condition: Dict[str, Any]) -> bool:
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
- arg_value = args[key]
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(arg_value):
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(arg_value).startswith(prefix):
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(arg_value).endswith(suffix):
259
+ if not str(argument_value).endswith(suffix):
246
260
  return False
247
261
  else:
248
262
  # Exact match
249
- if arg_value != pattern:
263
+ if argument_value != pattern:
250
264
  return False
251
265
  else:
252
266
  # Direct comparison
253
- if arg_value != pattern:
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(f"Enabled mock for tool '{tool_name}'")
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(f"Disabled mock for tool '{tool_name}'")
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")
@@ -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, List
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(f"OutputValidator initialized with {field_count} output fields")
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
- from tactus.protocols.result import TactusResult
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
- if isinstance(output, dict):
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
- errors = []
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
- errors.append(
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
- errors.append(f"Required field '{field_name}' is missing")
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
- errors.append(
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
- errors.append(
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(f"Filtering undeclared field '{field_name}' from output")
206
+ logger.debug("Filtering undeclared field '%s' from output", field_name)
202
207
 
203
- if errors:
204
- error_msg = "Output validation failed:\n " + "\n ".join(errors)
205
- raise OutputValidationError(error_msg)
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(f"Output validation passed for {len(validated_output)} fields")
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(f"Unknown type '{expected_type}', skipping validation")
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 {k: self._convert_lua_tables(v) for k, v in obj.items()}
256
+ return {key: self._convert_lua_tables(value) for key, value in obj.items()}
252
257
 
253
258
  # Handle lists
254
- elif isinstance(obj, (list, tuple)):
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
- elif isinstance(obj, dict):
259
- return {k: self._convert_lua_tables(v) for k, v in obj.items()}
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
- else:
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
- if field_name in self.schema:
268
- field_def = self.schema[field_name]
269
- if isinstance(field_def, dict):
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) -> List[str]:
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
- name
279
- for name, def_ in self.schema.items()
280
- if isinstance(def_, FieldDefinition) and def_.get("required", False)
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) -> List[str]:
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
- name
289
- for name, def_ in self.schema.items()
290
- if isinstance(def_, FieldDefinition) and not def_.get("required", False)
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
  ]