tactus 0.30.0__py3-none-any.whl → 0.31.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/core/dsl_stubs.py CHANGED
@@ -469,20 +469,21 @@ def create_dsl_stubs(
469
469
 
470
470
  return accept_config
471
471
 
472
- def _stages(*stage_names) -> None:
473
- """Register stage names."""
474
- # Handle both stages("a", "b", "c") and stages({"a", "b", "c"})
475
- if len(stage_names) == 1 and hasattr(stage_names[0], "items"):
476
- # Single Lua table argument - convert it
477
- stages_list = lua_table_to_dict(stage_names[0])
478
- else:
479
- # Multiple string arguments
480
- stages_list = list(stage_names)
481
- builder.set_stages(stages_list)
482
-
483
- def _specification(spec_name: str, scenarios) -> None:
484
- """Register a BDD specification."""
485
- builder.register_specification(spec_name, lua_table_to_dict(scenarios))
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)")
486
487
 
487
488
  def _specifications(gherkin_text: str) -> None:
488
489
  """Register Gherkin BDD specifications."""
@@ -493,8 +494,17 @@ def create_dsl_stubs(
493
494
  builder.register_custom_step(step_text, lua_function)
494
495
 
495
496
  def _evaluation(config) -> None:
496
- """Set evaluation configuration."""
497
- builder.set_evaluation_config(lua_table_to_dict(config or {}))
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)
498
508
 
499
509
  def _evaluations(config) -> None:
500
510
  """Register Pydantic Evals evaluation configuration."""
@@ -690,6 +700,28 @@ def create_dsl_stubs(
690
700
  "integer": _field_builder("integer"),
691
701
  }
692
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
+
693
725
  # Create lookup functions for uppercase names (Agent, Model)
694
726
  # These allow: Agent("greeter")(), Model("classifier")() (callable syntax)
695
727
  _Agent = AgentLookup(_agent_registry)
