tactus 0.33.0__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.
- 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.0.dist-info}/METADATA +14 -2
- {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/RECORD +100 -49
- {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/WHEEL +0 -0
- {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/entry_points.txt +0 -0
- {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/licenses/LICENSE +0 -0
tactus/__init__.py
CHANGED
tactus/adapters/__init__.py
CHANGED
|
@@ -4,6 +4,23 @@ Tactus adapters - Built-in implementations of Tactus protocols.
|
|
|
4
4
|
|
|
5
5
|
from tactus.adapters.memory import MemoryStorage
|
|
6
6
|
from tactus.adapters.file_storage import FileStorage
|
|
7
|
+
|
|
8
|
+
# Legacy HITL handler (maintained for backward compatibility)
|
|
7
9
|
from tactus.adapters.cli_hitl import CLIHITLHandler
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
# New control loop architecture
|
|
12
|
+
from tactus.adapters.control_loop import ControlLoopHandler
|
|
13
|
+
from tactus.adapters.channels import load_channels_from_config, load_default_channels
|
|
14
|
+
from tactus.adapters.channels.cli import CLIControlChannel
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"MemoryStorage",
|
|
18
|
+
"FileStorage",
|
|
19
|
+
# Legacy HITL
|
|
20
|
+
"CLIHITLHandler",
|
|
21
|
+
# New control loop
|
|
22
|
+
"ControlLoopHandler",
|
|
23
|
+
"CLIControlChannel",
|
|
24
|
+
"load_channels_from_config",
|
|
25
|
+
"load_default_channels",
|
|
26
|
+
]
|
tactus/adapters/broker_log.py
CHANGED
|
@@ -1,52 +1,118 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Broker log handler for container event streaming
|
|
2
|
+
Broker log handler for container event streaming.
|
|
3
3
|
|
|
4
4
|
Used inside the runtime container to forward structured log events to the
|
|
5
|
-
host-side broker
|
|
5
|
+
host-side broker for real-time streaming to the IDE frontend.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import queue
|
|
11
14
|
import threading
|
|
12
15
|
from typing import Optional
|
|
13
16
|
|
|
14
|
-
from tactus.broker.client import BrokerClient
|
|
15
17
|
from tactus.protocols.models import LogEvent, CostEvent
|
|
16
18
|
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
17
21
|
|
|
18
22
|
class BrokerLogHandler:
|
|
19
23
|
"""
|
|
20
|
-
Log handler that forwards events to the broker via
|
|
24
|
+
Log handler that forwards events to the broker via background thread.
|
|
25
|
+
|
|
26
|
+
Uses a dedicated thread with its own event loop to ensure events are
|
|
27
|
+
sent in real-time, regardless of whether the main execution yields control.
|
|
28
|
+
This enables true streaming of agent responses to the frontend.
|
|
21
29
|
|
|
22
30
|
The broker socket path is read from `TACTUS_BROKER_SOCKET`.
|
|
23
31
|
"""
|
|
24
32
|
|
|
25
|
-
def __init__(self,
|
|
26
|
-
self.
|
|
33
|
+
def __init__(self, socket_path: str):
|
|
34
|
+
self._socket_path = socket_path
|
|
27
35
|
self.cost_events: list[CostEvent] = []
|
|
28
|
-
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
29
|
-
self._loop_lock = threading.Lock()
|
|
30
36
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
# Thread-safe queue for events to send
|
|
38
|
+
self._queue: queue.Queue = queue.Queue()
|
|
39
|
+
|
|
40
|
+
# Background worker thread
|
|
41
|
+
self._worker_thread: Optional[threading.Thread] = None
|
|
42
|
+
self._shutdown = threading.Event()
|
|
43
|
+
self._started = False
|
|
44
|
+
self._start_lock = threading.Lock()
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def supports_streaming(self) -> bool:
|
|
48
|
+
"""Broker handler supports real-time streaming."""
|
|
49
|
+
return True
|
|
41
50
|
|
|
42
51
|
@classmethod
|
|
43
52
|
def from_environment(cls) -> Optional["BrokerLogHandler"]:
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
"""Create handler from TACTUS_BROKER_SOCKET environment variable."""
|
|
54
|
+
socket_path = os.environ.get("TACTUS_BROKER_SOCKET")
|
|
55
|
+
if not socket_path:
|
|
46
56
|
return None
|
|
47
|
-
return cls(
|
|
57
|
+
return cls(socket_path)
|
|
58
|
+
|
|
59
|
+
def _ensure_worker_started(self) -> None:
|
|
60
|
+
"""Start background worker thread if not already running."""
|
|
61
|
+
with self._start_lock:
|
|
62
|
+
if not self._started:
|
|
63
|
+
self._worker_thread = threading.Thread(
|
|
64
|
+
target=self._worker,
|
|
65
|
+
name="BrokerLogWorker",
|
|
66
|
+
daemon=True,
|
|
67
|
+
)
|
|
68
|
+
self._worker_thread.start()
|
|
69
|
+
self._started = True
|
|
70
|
+
logger.debug("[BROKER_LOG] Background worker started")
|
|
71
|
+
|
|
72
|
+
def _worker(self) -> None:
|
|
73
|
+
"""
|
|
74
|
+
Background thread that sends events to broker.
|
|
75
|
+
|
|
76
|
+
Runs in its own event loop to avoid blocking the main execution.
|
|
77
|
+
Creates a fresh BrokerClient connection for thread safety.
|
|
78
|
+
"""
|
|
79
|
+
from tactus.broker.client import BrokerClient
|
|
80
|
+
|
|
81
|
+
# Create fresh client in this thread's event loop
|
|
82
|
+
loop = asyncio.new_event_loop()
|
|
83
|
+
asyncio.set_event_loop(loop)
|
|
84
|
+
client = BrokerClient(self._socket_path)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
while not self._shutdown.is_set():
|
|
88
|
+
try:
|
|
89
|
+
# Wait for event with timeout to check shutdown flag
|
|
90
|
+
event_dict = self._queue.get(timeout=0.05)
|
|
91
|
+
|
|
92
|
+
# Send event to broker
|
|
93
|
+
loop.run_until_complete(client.emit_event(event_dict))
|
|
94
|
+
self._queue.task_done()
|
|
95
|
+
|
|
96
|
+
except queue.Empty:
|
|
97
|
+
continue
|
|
98
|
+
except Exception as e:
|
|
99
|
+
# Best effort - don't crash worker on individual failures
|
|
100
|
+
logger.debug(f"[BROKER_LOG] Failed to emit event: {e}")
|
|
101
|
+
try:
|
|
102
|
+
self._queue.task_done()
|
|
103
|
+
except ValueError:
|
|
104
|
+
pass
|
|
105
|
+
finally:
|
|
106
|
+
loop.close()
|
|
107
|
+
logger.debug("[BROKER_LOG] Background worker stopped")
|
|
48
108
|
|
|
49
109
|
def log(self, event: LogEvent) -> None:
|
|
110
|
+
"""
|
|
111
|
+
Forward an event to the broker.
|
|
112
|
+
|
|
113
|
+
Events are queued and sent by a background thread, enabling
|
|
114
|
+
real-time streaming without blocking procedure execution.
|
|
115
|
+
"""
|
|
50
116
|
# Track cost events for aggregation (mirrors IDELogHandler behavior)
|
|
51
117
|
if isinstance(event, CostEvent):
|
|
52
118
|
self.cost_events.append(event)
|
|
@@ -60,17 +126,44 @@ class BrokerLogHandler:
|
|
|
60
126
|
iso_string += "Z"
|
|
61
127
|
event_dict["timestamp"] = iso_string
|
|
62
128
|
|
|
63
|
-
#
|
|
129
|
+
# Ensure worker is running
|
|
130
|
+
self._ensure_worker_started()
|
|
131
|
+
|
|
132
|
+
# Queue event for background sending (non-blocking)
|
|
64
133
|
try:
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
134
|
+
self._queue.put_nowait(event_dict)
|
|
135
|
+
except queue.Full:
|
|
136
|
+
# Drop event if queue is full (shouldn't happen with unlimited queue)
|
|
137
|
+
logger.debug("[BROKER_LOG] Queue full, dropping event")
|
|
138
|
+
|
|
139
|
+
async def flush(self) -> None:
|
|
140
|
+
"""
|
|
141
|
+
Wait for all queued events to be sent.
|
|
142
|
+
|
|
143
|
+
Call this before procedure completion to ensure all events
|
|
144
|
+
are delivered to the broker before the container exits.
|
|
145
|
+
"""
|
|
146
|
+
if not self._started:
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
# Wait for queue to drain
|
|
150
|
+
max_wait = 5.0 # Maximum wait time in seconds
|
|
151
|
+
poll_interval = 0.05
|
|
152
|
+
elapsed = 0.0
|
|
153
|
+
|
|
154
|
+
while not self._queue.empty() and elapsed < max_wait:
|
|
155
|
+
await asyncio.sleep(poll_interval)
|
|
156
|
+
elapsed += poll_interval
|
|
157
|
+
|
|
158
|
+
if not self._queue.empty():
|
|
159
|
+
remaining = self._queue.qsize()
|
|
160
|
+
logger.warning(f"[BROKER_LOG] Flush timeout with {remaining} events remaining")
|
|
161
|
+
|
|
162
|
+
# Signal worker to shutdown
|
|
163
|
+
self._shutdown.set()
|
|
164
|
+
|
|
165
|
+
# Wait for worker thread to finish
|
|
166
|
+
if self._worker_thread and self._worker_thread.is_alive():
|
|
167
|
+
self._worker_thread.join(timeout=1.0)
|
|
168
|
+
if self._worker_thread.is_alive():
|
|
169
|
+
logger.warning("[BROKER_LOG] Worker thread did not stop cleanly")
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Control channel implementations for omnichannel control loop.
|
|
3
|
+
|
|
4
|
+
This package contains control channel plugins:
|
|
5
|
+
- CLI: Command-line interface for terminal control (host app pattern)
|
|
6
|
+
- IDE/SSE: Server-Sent Events for VSCode extension (Phase 2)
|
|
7
|
+
- Tactus Cloud: WebSocket API for companion app (Phase 5)
|
|
8
|
+
- Additional channels as needed: Slack, Teams, Email, etc.
|
|
9
|
+
|
|
10
|
+
The control loop uses a publish-subscribe pattern with namespace-based routing:
|
|
11
|
+
- Publishers (Tactus runtimes) emit control requests to namespaces
|
|
12
|
+
- Subscribers (controllers) subscribe to namespace patterns
|
|
13
|
+
- Subscribers can be observers (read-only) or responders (can provide input)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from typing import List, Dict, Any, Optional
|
|
17
|
+
import logging
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from tactus.protocols.control import ControlChannel, ControlLoopConfig
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Channel registry for lazy loading
|
|
26
|
+
_CHANNEL_LOADERS = {
|
|
27
|
+
"cli": "tactus.adapters.channels.cli:CLIControlChannel",
|
|
28
|
+
"ipc": "tactus.adapters.channels.ipc:IPCControlChannel",
|
|
29
|
+
# "ide": "tactus.adapters.channels.ide_sse:SSEControlChannel", # Phase 2
|
|
30
|
+
# "tactus_cloud": "tactus.adapters.channels.tactus_cloud:TactusCloudChannel", # Phase 5
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_channel(channel_id: str, config: Dict[str, Any]) -> Optional[ControlChannel]:
|
|
35
|
+
"""
|
|
36
|
+
Load a control channel by ID.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
channel_id: Channel identifier (e.g., 'cli', 'ide', 'tactus_cloud')
|
|
40
|
+
config: Channel configuration dict
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
ControlChannel instance or None if loading fails
|
|
44
|
+
"""
|
|
45
|
+
if channel_id not in _CHANNEL_LOADERS:
|
|
46
|
+
logger.warning(f"Unknown or not yet implemented channel: {channel_id}")
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
module_path = _CHANNEL_LOADERS[channel_id]
|
|
50
|
+
module_name, class_name = module_path.rsplit(":", 1)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
import importlib
|
|
54
|
+
|
|
55
|
+
module = importlib.import_module(module_name)
|
|
56
|
+
channel_class = getattr(module, class_name)
|
|
57
|
+
return channel_class(**config)
|
|
58
|
+
except ImportError as e:
|
|
59
|
+
logger.warning(
|
|
60
|
+
f"Failed to load {channel_id} channel. "
|
|
61
|
+
f"Ensure dependencies are installed. "
|
|
62
|
+
f"Error: {e}"
|
|
63
|
+
)
|
|
64
|
+
return None
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.exception(f"Failed to initialize {channel_id} channel: {e}")
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_channels_from_config(config: Optional[ControlLoopConfig] = None) -> List[ControlChannel]:
|
|
71
|
+
"""
|
|
72
|
+
Load control channels based on configuration and context.
|
|
73
|
+
|
|
74
|
+
By default:
|
|
75
|
+
- CLI channel is enabled if stdin is a tty (interactive terminal)
|
|
76
|
+
- Other channels are loaded based on configuration
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
config: Optional control loop configuration
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
List of enabled ControlChannel instances
|
|
83
|
+
"""
|
|
84
|
+
channels: List[ControlChannel] = []
|
|
85
|
+
|
|
86
|
+
if config is None:
|
|
87
|
+
config = ControlLoopConfig()
|
|
88
|
+
|
|
89
|
+
# Process each configured channel
|
|
90
|
+
for channel_id, channel_config in config.channels.items():
|
|
91
|
+
enabled = channel_config.get("enabled", False)
|
|
92
|
+
|
|
93
|
+
# Special handling for CLI with auto-detection
|
|
94
|
+
if channel_id == "cli":
|
|
95
|
+
if enabled == "auto" or enabled is None:
|
|
96
|
+
enabled = sys.stdin.isatty()
|
|
97
|
+
elif isinstance(enabled, str):
|
|
98
|
+
enabled = enabled.lower() == "true"
|
|
99
|
+
|
|
100
|
+
if not enabled:
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
# Remove 'enabled' from config before passing to constructor
|
|
104
|
+
init_config = {k: v for k, v in channel_config.items() if k != "enabled"}
|
|
105
|
+
channel = load_channel(channel_id, init_config)
|
|
106
|
+
if channel:
|
|
107
|
+
channels.append(channel)
|
|
108
|
+
logger.info(f"Loaded control channel: {channel_id}")
|
|
109
|
+
|
|
110
|
+
# If no channels configured, use defaults
|
|
111
|
+
if not config.channels:
|
|
112
|
+
channels = load_default_channels()
|
|
113
|
+
|
|
114
|
+
return channels
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def load_default_channels(procedure_id: Optional[str] = None) -> List[ControlChannel]:
|
|
118
|
+
"""
|
|
119
|
+
Load default control channels based on context.
|
|
120
|
+
|
|
121
|
+
By default:
|
|
122
|
+
- CLI channel is enabled if stdin is a tty (interactive terminal)
|
|
123
|
+
- IPC channel is always enabled (allows control CLI to connect)
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
procedure_id: Optional procedure ID for IPC socket path
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
List of enabled ControlChannel instances
|
|
130
|
+
"""
|
|
131
|
+
channels: List[ControlChannel] = []
|
|
132
|
+
|
|
133
|
+
# CLI channel - auto-detect based on tty
|
|
134
|
+
if sys.stdin.isatty():
|
|
135
|
+
from tactus.adapters.channels.cli import CLIControlChannel
|
|
136
|
+
|
|
137
|
+
channels.append(CLIControlChannel())
|
|
138
|
+
logger.info("Loaded CLI control channel (auto-detected tty)")
|
|
139
|
+
|
|
140
|
+
# IPC channel - always enabled for control CLI connectivity
|
|
141
|
+
from tactus.adapters.channels.ipc import IPCControlChannel
|
|
142
|
+
|
|
143
|
+
channels.append(IPCControlChannel(procedure_id=procedure_id))
|
|
144
|
+
logger.info("Loaded IPC control channel")
|
|
145
|
+
|
|
146
|
+
return channels
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
__all__ = [
|
|
150
|
+
"load_channel",
|
|
151
|
+
"load_channels_from_config",
|
|
152
|
+
"load_default_channels",
|
|
153
|
+
]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base classes for control channel implementations.
|
|
3
|
+
|
|
4
|
+
Provides InProcessChannel for channels that work with asyncio in-process.
|
|
5
|
+
A DaemonChannel base class may be added later if needed for channels
|
|
6
|
+
requiring separate processes (e.g., Discord WebSocket gateway).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from typing import AsyncIterator
|
|
13
|
+
|
|
14
|
+
from tactus.protocols.control import (
|
|
15
|
+
ControlRequest,
|
|
16
|
+
ControlResponse,
|
|
17
|
+
ChannelCapabilities,
|
|
18
|
+
DeliveryResult,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InProcessChannel(ABC):
|
|
25
|
+
"""
|
|
26
|
+
Base class for control channels that run in-process with asyncio.
|
|
27
|
+
|
|
28
|
+
Suitable for:
|
|
29
|
+
- Host app channels (CLI, IDE server, Jupyter)
|
|
30
|
+
- HTTP webhooks (Slack, Teams)
|
|
31
|
+
- Queue polling (SQS, Redis)
|
|
32
|
+
- Email (SMTP send)
|
|
33
|
+
- WebSocket clients (Tactus Cloud)
|
|
34
|
+
|
|
35
|
+
These channels coexist with the asyncio event loop and don't need
|
|
36
|
+
separate processes.
|
|
37
|
+
|
|
38
|
+
Subclasses must implement:
|
|
39
|
+
- channel_id property
|
|
40
|
+
- capabilities property
|
|
41
|
+
- send() method
|
|
42
|
+
|
|
43
|
+
Default implementations provided for:
|
|
44
|
+
- initialize() - no-op
|
|
45
|
+
- receive() - yields from internal response queue
|
|
46
|
+
- cancel() - no-op
|
|
47
|
+
- shutdown() - no-op
|
|
48
|
+
|
|
49
|
+
The response queue pattern:
|
|
50
|
+
- External handlers (webhooks, stdin readers, etc.) call push_response()
|
|
51
|
+
- receive() yields from the queue
|
|
52
|
+
- This bridges sync/async boundaries cleanly
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self):
|
|
56
|
+
"""Initialize the channel with an internal response queue."""
|
|
57
|
+
self._response_queue: asyncio.Queue[ControlResponse] = asyncio.Queue()
|
|
58
|
+
self._shutdown_event = asyncio.Event()
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def channel_id(self) -> str:
|
|
63
|
+
"""Unique identifier for this channel."""
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def capabilities(self) -> ChannelCapabilities:
|
|
69
|
+
"""Return channel capabilities."""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
async def initialize(self) -> None:
|
|
73
|
+
"""
|
|
74
|
+
Initialize the channel.
|
|
75
|
+
|
|
76
|
+
Default: no-op. Override for auth handshakes, connections, etc.
|
|
77
|
+
"""
|
|
78
|
+
logger.info(f"{self.channel_id}: initializing...")
|
|
79
|
+
logger.info(f"{self.channel_id}: ready")
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
async def send(self, request: ControlRequest) -> DeliveryResult:
|
|
83
|
+
"""
|
|
84
|
+
Send a control request to this channel.
|
|
85
|
+
|
|
86
|
+
Subclass must implement channel-specific send logic.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
request: ControlRequest with full context
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
DeliveryResult with delivery status
|
|
93
|
+
"""
|
|
94
|
+
...
|
|
95
|
+
|
|
96
|
+
async def receive(self) -> AsyncIterator[ControlResponse]:
|
|
97
|
+
"""
|
|
98
|
+
Yield responses as they arrive from the internal queue.
|
|
99
|
+
|
|
100
|
+
External handlers (webhooks, stdin readers, etc.) call push_response()
|
|
101
|
+
to add responses to the queue.
|
|
102
|
+
|
|
103
|
+
Override for polling-based channels that need custom receive logic.
|
|
104
|
+
|
|
105
|
+
Yields:
|
|
106
|
+
ControlResponse as they are received
|
|
107
|
+
"""
|
|
108
|
+
while not self._shutdown_event.is_set():
|
|
109
|
+
try:
|
|
110
|
+
# Use wait_for with timeout to check shutdown periodically
|
|
111
|
+
response = await asyncio.wait_for(
|
|
112
|
+
self._response_queue.get(),
|
|
113
|
+
timeout=0.5,
|
|
114
|
+
)
|
|
115
|
+
logger.info(f"{self.channel_id}: received response for {response.request_id}")
|
|
116
|
+
yield response
|
|
117
|
+
except asyncio.TimeoutError:
|
|
118
|
+
continue
|
|
119
|
+
except asyncio.CancelledError:
|
|
120
|
+
break
|
|
121
|
+
|
|
122
|
+
async def cancel(self, external_message_id: str, reason: str) -> None:
|
|
123
|
+
"""
|
|
124
|
+
Cancel or update a request when resolved via another channel.
|
|
125
|
+
|
|
126
|
+
Default: no-op. Override for channels that support message updates
|
|
127
|
+
(e.g., editing Slack messages, updating UI).
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
external_message_id: Channel-specific message ID
|
|
131
|
+
reason: Reason for cancellation
|
|
132
|
+
"""
|
|
133
|
+
logger.debug(f"{self.channel_id}: cancelling {external_message_id}: {reason}")
|
|
134
|
+
|
|
135
|
+
async def shutdown(self) -> None:
|
|
136
|
+
"""
|
|
137
|
+
Clean shutdown of the channel.
|
|
138
|
+
|
|
139
|
+
Default: sets shutdown event to stop receive loop.
|
|
140
|
+
Override for additional cleanup (close connections, etc.).
|
|
141
|
+
"""
|
|
142
|
+
logger.info(f"{self.channel_id}: shutting down")
|
|
143
|
+
self._shutdown_event.set()
|
|
144
|
+
|
|
145
|
+
def push_response(self, response: ControlResponse) -> None:
|
|
146
|
+
"""
|
|
147
|
+
Push a response to the internal queue.
|
|
148
|
+
|
|
149
|
+
Called by external handlers (webhooks, stdin readers, etc.) when
|
|
150
|
+
a response is received. This bridges sync/async boundaries.
|
|
151
|
+
|
|
152
|
+
Thread-safe: can be called from non-async contexts.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
response: ControlResponse to add to queue
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
self._response_queue.put_nowait(response)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
logger.error(f"{self.channel_id}: failed to queue response: {e}")
|
|
161
|
+
|
|
162
|
+
def push_response_threadsafe(
|
|
163
|
+
self, response: ControlResponse, loop: asyncio.AbstractEventLoop
|
|
164
|
+
) -> None:
|
|
165
|
+
"""
|
|
166
|
+
Push a response to the queue from another thread.
|
|
167
|
+
|
|
168
|
+
Use this when calling from a background thread (e.g., stdin reader).
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
response: ControlResponse to add to queue
|
|
172
|
+
loop: The event loop to use for thread-safe call
|
|
173
|
+
"""
|
|
174
|
+
loop.call_soon_threadsafe(self._response_queue.put_nowait, response)
|