tactus 0.33.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.
- tactus/__init__.py +1 -1
- tactus/adapters/__init__.py +18 -1
- tactus/adapters/broker_log.py +127 -34
- tactus/adapters/channels/__init__.py +153 -0
- tactus/adapters/channels/base.py +174 -0
- tactus/adapters/channels/broker.py +179 -0
- tactus/adapters/channels/cli.py +448 -0
- tactus/adapters/channels/host.py +225 -0
- tactus/adapters/channels/ipc.py +297 -0
- tactus/adapters/channels/sse.py +305 -0
- tactus/adapters/cli_hitl.py +223 -1
- tactus/adapters/control_loop.py +879 -0
- tactus/adapters/file_storage.py +35 -2
- tactus/adapters/ide_log.py +7 -1
- tactus/backends/http_backend.py +0 -1
- tactus/broker/client.py +31 -1
- tactus/broker/server.py +416 -92
- tactus/cli/app.py +270 -7
- tactus/cli/control.py +393 -0
- tactus/core/config_manager.py +33 -6
- tactus/core/dsl_stubs.py +102 -18
- tactus/core/execution_context.py +265 -8
- tactus/core/lua_sandbox.py +8 -9
- tactus/core/registry.py +19 -2
- tactus/core/runtime.py +235 -27
- tactus/docker/Dockerfile.pypi +49 -0
- tactus/docs/__init__.py +33 -0
- tactus/docs/extractor.py +326 -0
- tactus/docs/html_renderer.py +72 -0
- tactus/docs/models.py +121 -0
- tactus/docs/templates/base.html +204 -0
- tactus/docs/templates/index.html +58 -0
- tactus/docs/templates/module.html +96 -0
- tactus/dspy/agent.py +382 -22
- tactus/dspy/broker_lm.py +57 -6
- tactus/dspy/config.py +14 -3
- tactus/dspy/history.py +2 -1
- tactus/dspy/module.py +136 -11
- tactus/dspy/signature.py +0 -1
- tactus/ide/server.py +300 -9
- tactus/primitives/human.py +619 -47
- tactus/primitives/system.py +0 -1
- tactus/protocols/__init__.py +25 -0
- tactus/protocols/control.py +427 -0
- tactus/protocols/notification.py +207 -0
- tactus/sandbox/container_runner.py +79 -11
- tactus/sandbox/docker_manager.py +23 -0
- tactus/sandbox/entrypoint.py +26 -0
- tactus/sandbox/protocol.py +3 -0
- tactus/stdlib/README.md +77 -0
- tactus/stdlib/__init__.py +27 -1
- tactus/stdlib/classify/__init__.py +165 -0
- tactus/stdlib/classify/classify.spec.tac +195 -0
- tactus/stdlib/classify/classify.tac +257 -0
- tactus/stdlib/classify/fuzzy.py +282 -0
- tactus/stdlib/classify/llm.py +319 -0
- tactus/stdlib/classify/primitive.py +287 -0
- tactus/stdlib/core/__init__.py +57 -0
- tactus/stdlib/core/base.py +320 -0
- tactus/stdlib/core/confidence.py +211 -0
- tactus/stdlib/core/models.py +161 -0
- tactus/stdlib/core/retry.py +171 -0
- tactus/stdlib/core/validation.py +274 -0
- tactus/stdlib/extract/__init__.py +125 -0
- tactus/stdlib/extract/llm.py +330 -0
- tactus/stdlib/extract/primitive.py +256 -0
- tactus/stdlib/tac/tactus/classify/base.tac +51 -0
- tactus/stdlib/tac/tactus/classify/fuzzy.tac +87 -0
- tactus/stdlib/tac/tactus/classify/index.md +77 -0
- tactus/stdlib/tac/tactus/classify/init.tac +29 -0
- tactus/stdlib/tac/tactus/classify/llm.tac +150 -0
- tactus/stdlib/tac/tactus/classify.spec.tac +191 -0
- tactus/stdlib/tac/tactus/extract/base.tac +138 -0
- tactus/stdlib/tac/tactus/extract/index.md +96 -0
- tactus/stdlib/tac/tactus/extract/init.tac +27 -0
- tactus/stdlib/tac/tactus/extract/llm.tac +201 -0
- tactus/stdlib/tac/tactus/extract.spec.tac +153 -0
- tactus/stdlib/tac/tactus/generate/base.tac +142 -0
- tactus/stdlib/tac/tactus/generate/index.md +195 -0
- tactus/stdlib/tac/tactus/generate/init.tac +28 -0
- tactus/stdlib/tac/tactus/generate/llm.tac +169 -0
- tactus/stdlib/tac/tactus/generate.spec.tac +210 -0
- tactus/testing/behave_integration.py +171 -7
- tactus/testing/context.py +0 -1
- tactus/testing/evaluation_runner.py +0 -1
- tactus/testing/gherkin_parser.py +0 -1
- tactus/testing/mock_hitl.py +0 -1
- tactus/testing/mock_tools.py +0 -1
- tactus/testing/models.py +0 -1
- tactus/testing/steps/builtin.py +0 -1
- tactus/testing/steps/custom.py +81 -22
- tactus/testing/steps/registry.py +0 -1
- tactus/testing/test_runner.py +7 -1
- tactus/validation/semantic_visitor.py +11 -5
- tactus/validation/validator.py +0 -1
- {tactus-0.33.0.dist-info → tactus-0.34.1.dist-info}/METADATA +14 -2
- {tactus-0.33.0.dist-info → tactus-0.34.1.dist-info}/RECORD +100 -49
- {tactus-0.33.0.dist-info → tactus-0.34.1.dist-info}/WHEEL +0 -0
- {tactus-0.33.0.dist-info → tactus-0.34.1.dist-info}/entry_points.txt +0 -0
- {tactus-0.33.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))
|
tactus/core/config_manager.py
CHANGED
|
@@ -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 = {
|
|
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
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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 =
|
|
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 ]])
|
|
477
|
-
- Specification("name", { ... })
|
|
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
|
-
|
|
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
|
|
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":
|
|
2016
|
-
builder, _tool_registry, _agent_registry, _runtime_context
|
|
2017
|
-
),
|
|
2101
|
+
"_tactus_register_binding": binding_callback,
|
|
2018
2102
|
}
|
|
2019
2103
|
|
|
2020
2104
|
|