tactus 0.34.0__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.0.dist-info → tactus-0.35.0.dist-info}/METADATA +12 -3
- {tactus-0.34.0.dist-info → tactus-0.35.0.dist-info}/RECORD +81 -80
- {tactus-0.34.0.dist-info → tactus-0.35.0.dist-info}/WHEEL +0 -0
- {tactus-0.34.0.dist-info → tactus-0.35.0.dist-info}/entry_points.txt +0 -0
- {tactus-0.34.0.dist-info → tactus-0.35.0.dist-info}/licenses/LICENSE +0 -0
tactus/adapters/channels/ipc.py
CHANGED
|
@@ -10,7 +10,7 @@ import logging
|
|
|
10
10
|
import os
|
|
11
11
|
import uuid
|
|
12
12
|
from datetime import datetime
|
|
13
|
-
from typing import
|
|
13
|
+
from typing import Optional
|
|
14
14
|
|
|
15
15
|
from tactus.broker.protocol import read_message, write_message
|
|
16
16
|
from tactus.protocols.control import (
|
|
@@ -45,9 +45,9 @@ class IPCControlChannel:
|
|
|
45
45
|
self.channel_id = "ipc"
|
|
46
46
|
|
|
47
47
|
self._server: Optional[asyncio.Server] = None
|
|
48
|
-
self._clients:
|
|
48
|
+
self._clients: dict[str, asyncio.StreamWriter] = {} # client_id -> writer
|
|
49
49
|
self._response_queue: asyncio.Queue[ControlResponse] = asyncio.Queue()
|
|
50
|
-
self._pending_requests:
|
|
50
|
+
self._pending_requests: dict[str, ControlRequest] = {} # request_id -> request
|
|
51
51
|
self._initialized = False
|
|
52
52
|
|
|
53
53
|
@property
|
|
@@ -67,7 +67,7 @@ class IPCControlChannel:
|
|
|
67
67
|
if self._initialized:
|
|
68
68
|
return
|
|
69
69
|
|
|
70
|
-
logger.info(
|
|
70
|
+
logger.info("%s: initializing...", self.channel_id)
|
|
71
71
|
|
|
72
72
|
# Remove old socket file if it exists
|
|
73
73
|
if os.path.exists(self.socket_path):
|
|
@@ -84,7 +84,11 @@ class IPCControlChannel:
|
|
|
84
84
|
os.chmod(self.socket_path, 0o600)
|
|
85
85
|
|
|
86
86
|
self._initialized = True
|
|
87
|
-
logger.info(
|
|
87
|
+
logger.info(
|
|
88
|
+
"%s: ready (listening on %s)",
|
|
89
|
+
self.channel_id,
|
|
90
|
+
self.socket_path,
|
|
91
|
+
)
|
|
88
92
|
|
|
89
93
|
async def send(self, request: ControlRequest) -> DeliveryResult:
|
|
90
94
|
"""
|
|
@@ -96,10 +100,14 @@ class IPCControlChannel:
|
|
|
96
100
|
Returns:
|
|
97
101
|
DeliveryResult with success/failure info
|
|
98
102
|
"""
|
|
99
|
-
logger.info(
|
|
103
|
+
logger.info(
|
|
104
|
+
"%s: sending notification for %s",
|
|
105
|
+
self.channel_id,
|
|
106
|
+
request.request_id,
|
|
107
|
+
)
|
|
100
108
|
|
|
101
109
|
# Create control request message from ControlRequest object
|
|
102
|
-
|
|
110
|
+
request_payload = {
|
|
103
111
|
"type": "control.request",
|
|
104
112
|
"request_id": request.request_id,
|
|
105
113
|
"procedure_id": request.procedure_id,
|
|
@@ -120,7 +128,7 @@ class IPCControlChannel:
|
|
|
120
128
|
}
|
|
121
129
|
|
|
122
130
|
# Store pending request
|
|
123
|
-
self._pending_requests[request.request_id] =
|
|
131
|
+
self._pending_requests[request.request_id] = request_payload
|
|
124
132
|
|
|
125
133
|
# Send to all connected clients
|
|
126
134
|
successful = 0
|
|
@@ -128,16 +136,21 @@ class IPCControlChannel:
|
|
|
128
136
|
|
|
129
137
|
for client_id, writer in list(self._clients.items()):
|
|
130
138
|
try:
|
|
131
|
-
await write_message(writer,
|
|
139
|
+
await write_message(writer, request_payload)
|
|
132
140
|
successful += 1
|
|
133
|
-
except Exception as
|
|
134
|
-
logger.error(
|
|
141
|
+
except Exception as error:
|
|
142
|
+
logger.error(
|
|
143
|
+
"%s: failed to send to client %s: %s",
|
|
144
|
+
self.channel_id,
|
|
145
|
+
client_id,
|
|
146
|
+
error,
|
|
147
|
+
)
|
|
135
148
|
failed += 1
|
|
136
149
|
# Remove dead client
|
|
137
150
|
self._clients.pop(client_id, None)
|
|
138
151
|
|
|
139
152
|
if successful == 0 and len(self._clients) == 0:
|
|
140
|
-
logger.warning(
|
|
153
|
+
logger.warning("%s: no clients connected", self.channel_id)
|
|
141
154
|
|
|
142
155
|
# Return DeliveryResult
|
|
143
156
|
return DeliveryResult(
|
|
@@ -157,7 +170,11 @@ class IPCControlChannel:
|
|
|
157
170
|
"""
|
|
158
171
|
while True:
|
|
159
172
|
response = await self._response_queue.get()
|
|
160
|
-
logger.info(
|
|
173
|
+
logger.info(
|
|
174
|
+
"%s: received response for %s",
|
|
175
|
+
self.channel_id,
|
|
176
|
+
response.request_id,
|
|
177
|
+
)
|
|
161
178
|
yield response
|
|
162
179
|
|
|
163
180
|
async def cancel(self, request_id: str, reason: str) -> None:
|
|
@@ -168,7 +185,12 @@ class IPCControlChannel:
|
|
|
168
185
|
request_id: Request to cancel
|
|
169
186
|
reason: Cancellation reason
|
|
170
187
|
"""
|
|
171
|
-
logger.debug(
|
|
188
|
+
logger.debug(
|
|
189
|
+
"%s: cancelling %s (%s)",
|
|
190
|
+
self.channel_id,
|
|
191
|
+
request_id,
|
|
192
|
+
reason,
|
|
193
|
+
)
|
|
172
194
|
|
|
173
195
|
# Remove from pending
|
|
174
196
|
self._pending_requests.pop(request_id, None)
|
|
@@ -179,20 +201,30 @@ class IPCControlChannel:
|
|
|
179
201
|
for client_id, writer in list(self._clients.items()):
|
|
180
202
|
try:
|
|
181
203
|
await write_message(writer, cancel_message)
|
|
182
|
-
except Exception as
|
|
183
|
-
logger.error(
|
|
204
|
+
except Exception as error:
|
|
205
|
+
logger.error(
|
|
206
|
+
"%s: failed to send cancellation to %s: %s",
|
|
207
|
+
self.channel_id,
|
|
208
|
+
client_id,
|
|
209
|
+
error,
|
|
210
|
+
)
|
|
184
211
|
|
|
185
212
|
async def shutdown(self) -> None:
|
|
186
213
|
"""Clean up and close server."""
|
|
187
|
-
logger.info(
|
|
214
|
+
logger.info("%s: shutting down", self.channel_id)
|
|
188
215
|
|
|
189
216
|
# Close all client connections
|
|
190
217
|
for client_id, writer in list(self._clients.items()):
|
|
191
218
|
try:
|
|
192
219
|
writer.close()
|
|
193
220
|
await writer.wait_closed()
|
|
194
|
-
except Exception as
|
|
195
|
-
logger.error(
|
|
221
|
+
except Exception as error:
|
|
222
|
+
logger.error(
|
|
223
|
+
"%s: error closing client %s: %s",
|
|
224
|
+
self.channel_id,
|
|
225
|
+
client_id,
|
|
226
|
+
error,
|
|
227
|
+
)
|
|
196
228
|
|
|
197
229
|
self._clients.clear()
|
|
198
230
|
|
|
@@ -205,8 +237,12 @@ class IPCControlChannel:
|
|
|
205
237
|
if os.path.exists(self.socket_path):
|
|
206
238
|
try:
|
|
207
239
|
os.unlink(self.socket_path)
|
|
208
|
-
except Exception as
|
|
209
|
-
logger.error(
|
|
240
|
+
except Exception as error:
|
|
241
|
+
logger.error(
|
|
242
|
+
"%s: failed to remove socket file: %s",
|
|
243
|
+
self.channel_id,
|
|
244
|
+
error,
|
|
245
|
+
)
|
|
210
246
|
|
|
211
247
|
self._initialized = False
|
|
212
248
|
|
|
@@ -222,7 +258,7 @@ class IPCControlChannel:
|
|
|
222
258
|
"""
|
|
223
259
|
client_id = str(uuid.uuid4())[:8]
|
|
224
260
|
|
|
225
|
-
logger.info(
|
|
261
|
+
logger.info("%s: client connected (%s)", self.channel_id, client_id)
|
|
226
262
|
|
|
227
263
|
# Register client
|
|
228
264
|
self._clients[client_id] = writer
|
|
@@ -232,19 +268,22 @@ class IPCControlChannel:
|
|
|
232
268
|
for request_id, request_data in self._pending_requests.items():
|
|
233
269
|
try:
|
|
234
270
|
await write_message(writer, request_data)
|
|
235
|
-
except Exception as
|
|
271
|
+
except Exception as error:
|
|
236
272
|
logger.error(
|
|
237
|
-
|
|
273
|
+
"%s: failed to send pending request to %s: %s",
|
|
274
|
+
self.channel_id,
|
|
275
|
+
client_id,
|
|
276
|
+
error,
|
|
238
277
|
)
|
|
239
278
|
|
|
240
279
|
# Read messages from client
|
|
241
280
|
while True:
|
|
242
281
|
try:
|
|
243
282
|
message = await read_message(reader)
|
|
244
|
-
except EOFError:
|
|
245
|
-
break
|
|
246
283
|
except asyncio.IncompleteReadError:
|
|
247
284
|
break
|
|
285
|
+
except EOFError:
|
|
286
|
+
break
|
|
248
287
|
|
|
249
288
|
# Handle message
|
|
250
289
|
msg_type = message.get("type")
|
|
@@ -264,7 +303,11 @@ class IPCControlChannel:
|
|
|
264
303
|
channel_id=self.channel_id,
|
|
265
304
|
)
|
|
266
305
|
await self._response_queue.put(response)
|
|
267
|
-
logger.info(
|
|
306
|
+
logger.info(
|
|
307
|
+
"%s: received response for %s",
|
|
308
|
+
self.channel_id,
|
|
309
|
+
response.request_id,
|
|
310
|
+
)
|
|
268
311
|
|
|
269
312
|
# Remove from pending
|
|
270
313
|
self._pending_requests.pop(response.request_id, None)
|
|
@@ -279,16 +322,24 @@ class IPCControlChannel:
|
|
|
279
322
|
|
|
280
323
|
else:
|
|
281
324
|
logger.warning(
|
|
282
|
-
|
|
325
|
+
"%s: unknown message type from %s: %s",
|
|
326
|
+
self.channel_id,
|
|
327
|
+
client_id,
|
|
328
|
+
msg_type,
|
|
283
329
|
)
|
|
284
330
|
|
|
285
|
-
except Exception as
|
|
286
|
-
logger.error(
|
|
331
|
+
except Exception as error:
|
|
332
|
+
logger.error(
|
|
333
|
+
"%s: error handling client %s: %s",
|
|
334
|
+
self.channel_id,
|
|
335
|
+
client_id,
|
|
336
|
+
error,
|
|
337
|
+
)
|
|
287
338
|
|
|
288
339
|
finally:
|
|
289
340
|
# Clean up
|
|
290
341
|
self._clients.pop(client_id, None)
|
|
291
|
-
logger.info(
|
|
342
|
+
logger.info("%s: client disconnected (%s)", self.channel_id, client_id)
|
|
292
343
|
|
|
293
344
|
try:
|
|
294
345
|
writer.close()
|
tactus/adapters/channels/sse.py
CHANGED
|
@@ -8,7 +8,7 @@ and receives responses via HTTP POST callbacks.
|
|
|
8
8
|
import asyncio
|
|
9
9
|
import logging
|
|
10
10
|
import queue
|
|
11
|
-
from typing import
|
|
11
|
+
from typing import Any, Optional
|
|
12
12
|
from datetime import datetime, timezone
|
|
13
13
|
|
|
14
14
|
from tactus.adapters.channels.base import InProcessChannel
|
|
@@ -46,7 +46,7 @@ class SSEControlChannel(InProcessChannel):
|
|
|
46
46
|
super().__init__()
|
|
47
47
|
self._event_emitter = event_emitter
|
|
48
48
|
# Use thread-safe queue.Queue for sync access from Flask SSE stream
|
|
49
|
-
self._event_queue: queue.Queue[dict] = queue.Queue()
|
|
49
|
+
self._event_queue: queue.Queue[dict[str, Any]] = queue.Queue()
|
|
50
50
|
|
|
51
51
|
@property
|
|
52
52
|
def channel_id(self) -> str:
|
|
@@ -68,9 +68,9 @@ class SSEControlChannel(InProcessChannel):
|
|
|
68
68
|
|
|
69
69
|
async def initialize(self) -> None:
|
|
70
70
|
"""Initialize SSE channel (no-op, Flask SSE already running)."""
|
|
71
|
-
logger.info(
|
|
71
|
+
logger.info("%s: initializing...", self.channel_id)
|
|
72
72
|
# No auth or connection needed - Flask SSE already set up
|
|
73
|
-
logger.info(
|
|
73
|
+
logger.info("%s: ready", self.channel_id)
|
|
74
74
|
|
|
75
75
|
async def send(self, request: ControlRequest) -> DeliveryResult:
|
|
76
76
|
"""
|
|
@@ -78,18 +78,22 @@ class SSEControlChannel(InProcessChannel):
|
|
|
78
78
|
|
|
79
79
|
Creates a hitl.request event with rich context and pushes to SSE stream.
|
|
80
80
|
"""
|
|
81
|
-
logger.info(
|
|
81
|
+
logger.info(
|
|
82
|
+
"%s: sending notification for %s",
|
|
83
|
+
self.channel_id,
|
|
84
|
+
request.request_id,
|
|
85
|
+
)
|
|
82
86
|
|
|
83
87
|
try:
|
|
84
88
|
# Build SSE event payload
|
|
85
|
-
|
|
89
|
+
event_payload = self._build_hitl_event(request)
|
|
86
90
|
|
|
87
91
|
# Emit event to SSE stream
|
|
88
92
|
if self._event_emitter:
|
|
89
|
-
await self._event_emitter(
|
|
93
|
+
await self._event_emitter(event_payload)
|
|
90
94
|
else:
|
|
91
95
|
# Queue for external consumption if no emitter (thread-safe)
|
|
92
|
-
self._event_queue.put(
|
|
96
|
+
self._event_queue.put(event_payload)
|
|
93
97
|
|
|
94
98
|
return DeliveryResult(
|
|
95
99
|
channel_id=self.channel_id,
|
|
@@ -98,14 +102,18 @@ class SSEControlChannel(InProcessChannel):
|
|
|
98
102
|
success=True,
|
|
99
103
|
)
|
|
100
104
|
|
|
101
|
-
except Exception as
|
|
102
|
-
logger.error(
|
|
105
|
+
except Exception as error:
|
|
106
|
+
logger.error(
|
|
107
|
+
"%s: failed to send notification: %s",
|
|
108
|
+
self.channel_id,
|
|
109
|
+
error,
|
|
110
|
+
)
|
|
103
111
|
return DeliveryResult(
|
|
104
112
|
channel_id=self.channel_id,
|
|
105
113
|
external_message_id=request.request_id,
|
|
106
114
|
delivered_at=datetime.now(timezone.utc),
|
|
107
115
|
success=False,
|
|
108
|
-
error_message=str(
|
|
116
|
+
error_message=str(error),
|
|
109
117
|
)
|
|
110
118
|
|
|
111
119
|
def _build_hitl_event(self, request: ControlRequest) -> dict:
|
|
@@ -114,7 +122,7 @@ class SSEControlChannel(InProcessChannel):
|
|
|
114
122
|
|
|
115
123
|
Returns dict that will be serialized to JSON and sent as SSE event.
|
|
116
124
|
"""
|
|
117
|
-
|
|
125
|
+
event_payload = {
|
|
118
126
|
"event_type": "hitl.request", # Frontend expects event_type, not type
|
|
119
127
|
"request_id": request.request_id,
|
|
120
128
|
# Identity
|
|
@@ -195,7 +203,7 @@ class SSEControlChannel(InProcessChannel):
|
|
|
195
203
|
"metadata": request.metadata,
|
|
196
204
|
}
|
|
197
205
|
|
|
198
|
-
return
|
|
206
|
+
return event_payload
|
|
199
207
|
|
|
200
208
|
def _serialize_runtime_context(self, runtime_context) -> Optional[dict]:
|
|
201
209
|
"""Serialize RuntimeContext to dict for SSE payload."""
|
|
@@ -238,7 +246,7 @@ class SSEControlChannel(InProcessChannel):
|
|
|
238
246
|
request_id: The request being responded to
|
|
239
247
|
value: The response value from the IDE
|
|
240
248
|
"""
|
|
241
|
-
logger.info(
|
|
249
|
+
logger.info("%s: received response for %s", self.channel_id, request_id)
|
|
242
250
|
|
|
243
251
|
response = ControlResponse(
|
|
244
252
|
request_id=request_id,
|
|
@@ -251,15 +259,20 @@ class SSEControlChannel(InProcessChannel):
|
|
|
251
259
|
# Push to queue from sync context (Flask thread)
|
|
252
260
|
# Get the running event loop and schedule the put operation
|
|
253
261
|
try:
|
|
254
|
-
|
|
255
|
-
if
|
|
262
|
+
event_loop = asyncio.get_event_loop()
|
|
263
|
+
if event_loop.is_running():
|
|
256
264
|
# Schedule the coroutine in the running loop
|
|
257
|
-
asyncio.run_coroutine_threadsafe(self._response_queue.put(response),
|
|
265
|
+
asyncio.run_coroutine_threadsafe(self._response_queue.put(response), event_loop)
|
|
258
266
|
else:
|
|
259
267
|
# If no loop is running, use put_nowait (shouldn't happen)
|
|
260
268
|
self._response_queue.put_nowait(response)
|
|
261
|
-
except Exception as
|
|
262
|
-
logger.error(
|
|
269
|
+
except Exception as error:
|
|
270
|
+
logger.error(
|
|
271
|
+
"%s: failed to queue response for %s: %s",
|
|
272
|
+
self.channel_id,
|
|
273
|
+
request_id,
|
|
274
|
+
error,
|
|
275
|
+
)
|
|
263
276
|
|
|
264
277
|
def get_next_event(self, timeout: float = 0.001) -> Optional[dict]:
|
|
265
278
|
"""
|
|
@@ -275,8 +288,8 @@ class SSEControlChannel(InProcessChannel):
|
|
|
275
288
|
Event dict or None if queue is empty
|
|
276
289
|
"""
|
|
277
290
|
try:
|
|
278
|
-
|
|
279
|
-
return
|
|
291
|
+
event_payload = self._event_queue.get(timeout=timeout)
|
|
292
|
+
return event_payload
|
|
280
293
|
except queue.Empty:
|
|
281
294
|
return None
|
|
282
295
|
|
|
@@ -286,7 +299,12 @@ class SSEControlChannel(InProcessChannel):
|
|
|
286
299
|
|
|
287
300
|
Sends a hitl.cancel event to dismiss the prompt.
|
|
288
301
|
"""
|
|
289
|
-
logger.debug(
|
|
302
|
+
logger.debug(
|
|
303
|
+
"%s: cancelling %s: %s",
|
|
304
|
+
self.channel_id,
|
|
305
|
+
external_message_id,
|
|
306
|
+
reason,
|
|
307
|
+
)
|
|
290
308
|
|
|
291
309
|
cancel_event = {
|
|
292
310
|
"event_type": "hitl.cancel", # Frontend expects event_type, not type
|
|
@@ -301,5 +319,5 @@ class SSEControlChannel(InProcessChannel):
|
|
|
301
319
|
|
|
302
320
|
async def shutdown(self) -> None:
|
|
303
321
|
"""Shutdown SSE channel."""
|
|
304
|
-
logger.info(
|
|
322
|
+
logger.info("%s: shutting down", self.channel_id)
|
|
305
323
|
self._shutdown_event.set()
|
tactus/adapters/cli_hitl.py
CHANGED
|
@@ -55,7 +55,7 @@ class CLIHITLHandler:
|
|
|
55
55
|
Returns:
|
|
56
56
|
HITLResponse with user's response
|
|
57
57
|
"""
|
|
58
|
-
logger.debug(
|
|
58
|
+
logger.debug("HITL request: %s - %s", request.request_type, request.message)
|
|
59
59
|
|
|
60
60
|
# Display the request in a panel
|
|
61
61
|
self.console.print()
|
|
@@ -101,10 +101,10 @@ class CLIHITLHandler:
|
|
|
101
101
|
if request.options:
|
|
102
102
|
# Display options
|
|
103
103
|
self.console.print("\n[bold]Options:[/bold]")
|
|
104
|
-
for
|
|
105
|
-
label = option.get("label", f"Option {
|
|
104
|
+
for index, option in enumerate(request.options, 1):
|
|
105
|
+
label = option.get("label", f"Option {index}")
|
|
106
106
|
description = option.get("description", "")
|
|
107
|
-
self.console.print(f" {
|
|
107
|
+
self.console.print(f" {index}. [cyan]{label}[/cyan]")
|
|
108
108
|
if description:
|
|
109
109
|
self.console.print(f" [dim]{description}[/dim]")
|
|
110
110
|
|
|
@@ -195,19 +195,19 @@ class CLIHITLHandler:
|
|
|
195
195
|
|
|
196
196
|
# Display summary
|
|
197
197
|
self.console.print(f"\n[bold cyan]Collecting {len(items)} inputs:[/bold cyan]")
|
|
198
|
-
for
|
|
199
|
-
label = item.get("label", f"Item {
|
|
198
|
+
for index, item in enumerate(items, 1):
|
|
199
|
+
label = item.get("label", f"Item {index}")
|
|
200
200
|
required = item.get("required", True)
|
|
201
201
|
req_marker = "*" if required else ""
|
|
202
|
-
self.console.print(f" {
|
|
202
|
+
self.console.print(f" {index}. [cyan]{label}[/cyan]{req_marker}")
|
|
203
203
|
self.console.print()
|
|
204
204
|
|
|
205
205
|
# Collect responses for each item
|
|
206
206
|
responses = {}
|
|
207
207
|
|
|
208
|
-
for
|
|
208
|
+
for index, item in enumerate(items, 1):
|
|
209
209
|
item_id = item.get("item_id")
|
|
210
|
-
label = item.get("label", f"Item {
|
|
210
|
+
label = item.get("label", f"Item {index}")
|
|
211
211
|
request_type = item.get("request_type", "input")
|
|
212
212
|
message = item.get("message", "")
|
|
213
213
|
required = item.get("required", True)
|
|
@@ -219,7 +219,7 @@ class CLIHITLHandler:
|
|
|
219
219
|
self.console.print(
|
|
220
220
|
Panel(
|
|
221
221
|
message,
|
|
222
|
-
title=f"[bold]{
|
|
222
|
+
title=f"[bold]{index}/{len(items)}: {label}[/bold]",
|
|
223
223
|
style="cyan" if required else "blue",
|
|
224
224
|
)
|
|
225
225
|
)
|
|
@@ -237,13 +237,13 @@ class CLIHITLHandler:
|
|
|
237
237
|
self.console.print(
|
|
238
238
|
"\n[bold]Select multiple options (comma-separated numbers):[/bold]"
|
|
239
239
|
)
|
|
240
|
-
for
|
|
240
|
+
for index, option in enumerate(options, 1):
|
|
241
241
|
label_text = (
|
|
242
|
-
option.get("label", f"Option {
|
|
242
|
+
option.get("label", f"Option {index}")
|
|
243
243
|
if isinstance(option, dict)
|
|
244
244
|
else option
|
|
245
245
|
)
|
|
246
|
-
self.console.print(f" {
|
|
246
|
+
self.console.print(f" {index}. [cyan]{label_text}[/cyan]")
|
|
247
247
|
|
|
248
248
|
min_selections = metadata.get("min", 0)
|
|
249
249
|
max_selections = metadata.get("max", len(options))
|
|
@@ -288,15 +288,15 @@ class CLIHITLHandler:
|
|
|
288
288
|
else:
|
|
289
289
|
# Single selection
|
|
290
290
|
self.console.print("\n[bold]Options:[/bold]")
|
|
291
|
-
for
|
|
291
|
+
for index, option in enumerate(options, 1):
|
|
292
292
|
if isinstance(option, dict):
|
|
293
|
-
label_text = option.get("label", f"Option {
|
|
293
|
+
label_text = option.get("label", f"Option {index}")
|
|
294
294
|
description = option.get("description", "")
|
|
295
|
-
self.console.print(f" {
|
|
295
|
+
self.console.print(f" {index}. [cyan]{label_text}[/cyan]")
|
|
296
296
|
if description:
|
|
297
297
|
self.console.print(f" [dim]{description}[/dim]")
|
|
298
298
|
else:
|
|
299
|
-
self.console.print(f" {
|
|
299
|
+
self.console.print(f" {index}. [cyan]{option}[/cyan]")
|
|
300
300
|
|
|
301
301
|
while True:
|
|
302
302
|
choice_str = Prompt.ask("Select option (number)", console=self.console)
|
|
@@ -394,7 +394,7 @@ class CLIHITLHandler:
|
|
|
394
394
|
value=responses, responded_at=datetime.now(timezone.utc), timed_out=False
|
|
395
395
|
)
|
|
396
396
|
|
|
397
|
-
def check_pending_response(self, procedure_id: str,
|
|
397
|
+
def check_pending_response(self, procedure_id: str, request_id: str) -> Optional[HITLResponse]:
|
|
398
398
|
"""
|
|
399
399
|
Check for pending response (not used in CLI mode).
|
|
400
400
|
|
|
@@ -402,7 +402,7 @@ class CLIHITLHandler:
|
|
|
402
402
|
"""
|
|
403
403
|
return None
|
|
404
404
|
|
|
405
|
-
def cancel_pending_request(self, procedure_id: str,
|
|
405
|
+
def cancel_pending_request(self, procedure_id: str, request_id: str) -> None:
|
|
406
406
|
"""
|
|
407
407
|
Cancel pending request (not used in CLI mode).
|
|
408
408
|
|
tactus/adapters/cli_log.py
CHANGED
|
@@ -126,12 +126,12 @@ class CLILogHandler:
|
|
|
126
126
|
# Format result if available
|
|
127
127
|
result_str = ""
|
|
128
128
|
if event.tool_result is not None:
|
|
129
|
-
|
|
130
|
-
if len(
|
|
131
|
-
result_str = f"\n Result: {
|
|
129
|
+
result_text = str(event.tool_result)
|
|
130
|
+
if len(result_text) < 60:
|
|
131
|
+
result_str = f"\n Result: {result_text}"
|
|
132
132
|
else:
|
|
133
133
|
# Truncate long results
|
|
134
|
-
result_str = f"\n Result: {
|
|
134
|
+
result_str = f"\n Result: {result_text[:57]}..."
|
|
135
135
|
|
|
136
136
|
duration_str = f" ({event.duration_ms:.0f}ms)" if event.duration_ms else ""
|
|
137
137
|
|