@@ -1256,6 +1288,12 @@ def create_dsl_stubs(
1256
1288
  if len(config_dict) == 0:
1257
1289
  config_dict = {}
1258
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
+
1259
1297
  # Check for legacy handler field
1260
1298
  if handler_fn is None and isinstance(config_dict, dict):
1261
1299
  handler_fn = config_dict.pop("handler", None)
@@ -1429,6 +1467,12 @@ def create_dsl_stubs(
1429
1467
  if len(config_dict) == 0:
1430
1468
  config_dict = {}
1431
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
+
1432
1476
  # Check for legacy handler field
1433
1477
  if handler_fn is None and isinstance(config_dict, dict):
1434
1478
  handler_fn = config_dict.pop("handler", None)
@@ -1573,19 +1617,44 @@ def create_dsl_stubs(
1573
1617
  """
1574
1618
  config_dict = lua_table_to_dict(config)
1575
1619
 
1576
- # Handle tools field - convert ToolHandles to their names
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)
1577
1641
  if "tools" in config_dict:
1578
1642
  tools = config_dict["tools"]
1579
1643
  if isinstance(tools, (list, tuple)):
1580
- tool_names = []
1644
+ normalized = []
1581
1645
  for t in tools:
1582
- if hasattr(t, "name"): # ToolHandle
1583
- tool_names.append(t.name)
1584
- elif isinstance(t, str):
1585
- tool_names.append(t)
1586
- # Store as toolsets for runtime compatibility
1587
- config_dict["toolsets"] = tool_names
1588
- del config_dict["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
1589
1658
 
1590
1659
  # Extract input schema if present
1591
1660
  input_schema = None
@@ -1605,9 +1674,11 @@ def create_dsl_stubs(
1605
1674
  config_dict["output_schema"] = output_schema
1606
1675
  del config_dict["output"]
1607
1676
 
1608
- # Support 'session' as an alias for 'message_history'
1609
- if "session" in config_dict and "message_history" not in config_dict:
1610
- config_dict["message_history"] = config_dict["session"]
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
+ )
1611
1682
 
1612
1683
  # Register agent with provided name
1613
1684
  builder.register_agent(agent_name, config_dict, output_schema)
@@ -1635,6 +1706,12 @@ def create_dsl_stubs(
1635
1706
  # expects name as a separate parameter. We need to pass config without 'name'.
1636
1707
  agent_config = {k: v for k, v in config_dict.items() if k != "name"}
1637
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
+
1638
1715
  # Pre-process model format: combine provider and model into "provider:model"
1639
1716
  # This matches what _setup_agents does
1640
1717
  if "provider" in agent_config and "model" in agent_config:
@@ -1729,19 +1806,40 @@ def create_dsl_stubs(
1729
1806
 
1730
1807
  config_dict = lua_table_to_dict(config)
1731
1808
 
1732
- # Handle tools field - convert ToolHandles to their names
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)
1733
1826
  if "tools" in config_dict:
1734
1827
  tools = config_dict["tools"]
1735
1828
  if isinstance(tools, (list, tuple)):
1736
- tool_names = []
1829
+ normalized = []
1737
1830
  for t in tools:
1738
- if hasattr(t, "name"): # ToolHandle
1739
- tool_names.append(t.name)
1740
- elif isinstance(t, str):
1741
- tool_names.append(t)
1742
- # Store as toolsets for runtime compatibility
1743
- config_dict["toolsets"] = tool_names
1744
- del config_dict["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
1745
1843
 
1746
1844
  # Extract input schema if present
1747
1845
  input_schema = None
@@ -1761,9 +1859,9 @@ def create_dsl_stubs(
1761
1859
  config_dict["output_schema"] = output_schema
1762
1860
  del config_dict["output"]
1763
1861
 
1764
- # Support 'session' as an alias for 'message_history'
1765
- if "session" in config_dict and "message_history" not in config_dict:
1766
- config_dict["message_history"] = config_dict["session"]
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'.")
1767
1865
 
1768
1866
  # Generate a temporary name - will be replaced when assigned
1769
1867
  import uuid
@@ -1863,7 +1961,6 @@ def create_dsl_stubs(
1863
1961
  "Toolset": _toolset,
1864
1962
  "Tool": _new_tool, # NEW syntax - assignment based
1865
1963
  "Hitl": _hitl,
1866
- "Stages": _stages,
1867
1964
  "Specification": _specification,
1868
1965
  # BDD Testing
1869
1966
  "Specifications": _specifications,
@@ -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 Dict, Any, Optional, List
9
+ from typing import Any, Optional, List
10
10
 
11
11
  logger = logging.getLogger(__name__)
12
12
 
@@ -37,7 +37,16 @@ class OutputValidator:
37
37
  "array": list,
38
38
  }
39
39
 
40
- def __init__(self, output_schema: Optional[Dict[str, Any]] = None):
40
+ @classmethod
41
+ def _is_scalar_schema(cls, schema: Any) -> bool:
42
+ return (
43
+ isinstance(schema, dict)
44
+ and "type" in schema
45
+ and isinstance(schema.get("type"), str)
46
+ and schema.get("type") in cls.TYPE_MAP
47
+ )
48
+
49
+ def __init__(self, output_schema: Optional[Any] = None):
41
50
  """
42
51
  Initialize validator with output schema.
43
52
 
@@ -57,9 +66,17 @@ class OutputValidator:
57
66
  }
58
67
  """
59
68
  self.schema = output_schema or {}
60
- logger.debug(f"OutputValidator initialized with {len(self.schema)} output fields")
61
69
 
62
- def validate(self, output: Any) -> Dict[str, Any]:
70
+ if self._is_scalar_schema(self.schema):
71
+ logger.debug("OutputValidator initialized with scalar output schema")
72
+ else:
73
+ try:
74
+ field_count = len(self.schema)
75
+ except TypeError:
76
+ field_count = 0
77
+ logger.debug(f"OutputValidator initialized with {field_count} output fields")
78
+
79
+ def validate(self, output: Any) -> Any:
63
80
  """
64
81
  Validate workflow output against schema.
65
82
 
@@ -72,16 +89,56 @@ class OutputValidator:
72
89
  Raises:
73
90
  OutputValidationError: If validation fails
74
91
  """
92
+ # If a procedure returns a Result wrapper, validate its `.output` payload
93
+ # 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
99
+
75
100
  # If no schema defined, accept any output
76
101
  if not self.schema:
77
102
  logger.debug("No output schema defined, skipping validation")
78
103
  if isinstance(output, dict):
79
- return output
104
+ validated_payload = output
80
105
  elif hasattr(output, "items"):
81
106
  # Lua table - convert to dict
82
- return dict(output.items())
107
+ validated_payload = dict(output.items())
83
108
  else:
84
- return {"result": output}
109
+ validated_payload = output
110
+
111
+ if wrapped_result is not None:
112
+ return wrapped_result.model_copy(update={"output": validated_payload})
113
+ return validated_payload
114
+
115
+ # Scalar output schema: `output = field.string{...}` etc.
116
+ if self._is_scalar_schema(self.schema):
117
+ # Lua tables are not valid scalar outputs.
118
+ if hasattr(output, "items") and not isinstance(output, dict):
119
+ output = dict(output.items())
120
+
121
+ is_required = self.schema.get("required", False)
122
+ if output is None and not is_required:
123
+ return None
124
+
125
+ expected_type = self.schema.get("type")
126
+ if expected_type and not self._check_type(output, expected_type):
127
+ raise OutputValidationError(
128
+ f"Output should be {expected_type}, got {type(output).__name__}"
129
+ )
130
+
131
+ if "enum" in self.schema and self.schema["enum"]:
132
+ allowed_values = self.schema["enum"]
133
+ if output not in allowed_values:
134
+ raise OutputValidationError(
135
+ f"Output has invalid value '{output}'. Allowed values: {allowed_values}"
136
+ )
137
+
138
+ validated_payload = output
139
+ if wrapped_result is not None:
140
+ return wrapped_result.model_copy(update={"output": validated_payload})
141
+ return validated_payload
85
142
 
86
143
  # Convert Lua tables to dicts recursively
87
144
  if hasattr(output, "items") or isinstance(output, dict):
@@ -99,16 +156,13 @@ class OutputValidator:
99
156
 
100
157
  # Check required fields and validate types
101
158
  for field_name, field_def in self.schema.items():
102
- # Check if it's the new syntax
103
- from tactus.core.dsl_stubs import FieldDefinition
104
-
105
- if not isinstance(field_def, FieldDefinition):
159
+ if not isinstance(field_def, dict) or "type" not in field_def:
106
160
  errors.append(
107
161
  f"Field '{field_name}' uses old type syntax. "
108
162
  f"Use field.{field_def.get('type', 'string')}{{}} instead."
109
163
  )
110
164
  continue
111
- is_required = field_def.get("required", False)
165
+ is_required = bool(field_def.get("required", False))
112
166
 
113
167
  if is_required and field_name not in output:
114
168
  errors.append(f"Required field '{field_name}' is missing")
@@ -151,6 +205,8 @@ class OutputValidator:
151
205
  raise OutputValidationError(error_msg)
152
206
 
153
207
  logger.info(f"Output validation passed for {len(validated_output)} fields")
208
+ if wrapped_result is not None:
209
+ return wrapped_result.model_copy(update={"output": validated_output})
154
210
  return validated_output
155
211
 
156
212
  def _check_type(self, value: Any, expected_type: str) -> bool:
@@ -209,10 +265,8 @@ class OutputValidator:
209
265
  def get_field_description(self, field_name: str) -> Optional[str]:
210
266
  """Get description for an output field."""
211
267
  if field_name in self.schema:
212
- from tactus.core.dsl_stubs import FieldDefinition
213
-
214
268
  field_def = self.schema[field_name]
215
- if isinstance(field_def, FieldDefinition):
269
+ if isinstance(field_def, dict):
216
270
  return field_def.get("description")
217
271
  return None
218
272
 
tactus/core/registry.py CHANGED
@@ -48,13 +48,8 @@ class AgentDeclaration(BaseModel):
48
48
  model: Union[str, dict[str, Any]] = "gpt-4o"
49
49
  system_prompt: Union[str, Any] # String with {markers} or Lua function
50
50
  initial_message: Optional[str] = None
51
- tools: list[Union[str, dict[str, Any]]] = Field(
52
- default_factory=list
53
- ) # Supports toolset expressions
54
- inline_tool_defs: list[dict[str, Any]] = Field(
55
- default_factory=list
56
- ) # Inline tool definitions with Lua handlers
57
- output: Optional[AgentOutputSchema] = None # Legacy field
51
+ tools: list[Any] = Field(default_factory=list) # Tool/toolset references and expressions
52
+ inline_tools: list[dict[str, Any]] = Field(default_factory=list) # Inline tool definitions
58
53
  output: Optional[AgentOutputSchema] = None # Aligned with pydantic-ai
59
54
  message_history: Optional[MessageHistoryConfiguration] = None
60
55
  max_turns: int = 50
@@ -127,6 +122,10 @@ class AgentMockConfig(BaseModel):
127
122
  default_factory=dict,
128
123
  description="Optional token usage payload (exposed as result.usage in Lua)",
129
124
  )
125
+ temporal: list[dict[str, Any]] = Field(
126
+ default_factory=list,
127
+ description="Optional temporal mock turns (1-indexed by agent turn).",
128
+ )
130
129
 
131
130
 
132
131
  class ProcedureRegistry(BaseModel):
@@ -146,7 +145,6 @@ class ProcedureRegistry(BaseModel):
146
145
  toolsets: dict[str, dict[str, Any]] = Field(default_factory=dict)
147
146
  lua_tools: dict[str, dict[str, Any]] = Field(default_factory=dict) # Lua function tools
148
147
  hitl_points: dict[str, HITLDeclaration] = Field(default_factory=dict)
149
- stages: list[str] = Field(default_factory=list)
150
148
  specifications: list[SpecificationDeclaration] = Field(default_factory=list)
151
149
  dependencies: dict[str, DependencyDeclaration] = Field(default_factory=dict)
152
150
  mocks: dict[str, dict[str, Any]] = Field(default_factory=dict) # Mock configurations
@@ -241,19 +239,6 @@ class RegistryBuilder:
241
239
  fields[field_name] = OutputFieldDeclaration(**field_config_with_name)
242
240
  config["output"] = AgentOutputSchema(fields=fields)
243
241
 
244
- # Handle toolsets -> tools rename (backward compatibility)
245
- # The Lua DSL uses "toolsets" for toolset references and "tools" for inline tool definitions
246
- # AgentDeclaration expects "tools" for toolset references
247
- if "toolsets" in config:
248
- if "tools" in config:
249
- # Both exist: tools = inline defs, toolsets = references
250
- # Keep inline defs in a temp field, use toolsets as tools
251
- config["inline_tool_defs"] = config.pop("tools")
252
- config["tools"] = config.pop("toolsets")
253
- else:
254
- # Only toolsets exists: rename to tools
255
- config["tools"] = config.pop("toolsets")
256
-
257
242
  # Apply defaults
258
243
  if "provider" not in config and self.registry.default_provider:
259
244
  config["provider"] = self.registry.default_provider
@@ -340,10 +325,6 @@ class RegistryBuilder:
340
325
  except Exception as e:
341
326
  self._add_error(f"Invalid agent mock config for '{agent_name}': {e}")
342
327
 
343
- def set_stages(self, stage_names: list[str]) -> None:
344
- """Set stage names."""
345
- self.registry.stages = stage_names
346
-
347
328
  def register_specification(self, name: str, scenarios: list) -> None:
348
329
  """Register a BDD specification."""
349
330
  try:
@@ -379,6 +360,13 @@ class RegistryBuilder:
379
360
  "state_schema": state_schema,
380
361
  }
381
362
 
363
+ # If this is the main entry point, also populate the top-level schemas so
364
+ # runtime output validation and tooling use a single canonical `output`.
365
+ if name == "main":
366
+ self.registry.input_schema = input_schema
367
+ self.registry.output_schema = output_schema
368
+ self.registry.state_schema = state_schema
369
+
382
370
  def register_top_level_input(self, schema: dict) -> None:
383
371
  """Register top-level input schema for script mode."""
384
372
  self.registry.top_level_input_schema = schema