tactus 0.31.0__py3-none-any.whl → 0.34.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 (101) hide show
  1. tactus/__init__.py +1 -1
  2. tactus/adapters/__init__.py +18 -1
  3. tactus/adapters/broker_log.py +127 -34
  4. tactus/adapters/channels/__init__.py +153 -0
  5. tactus/adapters/channels/base.py +174 -0
  6. tactus/adapters/channels/broker.py +179 -0
  7. tactus/adapters/channels/cli.py +448 -0
  8. tactus/adapters/channels/host.py +225 -0
  9. tactus/adapters/channels/ipc.py +297 -0
  10. tactus/adapters/channels/sse.py +305 -0
  11. tactus/adapters/cli_hitl.py +223 -1
  12. tactus/adapters/control_loop.py +879 -0
  13. tactus/adapters/file_storage.py +35 -2
  14. tactus/adapters/ide_log.py +7 -1
  15. tactus/backends/http_backend.py +0 -1
  16. tactus/broker/client.py +31 -1
  17. tactus/broker/server.py +416 -92
  18. tactus/cli/app.py +270 -7
  19. tactus/cli/control.py +393 -0
  20. tactus/core/config_manager.py +33 -6
  21. tactus/core/dsl_stubs.py +102 -18
  22. tactus/core/execution_context.py +265 -8
  23. tactus/core/lua_sandbox.py +8 -9
  24. tactus/core/registry.py +19 -2
  25. tactus/core/runtime.py +235 -27
  26. tactus/docker/Dockerfile.pypi +49 -0
  27. tactus/docs/__init__.py +33 -0
  28. tactus/docs/extractor.py +326 -0
  29. tactus/docs/html_renderer.py +72 -0
  30. tactus/docs/models.py +121 -0
  31. tactus/docs/templates/base.html +204 -0
  32. tactus/docs/templates/index.html +58 -0
  33. tactus/docs/templates/module.html +96 -0
  34. tactus/dspy/agent.py +403 -22
  35. tactus/dspy/broker_lm.py +57 -6
  36. tactus/dspy/config.py +14 -3
  37. tactus/dspy/history.py +2 -1
  38. tactus/dspy/module.py +136 -11
  39. tactus/dspy/signature.py +0 -1
  40. tactus/ide/config_server.py +536 -0
  41. tactus/ide/server.py +345 -21
  42. tactus/primitives/human.py +619 -47
  43. tactus/primitives/system.py +0 -1
  44. tactus/protocols/__init__.py +25 -0
  45. tactus/protocols/control.py +427 -0
  46. tactus/protocols/notification.py +207 -0
  47. tactus/sandbox/container_runner.py +79 -11
  48. tactus/sandbox/docker_manager.py +23 -0
  49. tactus/sandbox/entrypoint.py +26 -0
  50. tactus/sandbox/protocol.py +3 -0
  51. tactus/stdlib/README.md +77 -0
  52. tactus/stdlib/__init__.py +27 -1
  53. tactus/stdlib/classify/__init__.py +165 -0
  54. tactus/stdlib/classify/classify.spec.tac +195 -0
  55. tactus/stdlib/classify/classify.tac +257 -0
  56. tactus/stdlib/classify/fuzzy.py +282 -0
  57. tactus/stdlib/classify/llm.py +319 -0
  58. tactus/stdlib/classify/primitive.py +287 -0
  59. tactus/stdlib/core/__init__.py +57 -0
  60. tactus/stdlib/core/base.py +320 -0
  61. tactus/stdlib/core/confidence.py +211 -0
  62. tactus/stdlib/core/models.py +161 -0
  63. tactus/stdlib/core/retry.py +171 -0
  64. tactus/stdlib/core/validation.py +274 -0
  65. tactus/stdlib/extract/__init__.py +125 -0
  66. tactus/stdlib/extract/llm.py +330 -0
  67. tactus/stdlib/extract/primitive.py +256 -0
  68. tactus/stdlib/tac/tactus/classify/base.tac +51 -0
  69. tactus/stdlib/tac/tactus/classify/fuzzy.tac +87 -0
  70. tactus/stdlib/tac/tactus/classify/index.md +77 -0
  71. tactus/stdlib/tac/tactus/classify/init.tac +29 -0
  72. tactus/stdlib/tac/tactus/classify/llm.tac +150 -0
  73. tactus/stdlib/tac/tactus/classify.spec.tac +191 -0
  74. tactus/stdlib/tac/tactus/extract/base.tac +138 -0
  75. tactus/stdlib/tac/tactus/extract/index.md +96 -0
  76. tactus/stdlib/tac/tactus/extract/init.tac +27 -0
  77. tactus/stdlib/tac/tactus/extract/llm.tac +201 -0
  78. tactus/stdlib/tac/tactus/extract.spec.tac +153 -0
  79. tactus/stdlib/tac/tactus/generate/base.tac +142 -0
  80. tactus/stdlib/tac/tactus/generate/index.md +195 -0
  81. tactus/stdlib/tac/tactus/generate/init.tac +28 -0
  82. tactus/stdlib/tac/tactus/generate/llm.tac +169 -0
  83. tactus/stdlib/tac/tactus/generate.spec.tac +210 -0
  84. tactus/testing/behave_integration.py +171 -7
  85. tactus/testing/context.py +0 -1
  86. tactus/testing/evaluation_runner.py +0 -1
  87. tactus/testing/gherkin_parser.py +0 -1
  88. tactus/testing/mock_hitl.py +0 -1
  89. tactus/testing/mock_tools.py +0 -1
  90. tactus/testing/models.py +0 -1
  91. tactus/testing/steps/builtin.py +0 -1
  92. tactus/testing/steps/custom.py +81 -22
  93. tactus/testing/steps/registry.py +0 -1
  94. tactus/testing/test_runner.py +7 -1
  95. tactus/validation/semantic_visitor.py +11 -5
  96. tactus/validation/validator.py +0 -1
  97. {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/METADATA +16 -2
  98. {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/RECORD +101 -49
  99. {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/WHEEL +0 -0
  100. {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/entry_points.txt +0 -0
  101. {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/licenses/LICENSE +0 -0
tactus/cli/control.py ADDED
@@ -0,0 +1,393 @@
1
+ """
2
+ Tactus Control CLI - Connect to running procedure and respond to control requests.
3
+
4
+ This is a standalone CLI application that connects to the runtime's IPC socket
5
+ and allows humans (or other controllers) to respond to control requests from
6
+ running procedures.
7
+
8
+ Usage:
9
+ tactus control [--socket PATH]
10
+ """
11
+
12
+ import asyncio
13
+ import logging
14
+ import sys
15
+ from datetime import datetime, timezone
16
+ from typing import Dict, List, Optional
17
+
18
+ from rich.console import Console
19
+ from rich.panel import Panel
20
+ from rich.prompt import Confirm, Prompt
21
+ from rich.table import Table
22
+
23
+ from tactus.broker.protocol import read_message, write_message
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class ControlCLI:
29
+ """CLI app for responding to control requests via IPC."""
30
+
31
+ def __init__(
32
+ self, socket_path: str = "/tmp/tactus-control.sock", auto_respond: Optional[str] = None
33
+ ):
34
+ """
35
+ Initialize control CLI.
36
+
37
+ Args:
38
+ socket_path: Path to runtime's Unix socket
39
+ auto_respond: If set, automatically respond with this value (for testing)
40
+ """
41
+ self.socket_path = socket_path
42
+ self.auto_respond = auto_respond
43
+ self.console = Console()
44
+ self._reader: Optional[asyncio.StreamReader] = None
45
+ self._writer: Optional[asyncio.StreamWriter] = None
46
+ self._running = False
47
+
48
+ async def connect(self) -> bool:
49
+ """
50
+ Connect to runtime's IPC socket.
51
+
52
+ Returns:
53
+ True if connected successfully
54
+ """
55
+ try:
56
+ self._reader, self._writer = await asyncio.open_unix_connection(self.socket_path)
57
+ return True
58
+ except FileNotFoundError:
59
+ self.console.print(f"[red]✗ Socket not found: {self.socket_path}[/red]")
60
+ self.console.print("\n[yellow]Is the Tactus runtime running?[/yellow]")
61
+ return False
62
+ except ConnectionRefusedError:
63
+ self.console.print(f"[red]✗ Connection refused: {self.socket_path}[/red]")
64
+ return False
65
+ except Exception as e:
66
+ self.console.print(f"[red]✗ Failed to connect: {e}[/red]")
67
+ return False
68
+
69
+ async def disconnect(self) -> None:
70
+ """Close connection to runtime."""
71
+ if self._writer:
72
+ try:
73
+ self._writer.close()
74
+ await self._writer.wait_closed()
75
+ except Exception:
76
+ pass
77
+
78
+ async def watch_mode(self) -> None:
79
+ """
80
+ Interactive mode - watch for requests and respond.
81
+
82
+ This is the main entry point for the control CLI. It connects to the
83
+ runtime, displays pending requests, and prompts for responses.
84
+ """
85
+ # Connect to runtime
86
+ if not await self.connect():
87
+ return
88
+
89
+ self.console.print()
90
+ self.console.print(
91
+ Panel(
92
+ f"[green]Connected to: {self.socket_path}[/green]\n"
93
+ "[dim]Waiting for control requests...[/dim]",
94
+ title="Control Session",
95
+ border_style="green",
96
+ )
97
+ )
98
+ self.console.print()
99
+
100
+ self._running = True
101
+
102
+ try:
103
+ # Read messages from runtime
104
+ while self._running:
105
+ try:
106
+ message = await read_message(self._reader)
107
+ except EOFError:
108
+ self.console.print("\n[yellow]✗ Connection closed by runtime[/yellow]")
109
+ break
110
+ except asyncio.IncompleteReadError:
111
+ self.console.print("\n[yellow]✗ Connection closed by runtime[/yellow]")
112
+ break
113
+
114
+ await self._handle_message(message)
115
+
116
+ except KeyboardInterrupt:
117
+ self.console.print("\n[dim]Disconnecting...[/dim]")
118
+
119
+ finally:
120
+ await self.disconnect()
121
+
122
+ async def _handle_message(self, message: Dict) -> None:
123
+ """
124
+ Handle a message from the runtime.
125
+
126
+ Args:
127
+ message: Parsed JSON message
128
+ """
129
+ msg_type = message.get("type")
130
+
131
+ if msg_type == "control.request":
132
+ await self._handle_request(message)
133
+
134
+ elif msg_type == "control.cancelled":
135
+ self._handle_cancellation(message)
136
+
137
+ elif msg_type == "control.list_response":
138
+ self._handle_list_response(message)
139
+
140
+ else:
141
+ logger.warning(f"Unknown message type: {msg_type}")
142
+
143
+ async def _handle_request(self, request: Dict) -> None:
144
+ """
145
+ Handle a control request from the runtime.
146
+
147
+ Args:
148
+ request: Control request message
149
+ """
150
+ request_id = request["request_id"]
151
+ procedure_name = request.get("procedure_name", "Unknown Procedure")
152
+ request_type = request["request_type"]
153
+ message = request["message"]
154
+ options = request.get("options", [])
155
+ default_value = request.get("default_value")
156
+ started_at = request.get("started_at")
157
+
158
+ # Calculate elapsed time
159
+ elapsed_str = "Unknown"
160
+ if started_at:
161
+ started = datetime.fromisoformat(started_at)
162
+ elapsed = (datetime.now(timezone.utc) - started).total_seconds()
163
+ if elapsed < 60:
164
+ elapsed_str = f"{int(elapsed)} seconds ago"
165
+ elif elapsed < 3600:
166
+ elapsed_str = f"{int(elapsed / 60)} minutes ago"
167
+ else:
168
+ elapsed_str = f"{int(elapsed / 3600)} hours ago"
169
+
170
+ # Display request panel
171
+ self.console.print()
172
+ self.console.print(
173
+ Panel(
174
+ f"[bold]{procedure_name}[/bold]\n" f"Started: {elapsed_str}",
175
+ title="Control Request",
176
+ border_style="blue",
177
+ )
178
+ )
179
+ self.console.print()
180
+
181
+ self.console.print(
182
+ Panel(
183
+ f"[bold]{message}[/bold]\n\n"
184
+ f"Type: {request_type}\n"
185
+ f"ID: [dim]{request_id}[/dim]",
186
+ border_style="cyan",
187
+ )
188
+ )
189
+ self.console.print()
190
+
191
+ # Handle based on request type
192
+ if request_type == "approval":
193
+ await self._handle_approval_request(request, options, default_value)
194
+
195
+ elif request_type == "choice":
196
+ await self._handle_choice_request(request, options, default_value)
197
+
198
+ elif request_type == "input":
199
+ await self._handle_input_request(request, default_value)
200
+
201
+ else:
202
+ self.console.print(f"[yellow]⚠ Unknown request type: {request_type}[/yellow]")
203
+
204
+ async def _handle_approval_request(
205
+ self, request: Dict, options: List[Dict], default_value: Optional[bool]
206
+ ) -> None:
207
+ """Handle an approval request."""
208
+ request_id = request["request_id"]
209
+
210
+ # Auto-respond mode (for testing)
211
+ if self.auto_respond is not None:
212
+ value = self.auto_respond.lower() in ("y", "yes", "true", "1")
213
+ await self._send_response(request_id, value)
214
+ return
215
+
216
+ # Interactive prompt
217
+ if default_value is not None:
218
+ prompt_str = f"Approve? [y/n] ({default_value and 'y' or 'n'}): "
219
+ else:
220
+ prompt_str = "Approve? [y/n]: "
221
+
222
+ response = Confirm.ask(
223
+ prompt_str, default=default_value if default_value is not None else None
224
+ )
225
+ await self._send_response(request_id, response)
226
+
227
+ async def _handle_choice_request(
228
+ self, request: Dict, options: List[Dict], default_value: Optional[any]
229
+ ) -> None:
230
+ """Handle a choice request."""
231
+ request_id = request["request_id"]
232
+
233
+ # Auto-respond mode (for testing)
234
+ if self.auto_respond is not None:
235
+ # Try to match auto_respond to an option value
236
+ for option in options:
237
+ if str(option.get("value")) == self.auto_respond:
238
+ await self._send_response(request_id, option["value"])
239
+ return
240
+ # Default to first option
241
+ await self._send_response(request_id, options[0]["value"] if options else None)
242
+ return
243
+
244
+ # Display options
245
+ self.console.print("Options:")
246
+ for i, option in enumerate(options, 1):
247
+ label = option.get("label", str(option.get("value")))
248
+ value = option.get("value")
249
+ if value == default_value:
250
+ self.console.print(f" [{i}] {label} [dim](default)[/dim]")
251
+ else:
252
+ self.console.print(f" [{i}] {label}")
253
+
254
+ self.console.print()
255
+
256
+ # Prompt for selection
257
+ while True:
258
+ selection = Prompt.ask(
259
+ "Choose an option",
260
+ default=(
261
+ str(
262
+ options.index(
263
+ next(
264
+ (o for o in options if o.get("value") == default_value), options[0]
265
+ )
266
+ )
267
+ + 1
268
+ )
269
+ if default_value
270
+ else "1"
271
+ ),
272
+ )
273
+ try:
274
+ index = int(selection) - 1
275
+ if 0 <= index < len(options):
276
+ value = options[index]["value"]
277
+ await self._send_response(request_id, value)
278
+ break
279
+ else:
280
+ self.console.print("[red]Invalid selection, please try again[/red]")
281
+ except ValueError:
282
+ self.console.print("[red]Please enter a number[/red]")
283
+
284
+ async def _handle_input_request(self, request: Dict, default_value: Optional[str]) -> None:
285
+ """Handle a text input request."""
286
+ request_id = request["request_id"]
287
+
288
+ # Auto-respond mode (for testing)
289
+ if self.auto_respond is not None:
290
+ await self._send_response(request_id, self.auto_respond)
291
+ return
292
+
293
+ # Interactive prompt
294
+ response = Prompt.ask("Enter value", default=default_value or "")
295
+ await self._send_response(request_id, response)
296
+
297
+ async def _send_response(self, request_id: str, value: any) -> None:
298
+ """
299
+ Send a response back to the runtime.
300
+
301
+ Args:
302
+ request_id: Request being responded to
303
+ value: Response value
304
+ """
305
+ response_message = {
306
+ "type": "control.response",
307
+ "request_id": request_id,
308
+ "value": value,
309
+ "responder_id": "control-cli",
310
+ "responded_at": datetime.now().isoformat(),
311
+ }
312
+
313
+ try:
314
+ await write_message(self._writer, response_message)
315
+ self.console.print()
316
+ self.console.print("[green]✓ Response sent via IPC[/green]")
317
+ self.console.print()
318
+ except Exception as e:
319
+ self.console.print(f"[red]✗ Failed to send response: {e}[/red]")
320
+
321
+ def _handle_cancellation(self, message: Dict) -> None:
322
+ """Handle a cancellation notification."""
323
+ request_id = message["request_id"]
324
+ reason = message.get("reason", "unknown")
325
+ self.console.print()
326
+ self.console.print(f"[yellow]✗ Request {request_id} was cancelled: {reason}[/yellow]")
327
+ self.console.print()
328
+
329
+ def _handle_list_response(self, message: Dict) -> None:
330
+ """Handle a list response."""
331
+ requests = message.get("requests", [])
332
+
333
+ if not requests:
334
+ self.console.print("[dim]No pending requests[/dim]")
335
+ return
336
+
337
+ table = Table(title="Pending Requests")
338
+ table.add_column("ID", style="cyan")
339
+ table.add_column("Procedure", style="green")
340
+ table.add_column("Type")
341
+ table.add_column("Message")
342
+
343
+ for request in requests:
344
+ table.add_row(
345
+ request["request_id"][:8],
346
+ request.get("procedure_name", "Unknown"),
347
+ request["request_type"],
348
+ request["message"][:50] + ("..." if len(request["message"]) > 50 else ""),
349
+ )
350
+
351
+ self.console.print(table)
352
+
353
+ async def list_requests(self) -> None:
354
+ """Request a list of pending requests from the runtime."""
355
+ if not await self.connect():
356
+ return
357
+
358
+ try:
359
+ # Send list request
360
+ list_message = {"type": "control.list"}
361
+ await write_message(self._writer, list_message)
362
+
363
+ # Wait for response
364
+ message = await read_message(self._reader)
365
+ if message.get("type") == "control.list_response":
366
+ self._handle_list_response(message)
367
+
368
+ except Exception as e:
369
+ self.console.print(f"[red]✗ Failed to list requests: {e}[/red]")
370
+
371
+ finally:
372
+ await self.disconnect()
373
+
374
+
375
+ async def main(socket_path: str = "/tmp/tactus-control.sock", auto_respond: Optional[str] = None):
376
+ """
377
+ Main entry point for control CLI.
378
+
379
+ Args:
380
+ socket_path: Path to runtime's Unix socket
381
+ auto_respond: If set, automatically respond with this value
382
+ """
383
+ cli = ControlCLI(socket_path, auto_respond)
384
+ await cli.watch_mode()
385
+
386
+
387
+ if __name__ == "__main__":
388
+ import sys
389
+
390
+ socket_path = sys.argv[1] if len(sys.argv) > 1 else "/tmp/tactus-control.sock"
391
+ auto_respond = sys.argv[2] if len(sys.argv) > 2 else None
392
+
393
+ asyncio.run(main(socket_path, auto_respond))
@@ -259,10 +259,32 @@ class ConfigManager:
259
259
  # Sandbox configuration
260
260
  "TACTUS_SANDBOX_ENABLED": ("sandbox", "enabled"),
261
261
  "TACTUS_SANDBOX_IMAGE": ("sandbox", "image"),
262
+ # Notification configuration
263
+ "TACTUS_NOTIFICATIONS_ENABLED": ("notifications", "enabled"),
264
+ "TACTUS_NOTIFICATIONS_CALLBACK_URL": ("notifications", "callback_base_url"),
265
+ "TACTUS_HITL_SIGNING_SECRET": ("notifications", "signing_secret"),
266
+ # Slack notification channel
267
+ "SLACK_BOT_TOKEN": ("notifications", "channels", "slack", "token"),
268
+ # Discord notification channel
269
+ "DISCORD_BOT_TOKEN": ("notifications", "channels", "discord", "token"),
270
+ # Teams notification channel
271
+ "TEAMS_WEBHOOK_URL": ("notifications", "channels", "teams", "webhook_url"),
272
+ # Control loop configuration
273
+ "TACTUS_CONTROL_ENABLED": ("control", "enabled"),
274
+ "TACTUS_CONTROL_CLI_ENABLED": ("control", "channels", "cli", "enabled"),
275
+ # Tactus Cloud control channel
276
+ "TACTUS_CLOUD_API_URL": ("control", "channels", "tactus_cloud", "api_url"),
277
+ "TACTUS_CLOUD_TOKEN": ("control", "channels", "tactus_cloud", "token"),
278
+ "TACTUS_CLOUD_WORKSPACE_ID": ("control", "channels", "tactus_cloud", "workspace_id"),
262
279
  }
263
280
 
264
281
  # Boolean env vars that need special parsing
265
- boolean_env_keys = {"TACTUS_SANDBOX_ENABLED"}
282
+ boolean_env_keys = {
283
+ "TACTUS_SANDBOX_ENABLED",
284
+ "TACTUS_NOTIFICATIONS_ENABLED",
285
+ "TACTUS_CONTROL_ENABLED",
286
+ "TACTUS_CONTROL_CLI_ENABLED",
287
+ }
266
288
 
267
289
  for env_key, config_key in env_mappings.items():
268
290
  value = os.environ.get(env_key)
@@ -272,12 +294,17 @@ class ConfigManager:
272
294
  value = value.lower() in ("true", "1", "yes", "on")
273
295
 
274
296
  if isinstance(config_key, tuple):
275
- # Nested key (e.g., aws.access_key_id, sandbox.enabled)
276
- if config_key[0] not in config:
277
- config[config_key[0]] = {}
278
- config[config_key[0]][config_key[1]] = value
297
+ # Nested key - handle arbitrary depth
298
+ # e.g., ("aws", "access_key_id") -> config["aws"]["access_key_id"]
299
+ # e.g., ("notifications", "channels", "slack", "token")
300
+ current = config
301
+ for i, key in enumerate(config_key[:-1]):
302
+ if key not in current:
303
+ current[key] = {}
304
+ current = current[key]
305
+ current[config_key[-1]] = value
279
306
  # Track env var name for this nested key
280
- path = f"{config_key[0]}.{config_key[1]}"
307
+ path = ".".join(config_key)
281
308
  self.env_var_mapping[path] = env_key
282
309
  elif config_key == "tool_paths":
283
310
  # Parse JSON list
tactus/core/dsl_stubs.py CHANGED
@@ -35,6 +35,7 @@ from typing import Any, Callable, Dict
35
35
 
36
36
  from .registry import RegistryBuilder
37
37
  from tactus.primitives.handles import AgentHandle, ModelHandle, AgentLookup, ModelLookup
38
+ from tactus.stdlib.classify import ClassifyPrimitive
38
39
 
39
40
 
40
41
  # NEW Builder pattern for field types - moved outside function for import
@@ -473,17 +474,26 @@ def create_dsl_stubs(
473
474
  """Register BDD specs.
474
475
 
475
476
  Supported forms:
476
- - Specification([[ Gherkin text ]]) (alias for Specifications)
477
- - Specification("name", { ... }) (structured form; legacy)
477
+ - Specification([[ Gherkin text ]]) (inline Gherkin)
478
+ - Specification("name", { ... }) (structured form; legacy)
479
+ - Specification { from = "path" } (external file reference)
478
480
  """
479
481
  if len(args) == 1:
480
- builder.register_specifications(args[0])
482
+ arg = args[0]
483
+ # Check if it's a table with 'from' parameter
484
+ if isinstance(arg, dict) or (hasattr(arg, "keys") and callable(arg.keys)):
485
+ config = lua_table_to_dict(arg) if not isinstance(arg, dict) else arg
486
+ if "from" in config:
487
+ builder.register_specs_from(config["from"])
488
+ return
489
+ # Otherwise treat as inline Gherkin text
490
+ builder.register_specifications(arg)
481
491
  return
482
492
  if len(args) >= 2:
483
493
  spec_name, scenarios = args[0], args[1]
484
494
  builder.register_specification(spec_name, lua_table_to_dict(scenarios))
485
495
  return
486
- raise TypeError("Specification expects either (gherkin_text) or (name, scenarios)")
496
+ raise TypeError("Specification expects gherkin_text, {from='path'}, or (name, scenarios)")
487
497
 
488
498
  def _specifications(gherkin_text: str) -> None:
489
499
  """Register Gherkin BDD specifications."""
@@ -947,6 +957,18 @@ def create_dsl_stubs(
947
957
  if not isinstance(mock_config, dict):
948
958
  continue
949
959
 
960
+ # Agent mocks can be message-only, tool_calls-only, or both.
961
+ if any(k in mock_config for k in ("tool_calls", "message", "data", "usage")):
962
+ agent_config = {
963
+ "tool_calls": mock_config.get("tool_calls", []),
964
+ "message": mock_config.get("message", ""),
965
+ "data": mock_config.get("data", {}),
966
+ "usage": mock_config.get("usage", {}),
967
+ "temporal": mock_config.get("temporal", []),
968
+ }
969
+ builder.register_agent_mock(name, agent_config)
970
+ continue
971
+
950
972
  # Tool mocks use explicit keys.
951
973
  tool_mock_keys = {"returns", "temporal", "conditional", "error"}
952
974
  if any(k in mock_config for k in tool_mock_keys):
@@ -978,17 +1000,6 @@ def create_dsl_stubs(
978
1000
  builder.register_mock(name, processed_config)
979
1001
  continue
980
1002
 
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
1003
  # Otherwise, ignore unknown mock config.
993
1004
  continue
994
1005
 
@@ -1949,6 +1960,80 @@ def create_dsl_stubs(
1949
1960
 
1950
1961
  return handle
1951
1962
 
1963
+ def _new_classify(config=None):
1964
+ """
1965
+ Classify factory for smart classification with retry logic.
1966
+
1967
+ Syntax:
1968
+ -- One-shot classification
1969
+ result = Classify {
1970
+ classes = {"Yes", "No"},
1971
+ prompt = "Did the agent greet the customer?",
1972
+ input = transcript
1973
+ }
1974
+
1975
+ -- Reusable classifier
1976
+ classifier = Classify {
1977
+ classes = {"positive", "negative", "neutral"},
1978
+ prompt = "What is the sentiment?"
1979
+ }
1980
+ result = classifier(text)
1981
+
1982
+ Config options:
1983
+ - classes: List of valid classification values (required)
1984
+ - prompt: Classification instruction (required)
1985
+ - input: Optional input for one-shot classification
1986
+ - max_retries: Maximum retry attempts (default: 3)
1987
+ - temperature: Model temperature (default: 0.3)
1988
+ - model: Model to use (optional)
1989
+ - confidence_mode: "heuristic" or "none" (default: "heuristic")
1990
+
1991
+ Returns:
1992
+ ClassifyHandle if no input (reusable)
1993
+ ClassifyResult dict if input provided (one-shot)
1994
+ """
1995
+ if config is None:
1996
+ raise TypeError("Classify requires a configuration table")
1997
+
1998
+ # Create a wrapper function that creates agents using _new_agent.
1999
+ #
2000
+ # Important: Classify creates internal agents that are not assigned to a Lua global,
2001
+ # so they normally keep a random _temp_agent_* name. If the stdlib passes a stable
2002
+ # `name` in the agent config, we rename the handle here so it can be mocked via
2003
+ # `Mocks { <name> = { ... } }` in BDD specs.
2004
+ def agent_factory(agent_config):
2005
+ """Factory function to create Agent instances for Classify."""
2006
+ desired_name = None
2007
+ if isinstance(agent_config, dict):
2008
+ desired_name = agent_config.get("name")
2009
+
2010
+ # Use _new_agent to create an agent handle
2011
+ handle = _new_agent(agent_config)
2012
+
2013
+ # Apply stable naming for internal agents when requested.
2014
+ if desired_name:
2015
+ try:
2016
+ binding_callback(desired_name, handle)
2017
+ except Exception:
2018
+ # Best-effort: naming is for mocking/traceability; do not break runtime
2019
+ pass
2020
+ return handle
2021
+
2022
+ # Create the classify primitive with the agent factory
2023
+ classify_primitive = ClassifyPrimitive(
2024
+ agent_factory=agent_factory,
2025
+ lua_table_from=None, # Will be handled by result conversion
2026
+ registry=builder.registry if hasattr(builder, "registry") else None,
2027
+ mock_manager=mock_manager,
2028
+ )
2029
+
2030
+ # Call the primitive with the config
2031
+ return classify_primitive(lua_table_to_dict(config))
2032
+
2033
+ binding_callback = _make_binding_callback(
2034
+ builder, _tool_registry, _agent_registry, _runtime_context
2035
+ )
2036
+
1952
2037
  return {
1953
2038
  # NEW SYNTAX (Phase B+)
1954
2039
  # Note: stdlib tools are accessed via require("tactus.tools.done") etc.
@@ -1960,6 +2045,7 @@ def create_dsl_stubs(
1960
2045
  "Prompt": _prompt,
1961
2046
  "Toolset": _toolset,
1962
2047
  "Tool": _new_tool, # NEW syntax - assignment based
2048
+ "Classify": _new_classify, # NEW stdlib: smart classification with retry
1963
2049
  "Hitl": _hitl,
1964
2050
  "Specification": _specification,
1965
2051
  # BDD Testing
@@ -2012,9 +2098,7 @@ def create_dsl_stubs(
2012
2098
  "model": _model_registry,
2013
2099
  },
2014
2100
  # Assignment interception callback
2015
- "_tactus_register_binding": _make_binding_callback(
2016
- builder, _tool_registry, _agent_registry, _runtime_context
2017
- ),
2101
+ "_tactus_register_binding": binding_callback,
2018
2102
  }
2019
2103
 
2020
2104