tactus 0.32.2__py3-none-any.whl → 0.34.0__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 (100) 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 +382 -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/server.py +300 -9
  41. tactus/primitives/human.py +619 -47
  42. tactus/primitives/system.py +0 -1
  43. tactus/protocols/__init__.py +25 -0
  44. tactus/protocols/control.py +427 -0
  45. tactus/protocols/notification.py +207 -0
  46. tactus/sandbox/container_runner.py +79 -11
  47. tactus/sandbox/docker_manager.py +23 -0
  48. tactus/sandbox/entrypoint.py +26 -0
  49. tactus/sandbox/protocol.py +3 -0
  50. tactus/stdlib/README.md +77 -0
  51. tactus/stdlib/__init__.py +27 -1
  52. tactus/stdlib/classify/__init__.py +165 -0
  53. tactus/stdlib/classify/classify.spec.tac +195 -0
  54. tactus/stdlib/classify/classify.tac +257 -0
  55. tactus/stdlib/classify/fuzzy.py +282 -0
  56. tactus/stdlib/classify/llm.py +319 -0
  57. tactus/stdlib/classify/primitive.py +287 -0
  58. tactus/stdlib/core/__init__.py +57 -0
  59. tactus/stdlib/core/base.py +320 -0
  60. tactus/stdlib/core/confidence.py +211 -0
  61. tactus/stdlib/core/models.py +161 -0
  62. tactus/stdlib/core/retry.py +171 -0
  63. tactus/stdlib/core/validation.py +274 -0
  64. tactus/stdlib/extract/__init__.py +125 -0
  65. tactus/stdlib/extract/llm.py +330 -0
  66. tactus/stdlib/extract/primitive.py +256 -0
  67. tactus/stdlib/tac/tactus/classify/base.tac +51 -0
  68. tactus/stdlib/tac/tactus/classify/fuzzy.tac +87 -0
  69. tactus/stdlib/tac/tactus/classify/index.md +77 -0
  70. tactus/stdlib/tac/tactus/classify/init.tac +29 -0
  71. tactus/stdlib/tac/tactus/classify/llm.tac +150 -0
  72. tactus/stdlib/tac/tactus/classify.spec.tac +191 -0
  73. tactus/stdlib/tac/tactus/extract/base.tac +138 -0
  74. tactus/stdlib/tac/tactus/extract/index.md +96 -0
  75. tactus/stdlib/tac/tactus/extract/init.tac +27 -0
  76. tactus/stdlib/tac/tactus/extract/llm.tac +201 -0
  77. tactus/stdlib/tac/tactus/extract.spec.tac +153 -0
  78. tactus/stdlib/tac/tactus/generate/base.tac +142 -0
  79. tactus/stdlib/tac/tactus/generate/index.md +195 -0
  80. tactus/stdlib/tac/tactus/generate/init.tac +28 -0
  81. tactus/stdlib/tac/tactus/generate/llm.tac +169 -0
  82. tactus/stdlib/tac/tactus/generate.spec.tac +210 -0
  83. tactus/testing/behave_integration.py +171 -7
  84. tactus/testing/context.py +0 -1
  85. tactus/testing/evaluation_runner.py +0 -1
  86. tactus/testing/gherkin_parser.py +0 -1
  87. tactus/testing/mock_hitl.py +0 -1
  88. tactus/testing/mock_tools.py +0 -1
  89. tactus/testing/models.py +0 -1
  90. tactus/testing/steps/builtin.py +0 -1
  91. tactus/testing/steps/custom.py +81 -22
  92. tactus/testing/steps/registry.py +0 -1
  93. tactus/testing/test_runner.py +7 -1
  94. tactus/validation/semantic_visitor.py +11 -5
  95. tactus/validation/validator.py +0 -1
  96. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/METADATA +14 -2
  97. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/RECORD +100 -49
  98. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/WHEEL +0 -0
  99. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/entry_points.txt +0 -0
  100. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,225 @@
