tactus 0.34.1__py3-none-any.whl → 0.35.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/broker_log.py +17 -14
- tactus/adapters/channels/__init__.py +17 -15
- tactus/adapters/channels/base.py +16 -7
- tactus/adapters/channels/broker.py +43 -13
- tactus/adapters/channels/cli.py +19 -15
- tactus/adapters/channels/host.py +15 -6
- tactus/adapters/channels/ipc.py +82 -31
- tactus/adapters/channels/sse.py +41 -23
- tactus/adapters/cli_hitl.py +19 -19
- tactus/adapters/cli_log.py +4 -4
- tactus/adapters/control_loop.py +138 -99
- tactus/adapters/cost_collector_log.py +9 -9
- tactus/adapters/file_storage.py +56 -52
- tactus/adapters/http_callback_log.py +23 -13
- tactus/adapters/ide_log.py +17 -9
- tactus/adapters/lua_tools.py +4 -5
- tactus/adapters/mcp.py +16 -19
- tactus/adapters/mcp_manager.py +46 -30
- tactus/adapters/memory.py +9 -9
- tactus/adapters/plugins.py +42 -42
- tactus/broker/client.py +75 -78
- tactus/broker/protocol.py +57 -57
- tactus/broker/server.py +252 -197
- tactus/cli/app.py +3 -1
- tactus/cli/control.py +2 -2
- tactus/core/config_manager.py +181 -135
- tactus/core/dependencies/registry.py +66 -48
- tactus/core/dsl_stubs.py +222 -163
- tactus/core/exceptions.py +10 -1
- tactus/core/execution_context.py +152 -112
- tactus/core/lua_sandbox.py +72 -64
- tactus/core/message_history_manager.py +138 -43
- tactus/core/mocking.py +41 -27
- tactus/core/output_validator.py +49 -44
- tactus/core/registry.py +94 -80
- tactus/core/runtime.py +211 -176
- tactus/core/template_resolver.py +16 -16
- tactus/core/yaml_parser.py +55 -45
- tactus/docs/extractor.py +7 -6
- tactus/ide/server.py +119 -78
- tactus/primitives/control.py +10 -6
- tactus/primitives/file.py +48 -46
- tactus/primitives/handles.py +47 -35
- tactus/primitives/host.py +29 -27
- tactus/primitives/human.py +154 -137
- tactus/primitives/json.py +22 -23
- tactus/primitives/log.py +26 -26
- tactus/primitives/message_history.py +285 -31
- tactus/primitives/model.py +15 -9
- tactus/primitives/procedure.py +86 -64
- tactus/primitives/procedure_callable.py +58 -51
- tactus/primitives/retry.py +31 -29
- tactus/primitives/session.py +42 -29
- tactus/primitives/state.py +54 -43
- tactus/primitives/step.py +9 -13
- tactus/primitives/system.py +34 -21
- tactus/primitives/tool.py +44 -31
- tactus/primitives/tool_handle.py +76 -54
- tactus/primitives/toolset.py +25 -22
- tactus/sandbox/config.py +4 -4
- tactus/sandbox/container_runner.py +161 -107
- tactus/sandbox/docker_manager.py +20 -20
- tactus/sandbox/entrypoint.py +16 -14
- tactus/sandbox/protocol.py +15 -15
- tactus/stdlib/classify/llm.py +1 -3
- tactus/stdlib/core/validation.py +0 -3
- tactus/testing/pydantic_eval_runner.py +1 -1
- tactus/utils/asyncio_helpers.py +27 -0
- tactus/utils/cost_calculator.py +7 -7
- tactus/utils/model_pricing.py +11 -12
- tactus/utils/safe_file_library.py +156 -132
- tactus/utils/safe_libraries.py +27 -27
- tactus/validation/error_listener.py +18 -5
- tactus/validation/semantic_visitor.py +392 -333
- tactus/validation/validator.py +89 -49
- {tactus-0.34.1.dist-info → tactus-0.35.0.dist-info}/METADATA +12 -3
- {tactus-0.34.1.dist-info → tactus-0.35.0.dist-info}/RECORD +81 -80
- {tactus-0.34.1.dist-info → tactus-0.35.0.dist-info}/WHEEL +0 -0
- {tactus-0.34.1.dist-info → tactus-0.35.0.dist-info}/entry_points.txt +0 -0
- {tactus-0.34.1.dist-info → tactus-0.35.0.dist-info}/licenses/LICENSE +0 -0
tactus/__init__.py
CHANGED
tactus/adapters/broker_log.py
CHANGED
|
@@ -12,7 +12,7 @@ import logging
|
|
|
12
12
|
import os
|
|
13
13
|
import queue
|
|
14
14
|
import threading
|
|
15
|
-
from typing import Optional
|
|
15
|
+
from typing import Any, Optional
|
|
16
16
|
|
|
17
17
|
from tactus.protocols.models import LogEvent, CostEvent
|
|
18
18
|
|
|
@@ -35,7 +35,7 @@ class BrokerLogHandler:
|
|
|
35
35
|
self.cost_events: list[CostEvent] = []
|
|
36
36
|
|
|
37
37
|
# Thread-safe queue for events to send
|
|
38
|
-
self._queue: queue.Queue = queue.Queue()
|
|
38
|
+
self._queue: queue.Queue[dict[str, Any]] = queue.Queue()
|
|
39
39
|
|
|
40
40
|
# Background worker thread
|
|
41
41
|
self._worker_thread: Optional[threading.Thread] = None
|
|
@@ -79,31 +79,31 @@ class BrokerLogHandler:
|
|
|
79
79
|
from tactus.broker.client import BrokerClient
|
|
80
80
|
|
|
81
81
|
# Create fresh client in this thread's event loop
|
|
82
|
-
|
|
83
|
-
asyncio.set_event_loop(
|
|
82
|
+
event_loop = asyncio.new_event_loop()
|
|
83
|
+
asyncio.set_event_loop(event_loop)
|
|
84
84
|
client = BrokerClient(self._socket_path)
|
|
85
85
|
|
|
86
86
|
try:
|
|
87
87
|
while not self._shutdown.is_set():
|
|
88
88
|
try:
|
|
89
89
|
# Wait for event with timeout to check shutdown flag
|
|
90
|
-
|
|
90
|
+
event_payload = self._queue.get(timeout=0.05)
|
|
91
91
|
|
|
92
92
|
# Send event to broker
|
|
93
|
-
|
|
93
|
+
event_loop.run_until_complete(client.emit_event(event_payload))
|
|
94
94
|
self._queue.task_done()
|
|
95
95
|
|
|
96
96
|
except queue.Empty:
|
|
97
97
|
continue
|
|
98
|
-
except Exception as
|
|
98
|
+
except Exception as error:
|
|
99
99
|
# Best effort - don't crash worker on individual failures
|
|
100
|
-
logger.debug(
|
|
100
|
+
logger.debug("[BROKER_LOG] Failed to emit event: %s", error)
|
|
101
101
|
try:
|
|
102
102
|
self._queue.task_done()
|
|
103
103
|
except ValueError:
|
|
104
104
|
pass
|
|
105
105
|
finally:
|
|
106
|
-
|
|
106
|
+
event_loop.close()
|
|
107
107
|
logger.debug("[BROKER_LOG] Background worker stopped")
|
|
108
108
|
|
|
109
109
|
def log(self, event: LogEvent) -> None:
|
|
@@ -118,20 +118,23 @@ class BrokerLogHandler:
|
|
|
118
118
|
self.cost_events.append(event)
|
|
119
119
|
|
|
120
120
|
# Serialize to JSON-friendly dict
|
|
121
|
-
|
|
121
|
+
event_payload = event.model_dump(mode="json")
|
|
122
122
|
|
|
123
123
|
# Normalize timestamp formatting for downstream consumers.
|
|
124
124
|
iso_string = event.timestamp.isoformat()
|
|
125
|
-
|
|
125
|
+
has_timezone_marker = (
|
|
126
|
+
iso_string.endswith("Z") or "+" in iso_string or iso_string.count("-") > 2
|
|
127
|
+
)
|
|
128
|
+
if not has_timezone_marker:
|
|
126
129
|
iso_string += "Z"
|
|
127
|
-
|
|
130
|
+
event_payload["timestamp"] = iso_string
|
|
128
131
|
|
|
129
132
|
# Ensure worker is running
|
|
130
133
|
self._ensure_worker_started()
|
|
131
134
|
|
|
132
135
|
# Queue event for background sending (non-blocking)
|
|
133
136
|
try:
|
|
134
|
-
self._queue.put_nowait(
|
|
137
|
+
self._queue.put_nowait(event_payload)
|
|
135
138
|
except queue.Full:
|
|
136
139
|
# Drop event if queue is full (shouldn't happen with unlimited queue)
|
|
137
140
|
logger.debug("[BROKER_LOG] Queue full, dropping event")
|
|
@@ -157,7 +160,7 @@ class BrokerLogHandler:
|
|
|
157
160
|
|
|
158
161
|
if not self._queue.empty():
|
|
159
162
|
remaining = self._queue.qsize()
|
|
160
|
-
logger.warning(
|
|
163
|
+
logger.warning("[BROKER_LOG] Flush timeout with %s events remaining", remaining)
|
|
161
164
|
|
|
162
165
|
# Signal worker to shutdown
|
|
163
166
|
self._shutdown.set()
|
|
@@ -13,7 +13,7 @@ The control loop uses a publish-subscribe pattern with namespace-based routing:
|
|
|
13
13
|
- Subscribers can be observers (read-only) or responders (can provide input)
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
|
-
from typing import
|
|
16
|
+
from typing import Any, Optional
|
|
17
17
|
import logging
|
|
18
18
|
import sys
|
|
19
19
|
|
|
@@ -31,7 +31,7 @@ _CHANNEL_LOADERS = {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
|
|
34
|
-
def load_channel(channel_id: str, config:
|
|
34
|
+
def load_channel(channel_id: str, config: dict[str, Any]) -> Optional[ControlChannel]:
|
|
35
35
|
"""
|
|
36
36
|
Load a control channel by ID.
|
|
37
37
|
|
|
@@ -43,7 +43,7 @@ def load_channel(channel_id: str, config: Dict[str, Any]) -> Optional[ControlCha
|
|
|
43
43
|
ControlChannel instance or None if loading fails
|
|
44
44
|
"""
|
|
45
45
|
if channel_id not in _CHANNEL_LOADERS:
|
|
46
|
-
logger.warning(
|
|
46
|
+
logger.warning("Unknown or not yet implemented channel: %s", channel_id)
|
|
47
47
|
return None
|
|
48
48
|
|
|
49
49
|
module_path = _CHANNEL_LOADERS[channel_id]
|
|
@@ -55,19 +55,21 @@ def load_channel(channel_id: str, config: Dict[str, Any]) -> Optional[ControlCha
|
|
|
55
55
|
module = importlib.import_module(module_name)
|
|
56
56
|
channel_class = getattr(module, class_name)
|
|
57
57
|
return channel_class(**config)
|
|
58
|
-
except ImportError as
|
|
58
|
+
except ImportError as error:
|
|
59
59
|
logger.warning(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
"Failed to load %s channel. Ensure dependencies are installed. Error: %s",
|
|
61
|
+
channel_id,
|
|
62
|
+
error,
|
|
63
63
|
)
|
|
64
64
|
return None
|
|
65
|
-
except Exception as
|
|
66
|
-
logger.exception(
|
|
65
|
+
except Exception as error:
|
|
66
|
+
logger.exception("Failed to initialize %s channel: %s", channel_id, error)
|
|
67
67
|
return None
|
|
68
68
|
|
|
69
69
|
|
|
70
|
-
def load_channels_from_config(
|
|
70
|
+
def load_channels_from_config(
|
|
71
|
+
config: Optional[ControlLoopConfig] = None,
|
|
72
|
+
) -> list[ControlChannel]:
|
|
71
73
|
"""
|
|
72
74
|
Load control channels based on configuration and context.
|
|
73
75
|
|
|
@@ -81,7 +83,7 @@ def load_channels_from_config(config: Optional[ControlLoopConfig] = None) -> Lis
|
|
|
81
83
|
Returns:
|
|
82
84
|
List of enabled ControlChannel instances
|
|
83
85
|
"""
|
|
84
|
-
channels:
|
|
86
|
+
channels: list[ControlChannel] = []
|
|
85
87
|
|
|
86
88
|
if config is None:
|
|
87
89
|
config = ControlLoopConfig()
|
|
@@ -101,11 +103,11 @@ def load_channels_from_config(config: Optional[ControlLoopConfig] = None) -> Lis
|
|
|
101
103
|
continue
|
|
102
104
|
|
|
103
105
|
# Remove 'enabled' from config before passing to constructor
|
|
104
|
-
init_config = {
|
|
106
|
+
init_config = {key: value for key, value in channel_config.items() if key != "enabled"}
|
|
105
107
|
channel = load_channel(channel_id, init_config)
|
|
106
108
|
if channel:
|
|
107
109
|
channels.append(channel)
|
|
108
|
-
logger.info(
|
|
110
|
+
logger.info("Loaded control channel: %s", channel_id)
|
|
109
111
|
|
|
110
112
|
# If no channels configured, use defaults
|
|
111
113
|
if not config.channels:
|
|
@@ -114,7 +116,7 @@ def load_channels_from_config(config: Optional[ControlLoopConfig] = None) -> Lis
|
|
|
114
116
|
return channels
|
|
115
117
|
|
|
116
118
|
|
|
117
|
-
def load_default_channels(procedure_id: Optional[str] = None) ->
|
|
119
|
+
def load_default_channels(procedure_id: Optional[str] = None) -> list[ControlChannel]:
|
|
118
120
|
"""
|
|
119
121
|
Load default control channels based on context.
|
|
120
122
|
|
|
@@ -128,7 +130,7 @@ def load_default_channels(procedure_id: Optional[str] = None) -> List[ControlCha
|
|
|
128
130
|
Returns:
|
|
129
131
|
List of enabled ControlChannel instances
|
|
130
132
|
"""
|
|
131
|
-
channels:
|
|
133
|
+
channels: list[ControlChannel] = []
|
|
132
134
|
|
|
133
135
|
# CLI channel - auto-detect based on tty
|
|
134
136
|
if sys.stdin.isatty():
|
tactus/adapters/channels/base.py
CHANGED
|
@@ -75,8 +75,8 @@ class InProcessChannel(ABC):
|
|
|
75
75
|
|
|
76
76
|
Default: no-op. Override for auth handshakes, connections, etc.
|
|
77
77
|
"""
|
|
78
|
-
logger.info(
|
|
79
|
-
logger.info(
|
|
78
|
+
logger.info("%s: initializing...", self.channel_id)
|
|
79
|
+
logger.info("%s: ready", self.channel_id)
|
|
80
80
|
|
|
81
81
|
@abstractmethod
|
|
82
82
|
async def send(self, request: ControlRequest) -> DeliveryResult:
|
|
@@ -112,7 +112,11 @@ class InProcessChannel(ABC):
|
|
|
112
112
|
self._response_queue.get(),
|
|
113
113
|
timeout=0.5,
|
|
114
114
|
)
|
|
115
|
-
logger.info(
|
|
115
|
+
logger.info(
|
|
116
|
+
"%s: received response for %s",
|
|
117
|
+
self.channel_id,
|
|
118
|
+
response.request_id,
|
|
119
|
+
)
|
|
116
120
|
yield response
|
|
117
121
|
except asyncio.TimeoutError:
|
|
118
122
|
continue
|
|
@@ -130,7 +134,12 @@ class InProcessChannel(ABC):
|
|
|
130
134
|
external_message_id: Channel-specific message ID
|
|
131
135
|
reason: Reason for cancellation
|
|
132
136
|
"""
|
|
133
|
-
logger.debug(
|
|
137
|
+
logger.debug(
|
|
138
|
+
"%s: cancelling %s: %s",
|
|
139
|
+
self.channel_id,
|
|
140
|
+
external_message_id,
|
|
141
|
+
reason,
|
|
142
|
+
)
|
|
134
143
|
|
|
135
144
|
async def shutdown(self) -> None:
|
|
136
145
|
"""
|
|
@@ -139,7 +148,7 @@ class InProcessChannel(ABC):
|
|
|
139
148
|
Default: sets shutdown event to stop receive loop.
|
|
140
149
|
Override for additional cleanup (close connections, etc.).
|
|
141
150
|
"""
|
|
142
|
-
logger.info(
|
|
151
|
+
logger.info("%s: shutting down", self.channel_id)
|
|
143
152
|
self._shutdown_event.set()
|
|
144
153
|
|
|
145
154
|
def push_response(self, response: ControlResponse) -> None:
|
|
@@ -156,8 +165,8 @@ class InProcessChannel(ABC):
|
|
|
156
165
|
"""
|
|
157
166
|
try:
|
|
158
167
|
self._response_queue.put_nowait(response)
|
|
159
|
-
except Exception as
|
|
160
|
-
logger.error(
|
|
168
|
+
except Exception as error:
|
|
169
|
+
logger.error("%s: failed to queue response: %s", self.channel_id, error)
|
|
161
170
|
|
|
162
171
|
def push_response_threadsafe(
|
|
163
172
|
self, response: ControlResponse, loop: asyncio.AbstractEventLoop
|
|
@@ -73,9 +73,9 @@ class BrokerControlChannel(InProcessChannel):
|
|
|
73
73
|
|
|
74
74
|
async def initialize(self) -> None:
|
|
75
75
|
"""Initialize broker control channel (broker already connected)."""
|
|
76
|
-
logger.info(
|
|
76
|
+
logger.info("%s: initializing...", self.channel_id)
|
|
77
77
|
# Broker client already initialized by BrokerLogHandler setup
|
|
78
|
-
logger.info(
|
|
78
|
+
logger.info("%s: ready (via broker)", self.channel_id)
|
|
79
79
|
|
|
80
80
|
async def send(self, request: ControlRequest) -> DeliveryResult:
|
|
81
81
|
"""
|
|
@@ -84,7 +84,11 @@ class BrokerControlChannel(InProcessChannel):
|
|
|
84
84
|
The request is serialized and sent via broker's control.request method.
|
|
85
85
|
The host will relay to its SSE channel and return the response.
|
|
86
86
|
"""
|
|
87
|
-
logger.info(
|
|
87
|
+
logger.info(
|
|
88
|
+
"%s: sending control request %s via broker",
|
|
89
|
+
self.channel_id,
|
|
90
|
+
request.request_id,
|
|
91
|
+
)
|
|
88
92
|
|
|
89
93
|
try:
|
|
90
94
|
# Serialize request to JSON-compatible dict
|
|
@@ -96,7 +100,11 @@ class BrokerControlChannel(InProcessChannel):
|
|
|
96
100
|
|
|
97
101
|
if event_type == "delivered":
|
|
98
102
|
# Request successfully delivered to host channels
|
|
99
|
-
logger.debug(
|
|
103
|
+
logger.debug(
|
|
104
|
+
"%s: request %s delivered",
|
|
105
|
+
self.channel_id,
|
|
106
|
+
request.request_id,
|
|
107
|
+
)
|
|
100
108
|
continue
|
|
101
109
|
|
|
102
110
|
elif event_type == "response":
|
|
@@ -110,13 +118,21 @@ class BrokerControlChannel(InProcessChannel):
|
|
|
110
118
|
channel_id=response_data.get("channel_id", "sse"),
|
|
111
119
|
responder_id=response_data.get("responder_id"),
|
|
112
120
|
)
|
|
113
|
-
logger.info(
|
|
121
|
+
logger.info(
|
|
122
|
+
"%s: received response for %s",
|
|
123
|
+
self.channel_id,
|
|
124
|
+
request.request_id,
|
|
125
|
+
)
|
|
114
126
|
self._response_queue.put_nowait(response)
|
|
115
127
|
break
|
|
116
128
|
|
|
117
129
|
elif event_type == "timeout":
|
|
118
130
|
# Host-side timeout
|
|
119
|
-
logger.warning(
|
|
131
|
+
logger.warning(
|
|
132
|
+
"%s: timeout for %s",
|
|
133
|
+
self.channel_id,
|
|
134
|
+
request.request_id,
|
|
135
|
+
)
|
|
120
136
|
response = ControlResponse(
|
|
121
137
|
request_id=request.request_id,
|
|
122
138
|
value=request.default_value,
|
|
@@ -131,7 +147,12 @@ class BrokerControlChannel(InProcessChannel):
|
|
|
131
147
|
# Delivery or processing error
|
|
132
148
|
error = event.get("error", {})
|
|
133
149
|
error_msg = error.get("message", "Unknown broker error")
|
|
134
|
-
logger.error(
|
|
150
|
+
logger.error(
|
|
151
|
+
"%s: error for %s: %s",
|
|
152
|
+
self.channel_id,
|
|
153
|
+
request.request_id,
|
|
154
|
+
error_msg,
|
|
155
|
+
)
|
|
135
156
|
raise RuntimeError(f"Broker control request failed: {error_msg}")
|
|
136
157
|
|
|
137
158
|
return DeliveryResult(
|
|
@@ -141,14 +162,19 @@ class BrokerControlChannel(InProcessChannel):
|
|
|
141
162
|
success=True,
|
|
142
163
|
)
|
|
143
164
|
|
|
144
|
-
except Exception as
|
|
145
|
-
logger.error(
|
|
165
|
+
except Exception as error:
|
|
166
|
+
logger.error(
|
|
167
|
+
"%s: failed to send %s: %s",
|
|
168
|
+
self.channel_id,
|
|
169
|
+
request.request_id,
|
|
170
|
+
error,
|
|
171
|
+
)
|
|
146
172
|
return DeliveryResult(
|
|
147
173
|
channel_id=self.channel_id,
|
|
148
174
|
external_message_id=request.request_id,
|
|
149
175
|
delivered_at=datetime.now(timezone.utc),
|
|
150
176
|
success=False,
|
|
151
|
-
error_message=str(
|
|
177
|
+
error_message=str(error),
|
|
152
178
|
)
|
|
153
179
|
|
|
154
180
|
@classmethod
|
|
@@ -171,9 +197,13 @@ class BrokerControlChannel(InProcessChannel):
|
|
|
171
197
|
|
|
172
198
|
client = BrokerClient(socket_path)
|
|
173
199
|
logger.info(
|
|
174
|
-
|
|
200
|
+
"BrokerControlChannel: initialized from environment (socket=%s)",
|
|
201
|
+
socket_path,
|
|
175
202
|
)
|
|
176
203
|
return cls(client)
|
|
177
|
-
except Exception as
|
|
178
|
-
logger.warning(
|
|
204
|
+
except Exception as error:
|
|
205
|
+
logger.warning(
|
|
206
|
+
"BrokerControlChannel: failed to initialize from environment: %s",
|
|
207
|
+
error,
|
|
208
|
+
)
|
|
179
209
|
return None
|
tactus/adapters/channels/cli.py
CHANGED
|
@@ -26,10 +26,14 @@ from tactus.adapters.channels.host import HostControlChannel
|
|
|
26
26
|
logger = logging.getLogger(__name__)
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def format_time_ago(
|
|
29
|
+
def format_time_ago(timestamp: datetime) -> str:
|
|
30
30
|
"""Format datetime as human-readable time ago string."""
|
|
31
31
|
now = datetime.now(timezone.utc)
|
|
32
|
-
delta =
|
|
32
|
+
delta = (
|
|
33
|
+
now - timestamp.replace(tzinfo=timezone.utc)
|
|
34
|
+
if timestamp.tzinfo is None
|
|
35
|
+
else now - timestamp
|
|
36
|
+
)
|
|
33
37
|
|
|
34
38
|
seconds = int(delta.total_seconds())
|
|
35
39
|
if seconds < 60:
|
|
@@ -90,11 +94,11 @@ class CLIControlChannel(HostControlChannel):
|
|
|
90
94
|
|
|
91
95
|
async def initialize(self) -> None:
|
|
92
96
|
"""Initialize the CLI channel."""
|
|
93
|
-
logger.info(
|
|
97
|
+
logger.info("%s: initializing...", self.channel_id)
|
|
94
98
|
# Check if stdin is a tty
|
|
95
99
|
if not sys.stdin.isatty():
|
|
96
|
-
logger.warning(
|
|
97
|
-
logger.info(
|
|
100
|
+
logger.warning("%s: stdin is not a tty, prompts may not work", self.channel_id)
|
|
101
|
+
logger.info("%s: ready", self.channel_id)
|
|
98
102
|
|
|
99
103
|
def _display_request(self, request: ControlRequest) -> None:
|
|
100
104
|
"""
|
|
@@ -163,13 +167,13 @@ class CLIControlChannel(HostControlChannel):
|
|
|
163
167
|
request_type = request.request_type
|
|
164
168
|
if request_type == ControlRequestType.APPROVAL:
|
|
165
169
|
return self._handle_approval(request)
|
|
166
|
-
|
|
170
|
+
if request_type == ControlRequestType.INPUT:
|
|
167
171
|
return self._handle_input(request)
|
|
168
|
-
|
|
172
|
+
if request_type == ControlRequestType.REVIEW:
|
|
169
173
|
return self._handle_review(request)
|
|
170
|
-
|
|
174
|
+
if request_type == ControlRequestType.ESCALATION:
|
|
171
175
|
return self._handle_escalation(request)
|
|
172
|
-
|
|
176
|
+
if request_type == ControlRequestType.INPUTS:
|
|
173
177
|
return self._handle_inputs(request)
|
|
174
178
|
else:
|
|
175
179
|
# Default: treat as input
|
|
@@ -212,8 +216,8 @@ class CLIControlChannel(HostControlChannel):
|
|
|
212
216
|
"""Handle options selection."""
|
|
213
217
|
# Display options
|
|
214
218
|
self.console.print("\n[bold]Options:[/bold]")
|
|
215
|
-
for
|
|
216
|
-
self.console.print(f" {
|
|
219
|
+
for index, option in enumerate(options, 1):
|
|
220
|
+
self.console.print(f" {index}. [cyan]{option.label}[/cyan]")
|
|
217
221
|
if option.description:
|
|
218
222
|
self.console.print(f" [dim]{option.description}[/dim]")
|
|
219
223
|
|
|
@@ -309,15 +313,15 @@ class CLIControlChannel(HostControlChannel):
|
|
|
309
313
|
|
|
310
314
|
# Display summary
|
|
311
315
|
self.console.print(f"\n[bold cyan]Collecting {len(items)} inputs:[/bold cyan]")
|
|
312
|
-
for
|
|
316
|
+
for index, item in enumerate(items, 1):
|
|
313
317
|
req_marker = "*" if item.required else ""
|
|
314
|
-
self.console.print(f" {
|
|
318
|
+
self.console.print(f" {index}. [cyan]{item.label}[/cyan]{req_marker}")
|
|
315
319
|
self.console.print()
|
|
316
320
|
|
|
317
321
|
# Collect responses for each item
|
|
318
322
|
responses = {}
|
|
319
323
|
|
|
320
|
-
for
|
|
324
|
+
for index, item in enumerate(items, 1):
|
|
321
325
|
if self.is_cancelled():
|
|
322
326
|
return None
|
|
323
327
|
|
|
@@ -325,7 +329,7 @@ class CLIControlChannel(HostControlChannel):
|
|
|
325
329
|
self.console.print(
|
|
326
330
|
Panel(
|
|
327
331
|
item.message,
|
|
328
|
-
title=f"[bold]{
|
|
332
|
+
title=f"[bold]{index}/{len(items)}: {item.label}[/bold]",
|
|
329
333
|
style="cyan" if item.required else "blue",
|
|
330
334
|
)
|
|
331
335
|
)
|
tactus/adapters/channels/host.py
CHANGED
|
@@ -15,7 +15,7 @@ import asyncio
|
|
|
15
15
|
import logging
|
|
16
16
|
import threading
|
|
17
17
|
from abc import abstractmethod
|
|
18
|
-
from typing import Optional
|
|
18
|
+
from typing import Any, Optional
|
|
19
19
|
from datetime import datetime, timezone
|
|
20
20
|
|
|
21
21
|
from tactus.protocols.control import (
|
|
@@ -84,7 +84,11 @@ class HostControlChannel(InProcessChannel):
|
|
|
84
84
|
Returns:
|
|
85
85
|
DeliveryResult indicating successful delivery
|
|
86
86
|
"""
|
|
87
|
-
logger.info(
|
|
87
|
+
logger.info(
|
|
88
|
+
"%s: sending notification for %s",
|
|
89
|
+
self.channel_id,
|
|
90
|
+
request.request_id,
|
|
91
|
+
)
|
|
88
92
|
|
|
89
93
|
# Store for background thread access
|
|
90
94
|
self._current_request = request
|
|
@@ -122,7 +126,12 @@ class HostControlChannel(InProcessChannel):
|
|
|
122
126
|
external_message_id: Request ID (same as sent)
|
|
123
127
|
reason: Reason for cancellation (e.g., "Responded via tactus_cloud")
|
|
124
128
|
"""
|
|
125
|
-
logger.debug(
|
|
129
|
+
logger.debug(
|
|
130
|
+
"%s: cancelling %s: %s",
|
|
131
|
+
self.channel_id,
|
|
132
|
+
external_message_id,
|
|
133
|
+
reason,
|
|
134
|
+
)
|
|
126
135
|
self._cancel_event.set()
|
|
127
136
|
self._show_cancelled(reason)
|
|
128
137
|
|
|
@@ -167,9 +176,9 @@ class HostControlChannel(InProcessChannel):
|
|
|
167
176
|
else:
|
|
168
177
|
self.push_response(response)
|
|
169
178
|
|
|
170
|
-
except Exception as
|
|
179
|
+
except Exception as error:
|
|
171
180
|
if not self._cancel_event.is_set():
|
|
172
|
-
logger.error(
|
|
181
|
+
logger.error("%s: input error: %s", self.channel_id, error)
|
|
173
182
|
|
|
174
183
|
@abstractmethod
|
|
175
184
|
def _display_request(self, request: ControlRequest) -> None:
|
|
@@ -185,7 +194,7 @@ class HostControlChannel(InProcessChannel):
|
|
|
185
194
|
...
|
|
186
195
|
|
|
187
196
|
@abstractmethod
|
|
188
|
-
def _prompt_for_input(self, request: ControlRequest) -> Optional[
|
|
197
|
+
def _prompt_for_input(self, request: ControlRequest) -> Optional[Any]:
|
|
189
198
|
"""
|
|
190
199
|
Collect input from the user.
|
|
191
200
|
|