1
+ """
2
+ Host app control channel base class.
3
+
4
+ Provides the pattern for any app embedding Tactus to become a control channel.
5
+ The CLI is the simplest example, but this applies to any host app: web servers,
6
+ desktop apps, Jupyter notebooks, etc.
7
+
8
+ Key features:
9
+ - Interruptible via background thread pattern
10
+ - Can be cancelled if another channel responds first
11
+ - Races with remote channels - first response wins
12
+ """
13
+
14
+ import asyncio
15
+ import logging
16
+ import threading
17
+ from abc import abstractmethod
18
+ from typing import Optional
19
+ from datetime import datetime, timezone
20
+
21
+ from tactus.protocols.control import (
22
+ ControlRequest,
23
+ ControlResponse,
24
+ ChannelCapabilities,
25
+ DeliveryResult,
26
+ )
27
+ from tactus.adapters.channels.base import InProcessChannel
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class HostControlChannel(InProcessChannel):
33
+ """
34
+ Base class for host app control channels.
35
+
36
+ Any app embedding Tactus can extend this to become a control channel.
37
+ The channel uses a background thread for input collection so it can
38
+ be interrupted if another channel responds first.
39
+
40
+ Subclasses must implement:
41
+ - _prompt_for_input(): Display prompt and collect input (runs in thread)
42
+ - _show_cancelled(): Display cancellation message
43
+
44
+ The background thread pattern:
45
+ 1. send() displays the request and starts a background thread
46
+ 2. Thread calls _prompt_for_input() which blocks on user input
47
+ 3. If input received, push_response() adds to queue
48
+ 4. If cancel() called first, thread is interrupted via _cancel_event
49
+ 5. _show_cancelled() displays "Responded via {channel}" message
50
+ """
51
+
52
+ def __init__(self):
53
+ """Initialize the host channel."""
54
+ super().__init__()
55
+ self._cancel_event = threading.Event()
56
+ self._input_thread: Optional[threading.Thread] = None
57
+ self._current_request: Optional[ControlRequest] = None
58
+ self._event_loop: Optional[asyncio.AbstractEventLoop] = None
59
+
60
+ @property
61
+ def capabilities(self) -> ChannelCapabilities:
62
+ """Host channels support immediate synchronous responses."""
63
+ return ChannelCapabilities(
64
+ supports_approval=True,
65
+ supports_input=True,
66
+ supports_review=True,
67
+ supports_escalation=True,
68
+ supports_interactive_buttons=False,
69
+ supports_file_attachments=False,
70
+ max_message_length=None,
71
+ is_synchronous=True,
72
+ )
73
+
74
+ async def send(self, request: ControlRequest) -> DeliveryResult:
75
+ """
76
+ Display the request and start background input collection.
77
+
78
+ The actual prompt is displayed and input collected in a background
79
+ thread so we can be interrupted if another channel responds first.
80
+
81
+ Args:
82
+ request: ControlRequest with full context
83
+
84
+ Returns:
85
+ DeliveryResult indicating successful delivery
86
+ """
87
+ logger.info(f"{self.channel_id}: sending notification for {request.request_id}")
88
+
89
+ # Store for background thread access
90
+ self._current_request = request
91
+ self._cancel_event.clear()
92
+
93
+ # Capture event loop for thread-safe response pushing
94
+ self._event_loop = asyncio.get_event_loop()
95
+
96
+ # Display the request (synchronous, before starting thread)
97
+ self._display_request(request)
98
+
99
+ # Start background thread for input collection
100
+ self._input_thread = threading.Thread(
101
+ target=self._input_thread_main,
102
+ args=(request,),
103
+ daemon=True,
104
+ )
105
+ self._input_thread.start()
106
+
107
+ return DeliveryResult(
108
+ channel_id=self.channel_id,
109
+ external_message_id=request.request_id,
110
+ delivered_at=datetime.now(timezone.utc),
111
+ success=True,
112
+ )
113
+
114
+ async def cancel(self, external_message_id: str, reason: str) -> None:
115
+ """
116
+ Cancel the prompt - another channel responded first.
117
+
118
+ Sets the cancel event to interrupt the background thread,
119
+ then displays a message indicating another channel responded.
120
+
121
+ Args:
122
+ external_message_id: Request ID (same as sent)
123
+ reason: Reason for cancellation (e.g., "Responded via tactus_cloud")
124
+ """
125
+ logger.debug(f"{self.channel_id}: cancelling {external_message_id}: {reason}")
126
+ self._cancel_event.set()
127
+ self._show_cancelled(reason)
128
+
129
+ async def shutdown(self) -> None:
130
+ """Clean shutdown - cancel any pending input."""
131
+ await super().shutdown()
132
+ self._cancel_event.set()
133
+ if self._input_thread and self._input_thread.is_alive():
134
+ self._input_thread.join(timeout=1.0)
135
+
136
+ def _input_thread_main(self, request: ControlRequest) -> None:
137
+ """
138
+ Background thread main function.
139
+
140
+ Collects input from the user and pushes the response to the queue.
141
+ Can be interrupted via _cancel_event.
142
+
143
+ Args:
144
+ request: The control request to handle
145
+ """
146
+ try:
147
+ # Collect input (may block)
148
+ response_value = self._prompt_for_input(request)
149
+
150
+ # Check if cancelled while waiting
151
+ if self._cancel_event.is_set():
152
+ return
153
+
154
+ if response_value is not None:
155
+ # Create response and push to queue
156
+ response = ControlResponse(
157
+ request_id=request.request_id,
158
+ value=response_value,
159
+ responded_at=datetime.now(timezone.utc),
160
+ timed_out=False,
161
+ channel_id=self.channel_id,
162
+ )
163
+
164
+ # Push thread-safe
165
+ if self._event_loop:
166
+ self.push_response_threadsafe(response, self._event_loop)
167
+ else:
168
+ self.push_response(response)
169
+
170
+ except Exception as e:
171
+ if not self._cancel_event.is_set():
172
+ logger.error(f"{self.channel_id}: input error: {e}")
173
+
174
+ @abstractmethod
175
+ def _display_request(self, request: ControlRequest) -> None:
176
+ """
177
+ Display the control request to the user.
178
+
179
+ Called synchronously before starting input thread.
180
+ Should display the message, options, context, etc.
181
+
182
+ Args:
183
+ request: The control request to display
184
+ """
185
+ ...
186
+
187
+ @abstractmethod
188
+ def _prompt_for_input(self, request: ControlRequest) -> Optional[any]:
189
+ """
190
+ Collect input from the user.
191
+
192
+ Runs in a background thread. Should check _cancel_event periodically
193
+ if blocking on long operations. Returns None if cancelled.
194
+
195
+ Args:
196
+ request: The control request being handled
197
+
198
+ Returns:
199
+ The user's response value, or None if cancelled/interrupted
200
+ """
201
+ ...
202
+
203
+ @abstractmethod
204
+ def _show_cancelled(self, reason: str) -> None:
205
+ """
206
+ Show cancellation message.
207
+
208
+ Called when another channel responds first.
209
+
210
+ Args:
211
+ reason: Reason for cancellation (e.g., "Responded via tactus_cloud")
212
+ """
213
+ ...
214
+
215
+ def is_cancelled(self) -> bool:
216
+ """
217
+ Check if the current request has been cancelled.
218
+
219
+ Call this periodically from _prompt_for_input() if your input
220
+ method supports checking for interruption.
221
+
222
+ Returns:
223
+ True if cancelled, False otherwise
224
+ """
225
+ return self._cancel_event.is_set()
@@ -0,0 +1,297 @@
1
+ """
2
+ IPC Control Channel - Unix socket communication for control loop.
3
+
4
+ This channel allows external control CLI apps to connect and respond to
5
+ control requests via Unix domain sockets using the broker protocol.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import os
11
+ import uuid
12
+ from datetime import datetime
13
+ from typing import Dict, Optional
14
+
15
+ from tactus.broker.protocol import read_message, write_message
16
+ from tactus.protocols.control import (
17
+ ControlRequest,
18
+ ControlResponse,
19
+ ChannelCapabilities,
20
+ DeliveryResult,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class IPCControlChannel:
27
+ """
28
+ Control channel using Unix socket IPC with broker protocol.
29
+
30
+ The runtime creates a Unix socket server that control CLI clients can
31
+ connect to. Control requests are broadcast to all connected clients,
32
+ and the first response wins (standard racing pattern).
33
+ """
34
+
35
+ def __init__(self, socket_path: Optional[str] = None, procedure_id: Optional[str] = None):
36
+ """
37
+ Initialize IPC control channel.
38
+
39
+ Args:
40
+ socket_path: Path to Unix socket (default: /tmp/tactus-control-{procedure_id}.sock)
41
+ procedure_id: Procedure ID for default socket path
42
+ """
43
+ self.procedure_id = procedure_id or "default"
44
+ self.socket_path = socket_path or f"/tmp/tactus-control-{self.procedure_id}.sock"
45
+ self.channel_id = "ipc"
46
+
47
+ self._server: Optional[asyncio.Server] = None
48
+ self._clients: Dict[str, asyncio.StreamWriter] = {} # client_id -> writer
49
+ self._response_queue: asyncio.Queue[ControlResponse] = asyncio.Queue()
50
+ self._pending_requests: Dict[str, ControlRequest] = {} # request_id -> request
51
+ self._initialized = False
52
+
53
+ @property
54
+ def capabilities(self) -> ChannelCapabilities:
55
+ """IPC supports all request types and can respond synchronously."""
56
+ return ChannelCapabilities(
57
+ supports_approval=True,
58
+ supports_input=True,
59
+ supports_choice=True,
60
+ supports_review=True,
61
+ supports_escalation=True,
62
+ is_synchronous=True, # Humans respond in real-time via control CLI
63
+ )
64
+
65
+ async def initialize(self) -> None:
66
+ """Start Unix socket server and accept connections."""
67
+ if self._initialized:
68
+ return
69
+
70
+ logger.info(f"{self.channel_id}: initializing...")
71
+
72
+ # Remove old socket file if it exists
73
+ if os.path.exists(self.socket_path):
74
+ os.unlink(self.socket_path)
75
+
76
+ # Create parent directory if needed
77
+ socket_dir = os.path.dirname(self.socket_path)
78
+ os.makedirs(socket_dir, exist_ok=True)
79
+
80
+ # Start Unix socket server
81
+ self._server = await asyncio.start_unix_server(self._handle_client, path=self.socket_path)
82
+
83
+ # Set socket permissions
84
+ os.chmod(self.socket_path, 0o600)
85
+
86
+ self._initialized = True
87
+ logger.info(f"{self.channel_id}: ready (listening on {self.socket_path})")
88
+
89
+ async def send(self, request: ControlRequest) -> DeliveryResult:
90
+ """
91
+ Send control request to all connected clients.
92
+
93
+ Args:
94
+ request: ControlRequest object with all request details
95
+
96
+ Returns:
97
+ DeliveryResult with success/failure info
98
+ """
99
+ logger.info(f"{self.channel_id}: sending notification for {request.request_id}")
100
+
101
+ # Create control request message from ControlRequest object
102
+ request_data = {
103
+ "type": "control.request",
104
+ "request_id": request.request_id,
105
+ "procedure_id": request.procedure_id,
106
+ "procedure_name": request.procedure_name,
107
+ "invocation_id": request.invocation_id,
108
+ "request_type": request.request_type,
109
+ "message": request.message,
110
+ "options": [{"label": opt.label, "value": opt.value} for opt in request.options],
111
+ "default_value": request.default_value,
112
+ "timeout_seconds": request.timeout_seconds,
113
+ "metadata": request.metadata,
114
+ "namespace": request.namespace,
115
+ "subject": request.subject,
116
+ "started_at": request.started_at.isoformat() if request.started_at else None,
117
+ "input_summary": request.input_summary,
118
+ "conversation": request.conversation,
119
+ "prior_interactions": request.prior_interactions,
120
+ }
121
+
122
+ # Store pending request
123
+ self._pending_requests[request.request_id] = request_data
124
+
125
+ # Send to all connected clients
126
+ successful = 0
127
+ failed = 0
128
+
129
+ for client_id, writer in list(self._clients.items()):
130
+ try:
131
+ await write_message(writer, request_data)
132
+ successful += 1
133
+ except Exception as e:
134
+ logger.error(f"{self.channel_id}: failed to send to client {client_id}: {e}")
135
+ failed += 1
136
+ # Remove dead client
137
+ self._clients.pop(client_id, None)
138
+
139
+ if successful == 0 and len(self._clients) == 0:
140
+ logger.warning(f"{self.channel_id}: no clients connected")
141
+
142
+ # Return DeliveryResult
143
+ return DeliveryResult(
144
+ channel_id=self.channel_id,
145
+ external_message_id=request.request_id,
146
+ delivered_at=datetime.now(),
147
+ success=successful > 0,
148
+ error_message=None if successful > 0 else "No clients connected",
149
+ )
150
+
151
+ async def receive(self):
152
+ """
153
+ Yield responses from clients as they arrive.
154
+
155
+ Yields:
156
+ ControlResponse objects
157
+ """
158
+ while True:
159
+ response = await self._response_queue.get()
160
+ logger.info(f"{self.channel_id}: received response for {response.request_id}")
161
+ yield response
162
+
163
+ async def cancel(self, request_id: str, reason: str) -> None:
164
+ """
165
+ Cancel a pending request.
166
+
167
+ Args:
168
+ request_id: Request to cancel
169
+ reason: Cancellation reason
170
+ """
171
+ logger.debug(f"{self.channel_id}: cancelling {request_id} ({reason})")
172
+
173
+ # Remove from pending
174
+ self._pending_requests.pop(request_id, None)
175
+
176
+ # Send cancellation to all clients
177
+ cancel_message = {"type": "control.cancelled", "request_id": request_id, "reason": reason}
178
+
179
+ for client_id, writer in list(self._clients.items()):
180
+ try:
181
+ await write_message(writer, cancel_message)
182
+ except Exception as e:
183
+ logger.error(f"{self.channel_id}: failed to send cancellation to {client_id}: {e}")
184
+
185
+ async def shutdown(self) -> None:
186
+ """Clean up and close server."""
187
+ logger.info(f"{self.channel_id}: shutting down")
188
+
189
+ # Close all client connections
190
+ for client_id, writer in list(self._clients.items()):
191
+ try:
192
+ writer.close()
193
+ await writer.wait_closed()
194
+ except Exception as e:
195
+ logger.error(f"{self.channel_id}: error closing client {client_id}: {e}")
196
+
197
+ self._clients.clear()
198
+
199
+ # Close server
200
+ if self._server:
201
+ self._server.close()
202
+ await self._server.wait_closed()
203
+
204
+ # Remove socket file
205
+ if os.path.exists(self.socket_path):
206
+ try:
207
+ os.unlink(self.socket_path)
208
+ except Exception as e:
209
+ logger.error(f"{self.channel_id}: failed to remove socket file: {e}")
210
+
211
+ self._initialized = False
212
+
213
+ async def _handle_client(
214
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
215
+ ) -> None:
216
+ """
217
+ Handle a connected client.
218
+
219
+ Args:
220
+ reader: asyncio StreamReader
221
+ writer: asyncio StreamWriter
222
+ """
223
+ client_id = str(uuid.uuid4())[:8]
224
+
225
+ logger.info(f"{self.channel_id}: client connected ({client_id})")
226
+
227
+ # Register client
228
+ self._clients[client_id] = writer
229
+
230
+ try:
231
+ # Send any pending requests to the new client
232
+ for request_id, request_data in self._pending_requests.items():
233
+ try:
234
+ await write_message(writer, request_data)
235
+ except Exception as e:
236
+ logger.error(
237
+ f"{self.channel_id}: failed to send pending request to {client_id}: {e}"
238
+ )
239
+
240
+ # Read messages from client
241
+ while True:
242
+ try:
243
+ message = await read_message(reader)
244
+ except EOFError:
245
+ break
246
+ except asyncio.IncompleteReadError:
247
+ break
248
+
249
+ # Handle message
250
+ msg_type = message.get("type")
251
+
252
+ if msg_type == "control.response":
253
+ # Parse response and queue it
254
+ response = ControlResponse(
255
+ request_id=message["request_id"],
256
+ value=message["value"],
257
+ responder_id=message.get("responder_id", client_id),
258
+ responded_at=(
259
+ datetime.fromisoformat(message["responded_at"])
260
+ if message.get("responded_at")
261
+ else datetime.now()
262
+ ),
263
+ timed_out=message.get("timed_out", False),
264
+ channel_id=self.channel_id,
265
+ )
266
+ await self._response_queue.put(response)
267
+ logger.info(f"{self.channel_id}: received response for {response.request_id}")
268
+
269
+ # Remove from pending
270
+ self._pending_requests.pop(response.request_id, None)
271
+
272
+ elif msg_type == "control.list":
273
+ # Client requesting list of pending requests
274
+ list_response = {
275
+ "type": "control.list_response",
276
+ "requests": list(self._pending_requests.values()),
277
+ }
278
+ await write_message(writer, list_response)
279
+
280
+ else:
281
+ logger.warning(
282
+ f"{self.channel_id}: unknown message type from {client_id}: {msg_type}"
283
+ )
284
+
285
+ except Exception as e:
286
+ logger.error(f"{self.channel_id}: error handling client {client_id}: {e}")
287
+
288
+ finally:
289
+ # Clean up
290
+ self._clients.pop(client_id, None)
291
+ logger.info(f"{self.channel_id}: client disconnected ({client_id})")
292
+
293
+ try:
294
+ writer.close()
295
+ await writer.wait_closed()
296
+ except Exception:
297
+ pass