tactus 0.31.0__py3-none-any.whl → 0.34.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tactus/__init__.py +1 -1
- tactus/adapters/__init__.py +18 -1
- tactus/adapters/broker_log.py +127 -34
- tactus/adapters/channels/__init__.py +153 -0
- tactus/adapters/channels/base.py +174 -0
- tactus/adapters/channels/broker.py +179 -0
- tactus/adapters/channels/cli.py +448 -0
- tactus/adapters/channels/host.py +225 -0
- tactus/adapters/channels/ipc.py +297 -0
- tactus/adapters/channels/sse.py +305 -0
- tactus/adapters/cli_hitl.py +223 -1
- tactus/adapters/control_loop.py +879 -0
- tactus/adapters/file_storage.py +35 -2
- tactus/adapters/ide_log.py +7 -1
- tactus/backends/http_backend.py +0 -1
- tactus/broker/client.py +31 -1
- tactus/broker/server.py +416 -92
- tactus/cli/app.py +270 -7
- tactus/cli/control.py +393 -0
- tactus/core/config_manager.py +33 -6
- tactus/core/dsl_stubs.py +102 -18
- tactus/core/execution_context.py +265 -8
- tactus/core/lua_sandbox.py +8 -9
- tactus/core/registry.py +19 -2
- tactus/core/runtime.py +235 -27
- tactus/docker/Dockerfile.pypi +49 -0
- tactus/docs/__init__.py +33 -0
- tactus/docs/extractor.py +326 -0
- tactus/docs/html_renderer.py +72 -0
- tactus/docs/models.py +121 -0
- tactus/docs/templates/base.html +204 -0
- tactus/docs/templates/index.html +58 -0
- tactus/docs/templates/module.html +96 -0
- tactus/dspy/agent.py +403 -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/config_server.py +536 -0
- tactus/ide/server.py +345 -21
- 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.31.0.dist-info → tactus-0.34.1.dist-info}/METADATA +16 -2
- {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/RECORD +101 -49
- {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/WHEEL +0 -0
- {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/entry_points.txt +0 -0
- {tactus-0.31.0.dist-info → tactus-0.34.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SSE Control Channel for IDE integration.
|
|
3
|
+
|
|
4
|
+
Server-Sent Events channel that pushes HITL requests to connected IDEs
|
|
5
|
+
and receives responses via HTTP POST callbacks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import queue
|
|
11
|
+
from typing import Optional, Any
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
|
|
14
|
+
from tactus.adapters.channels.base import InProcessChannel
|
|
15
|
+
from tactus.protocols.control import (
|
|
16
|
+
ControlRequest,
|
|
17
|
+
ControlResponse,
|
|
18
|
+
ChannelCapabilities,
|
|
19
|
+
DeliveryResult,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SSEControlChannel(InProcessChannel):
|
|
26
|
+
"""
|
|
27
|
+
Server-Sent Events channel for IDE HITL integration.
|
|
28
|
+
|
|
29
|
+
Reuses existing Flask SSE infrastructure. Sends hitl.request events
|
|
30
|
+
to connected IDE clients, receives responses via POST /hitl/response.
|
|
31
|
+
|
|
32
|
+
Architecture:
|
|
33
|
+
- send(): Pushes hitl.request event to SSE manager
|
|
34
|
+
- receive(): Yields responses from internal queue
|
|
35
|
+
- handle_ide_response(): Called by Flask POST endpoint to enqueue responses
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, event_emitter: Optional[Any] = None):
|
|
39
|
+
"""
|
|
40
|
+
Initialize SSE control channel.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
event_emitter: Optional callable to emit SSE events.
|
|
44
|
+
If None, uses a queue that can be consumed externally.
|
|
45
|
+
"""
|
|
46
|
+
super().__init__()
|
|
47
|
+
self._event_emitter = event_emitter
|
|
48
|
+
# Use thread-safe queue.Queue for sync access from Flask SSE stream
|
|
49
|
+
self._event_queue: queue.Queue[dict] = queue.Queue()
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def channel_id(self) -> str:
|
|
53
|
+
return "sse"
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def capabilities(self) -> ChannelCapabilities:
|
|
57
|
+
return ChannelCapabilities(
|
|
58
|
+
supports_approval=True,
|
|
59
|
+
supports_input=True,
|
|
60
|
+
supports_review=True,
|
|
61
|
+
supports_escalation=True,
|
|
62
|
+
supports_select=True,
|
|
63
|
+
supports_upload=True,
|
|
64
|
+
supports_inputs=True, # Batched inputs support
|
|
65
|
+
supports_interactive_buttons=True,
|
|
66
|
+
max_message_length=None, # No length limit for IDE
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
async def initialize(self) -> None:
|
|
70
|
+
"""Initialize SSE channel (no-op, Flask SSE already running)."""
|
|
71
|
+
logger.info(f"{self.channel_id}: initializing...")
|
|
72
|
+
# No auth or connection needed - Flask SSE already set up
|
|
73
|
+
logger.info(f"{self.channel_id}: ready")
|
|
74
|
+
|
|
75
|
+
async def send(self, request: ControlRequest) -> DeliveryResult:
|
|
76
|
+
"""
|
|
77
|
+
Send HITL request as SSE event to connected IDEs.
|
|
78
|
+
|
|
79
|
+
Creates a hitl.request event with rich context and pushes to SSE stream.
|
|
80
|
+
"""
|
|
81
|
+
logger.info(f"{self.channel_id}: sending notification for {request.request_id}")
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
# Build SSE event payload
|
|
85
|
+
event = self._build_hitl_event(request)
|
|
86
|
+
|
|
87
|
+
# Emit event to SSE stream
|
|
88
|
+
if self._event_emitter:
|
|
89
|
+
await self._event_emitter(event)
|
|
90
|
+
else:
|
|
91
|
+
# Queue for external consumption if no emitter (thread-safe)
|
|
92
|
+
self._event_queue.put(event)
|
|
93
|
+
|
|
94
|
+
return DeliveryResult(
|
|
95
|
+
channel_id=self.channel_id,
|
|
96
|
+
external_message_id=request.request_id,
|
|
97
|
+
delivered_at=datetime.now(timezone.utc),
|
|
98
|
+
success=True,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
except Exception as e:
|
|
102
|
+
logger.error(f"{self.channel_id}: failed to send notification: {e}")
|
|
103
|
+
return DeliveryResult(
|
|
104
|
+
channel_id=self.channel_id,
|
|
105
|
+
external_message_id=request.request_id,
|
|
106
|
+
delivered_at=datetime.now(timezone.utc),
|
|
107
|
+
success=False,
|
|
108
|
+
error_message=str(e),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def _build_hitl_event(self, request: ControlRequest) -> dict:
|
|
112
|
+
"""
|
|
113
|
+
Build SSE event payload from ControlRequest.
|
|
114
|
+
|
|
115
|
+
Returns dict that will be serialized to JSON and sent as SSE event.
|
|
116
|
+
"""
|
|
117
|
+
event = {
|
|
118
|
+
"event_type": "hitl.request", # Frontend expects event_type, not type
|
|
119
|
+
"request_id": request.request_id,
|
|
120
|
+
# Identity
|
|
121
|
+
"procedure_id": request.procedure_id,
|
|
122
|
+
"procedure_name": request.procedure_name,
|
|
123
|
+
"invocation_id": request.invocation_id,
|
|
124
|
+
# Context
|
|
125
|
+
"subject": request.subject,
|
|
126
|
+
"started_at": request.started_at.isoformat() if request.started_at else None,
|
|
127
|
+
"input_summary": request.input_summary,
|
|
128
|
+
# The question
|
|
129
|
+
"request_type": request.request_type,
|
|
130
|
+
"message": request.message,
|
|
131
|
+
"default_value": request.default_value,
|
|
132
|
+
"timeout_seconds": request.timeout_seconds,
|
|
133
|
+
# Options
|
|
134
|
+
"options": (
|
|
135
|
+
[
|
|
136
|
+
{
|
|
137
|
+
"label": opt.label,
|
|
138
|
+
"value": opt.value,
|
|
139
|
+
"style": opt.style,
|
|
140
|
+
"description": opt.description,
|
|
141
|
+
}
|
|
142
|
+
for opt in request.options
|
|
143
|
+
]
|
|
144
|
+
if request.options
|
|
145
|
+
else []
|
|
146
|
+
),
|
|
147
|
+
# For batched inputs requests
|
|
148
|
+
"items": (
|
|
149
|
+
[
|
|
150
|
+
{
|
|
151
|
+
"item_id": item.item_id,
|
|
152
|
+
"label": item.label,
|
|
153
|
+
"request_type": item.request_type,
|
|
154
|
+
"message": item.message,
|
|
155
|
+
"options": (
|
|
156
|
+
[
|
|
157
|
+
{
|
|
158
|
+
"label": opt.label,
|
|
159
|
+
"value": opt.value,
|
|
160
|
+
"style": opt.style,
|
|
161
|
+
"description": opt.description,
|
|
162
|
+
}
|
|
163
|
+
for opt in item.options
|
|
164
|
+
]
|
|
165
|
+
if item.options
|
|
166
|
+
else []
|
|
167
|
+
),
|
|
168
|
+
"default_value": item.default_value,
|
|
169
|
+
"required": item.required,
|
|
170
|
+
"metadata": item.metadata,
|
|
171
|
+
}
|
|
172
|
+
for item in request.items
|
|
173
|
+
]
|
|
174
|
+
if request.items
|
|
175
|
+
else []
|
|
176
|
+
),
|
|
177
|
+
# Rich context for decision-making
|
|
178
|
+
"conversation": request.conversation,
|
|
179
|
+
"prior_interactions": request.prior_interactions,
|
|
180
|
+
# New context architecture (Phase 5)
|
|
181
|
+
"runtime_context": self._serialize_runtime_context(request.runtime_context),
|
|
182
|
+
"application_context": (
|
|
183
|
+
[
|
|
184
|
+
{
|
|
185
|
+
"name": link.name,
|
|
186
|
+
"value": link.value,
|
|
187
|
+
"url": link.url,
|
|
188
|
+
}
|
|
189
|
+
for link in request.application_context
|
|
190
|
+
]
|
|
191
|
+
if request.application_context
|
|
192
|
+
else []
|
|
193
|
+
),
|
|
194
|
+
# Additional metadata
|
|
195
|
+
"metadata": request.metadata,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return event
|
|
199
|
+
|
|
200
|
+
def _serialize_runtime_context(self, runtime_context) -> Optional[dict]:
|
|
201
|
+
"""Serialize RuntimeContext to dict for SSE payload."""
|
|
202
|
+
if not runtime_context:
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
"source_line": runtime_context.source_line,
|
|
207
|
+
"source_file": runtime_context.source_file,
|
|
208
|
+
"checkpoint_position": runtime_context.checkpoint_position,
|
|
209
|
+
"procedure_name": runtime_context.procedure_name,
|
|
210
|
+
"invocation_id": runtime_context.invocation_id,
|
|
211
|
+
"started_at": (
|
|
212
|
+
runtime_context.started_at.isoformat() if runtime_context.started_at else None
|
|
213
|
+
),
|
|
214
|
+
"elapsed_seconds": runtime_context.elapsed_seconds,
|
|
215
|
+
"backtrace": (
|
|
216
|
+
[
|
|
217
|
+
{
|
|
218
|
+
"checkpoint_type": bt.checkpoint_type,
|
|
219
|
+
"line": bt.line,
|
|
220
|
+
"function_name": bt.function_name,
|
|
221
|
+
"duration_ms": bt.duration_ms,
|
|
222
|
+
}
|
|
223
|
+
for bt in runtime_context.backtrace
|
|
224
|
+
]
|
|
225
|
+
if runtime_context.backtrace
|
|
226
|
+
else []
|
|
227
|
+
),
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
def handle_ide_response(self, request_id: str, value: Any) -> None:
|
|
231
|
+
"""
|
|
232
|
+
Handle response from IDE (called by Flask POST endpoint).
|
|
233
|
+
|
|
234
|
+
Bridges sync Flask handler to async channel protocol by pushing
|
|
235
|
+
to the internal response queue.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
request_id: The request being responded to
|
|
239
|
+
value: The response value from the IDE
|
|
240
|
+
"""
|
|
241
|
+
logger.info(f"{self.channel_id}: received response for {request_id}")
|
|
242
|
+
|
|
243
|
+
response = ControlResponse(
|
|
244
|
+
request_id=request_id,
|
|
245
|
+
value=value,
|
|
246
|
+
responded_at=datetime.now(timezone.utc),
|
|
247
|
+
timed_out=False,
|
|
248
|
+
channel_id=self.channel_id,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Push to queue from sync context (Flask thread)
|
|
252
|
+
# Get the running event loop and schedule the put operation
|
|
253
|
+
try:
|
|
254
|
+
loop = asyncio.get_event_loop()
|
|
255
|
+
if loop.is_running():
|
|
256
|
+
# Schedule the coroutine in the running loop
|
|
257
|
+
asyncio.run_coroutine_threadsafe(self._response_queue.put(response), loop)
|
|
258
|
+
else:
|
|
259
|
+
# If no loop is running, use put_nowait (shouldn't happen)
|
|
260
|
+
self._response_queue.put_nowait(response)
|
|
261
|
+
except Exception as e:
|
|
262
|
+
logger.error(f"{self.channel_id}: failed to queue response for {request_id}: {e}")
|
|
263
|
+
|
|
264
|
+
def get_next_event(self, timeout: float = 0.001) -> Optional[dict]:
|
|
265
|
+
"""
|
|
266
|
+
Get next SSE event from queue (for external consumption).
|
|
267
|
+
|
|
268
|
+
Used when no event_emitter is provided - allows Flask endpoint
|
|
269
|
+
to consume events from this channel.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
timeout: Timeout in seconds
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
Event dict or None if queue is empty
|
|
276
|
+
"""
|
|
277
|
+
try:
|
|
278
|
+
event = self._event_queue.get(timeout=timeout)
|
|
279
|
+
return event
|
|
280
|
+
except queue.Empty:
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
async def cancel(self, external_message_id: str, reason: str) -> None:
|
|
284
|
+
"""
|
|
285
|
+
Cancel/dismiss HITL request in IDE.
|
|
286
|
+
|
|
287
|
+
Sends a hitl.cancel event to dismiss the prompt.
|
|
288
|
+
"""
|
|
289
|
+
logger.debug(f"{self.channel_id}: cancelling {external_message_id}: {reason}")
|
|
290
|
+
|
|
291
|
+
cancel_event = {
|
|
292
|
+
"event_type": "hitl.cancel", # Frontend expects event_type, not type
|
|
293
|
+
"request_id": external_message_id,
|
|
294
|
+
"reason": reason,
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if self._event_emitter:
|
|
298
|
+
await self._event_emitter(cancel_event)
|
|
299
|
+
else:
|
|
300
|
+
self._event_queue.put(cancel_event)
|
|
301
|
+
|
|
302
|
+
async def shutdown(self) -> None:
|
|
303
|
+
"""Shutdown SSE channel."""
|
|
304
|
+
logger.info(f"{self.channel_id}: shutting down")
|
|
305
|
+
self._shutdown_event.set()
|
tactus/adapters/cli_hitl.py
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
"""
|
|
2
2
|
CLI HITL Handler for interactive human-in-the-loop interactions.
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
This module provides backward compatibility with the existing HITLHandler protocol
|
|
5
|
+
while the new control loop architecture is being developed.
|
|
6
|
+
|
|
7
|
+
For new code, prefer using the ControlLoopHandler and CLIControlChannel classes
|
|
8
|
+
from the control loop module.
|
|
5
9
|
"""
|
|
6
10
|
|
|
7
11
|
import logging
|
|
@@ -22,6 +26,12 @@ class CLIHITLHandler:
|
|
|
22
26
|
CLI-based HITL handler using rich prompts.
|
|
23
27
|
|
|
24
28
|
Provides interactive command-line prompts for human-in-the-loop interactions.
|
|
29
|
+
|
|
30
|
+
Note: This class is maintained for backward compatibility. For new implementations,
|
|
31
|
+
consider using ControlLoopHandler with CLIControlChannel which supports:
|
|
32
|
+
- Multi-channel racing (first response wins)
|
|
33
|
+
- Interruptible prompts
|
|
34
|
+
- Namespace-based routing
|
|
25
35
|
"""
|
|
26
36
|
|
|
27
37
|
def __init__(self, console: Optional[Console] = None):
|
|
@@ -66,6 +76,8 @@ class CLIHITLHandler:
|
|
|
66
76
|
return self._handle_review(request)
|
|
67
77
|
elif request.request_type == "escalation":
|
|
68
78
|
return self._handle_escalation(request)
|
|
79
|
+
elif request.request_type == "inputs":
|
|
80
|
+
return self._handle_inputs(request)
|
|
69
81
|
else:
|
|
70
82
|
# Default: treat as input
|
|
71
83
|
return self._handle_input(request)
|
|
@@ -172,6 +184,216 @@ class CLIHITLHandler:
|
|
|
172
184
|
# Escalation doesn't need a specific value
|
|
173
185
|
return HITLResponse(value=None, responded_at=datetime.now(timezone.utc), timed_out=False)
|
|
174
186
|
|
|
187
|
+
def _handle_inputs(self, request: HITLRequest) -> HITLResponse:
|
|
188
|
+
"""Handle batched inputs request (multiple inputs in one interaction)."""
|
|
189
|
+
# Extract items from metadata
|
|
190
|
+
items = request.metadata.get("items", [])
|
|
191
|
+
|
|
192
|
+
if not items:
|
|
193
|
+
self.console.print("[red]Error: No items found in inputs request[/red]")
|
|
194
|
+
return HITLResponse(value={}, responded_at=datetime.now(timezone.utc), timed_out=False)
|
|
195
|
+
|
|
196
|
+
# Display summary
|
|
197
|
+
self.console.print(f"\n[bold cyan]Collecting {len(items)} inputs:[/bold cyan]")
|
|
198
|
+
for idx, item in enumerate(items, 1):
|
|
199
|
+
label = item.get("label", f"Item {idx}")
|
|
200
|
+
required = item.get("required", True)
|
|
201
|
+
req_marker = "*" if required else ""
|
|
202
|
+
self.console.print(f" {idx}. [cyan]{label}[/cyan]{req_marker}")
|
|
203
|
+
self.console.print()
|
|
204
|
+
|
|
205
|
+
# Collect responses for each item
|
|
206
|
+
responses = {}
|
|
207
|
+
|
|
208
|
+
for idx, item in enumerate(items, 1):
|
|
209
|
+
item_id = item.get("item_id")
|
|
210
|
+
label = item.get("label", f"Item {idx}")
|
|
211
|
+
request_type = item.get("request_type", "input")
|
|
212
|
+
message = item.get("message", "")
|
|
213
|
+
required = item.get("required", True)
|
|
214
|
+
options = item.get("options", [])
|
|
215
|
+
default_value = item.get("default_value")
|
|
216
|
+
metadata = item.get("metadata", {})
|
|
217
|
+
|
|
218
|
+
# Display item panel
|
|
219
|
+
self.console.print(
|
|
220
|
+
Panel(
|
|
221
|
+
message,
|
|
222
|
+
title=f"[bold]{idx}/{len(items)}: {label}[/bold]",
|
|
223
|
+
style="cyan" if required else "blue",
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Handle based on item type
|
|
228
|
+
if request_type == "approval":
|
|
229
|
+
default = default_value if default_value is not None else False
|
|
230
|
+
value = Confirm.ask("Approve?", default=default, console=self.console)
|
|
231
|
+
|
|
232
|
+
elif request_type == "select":
|
|
233
|
+
mode = metadata.get("mode", "single")
|
|
234
|
+
|
|
235
|
+
if mode == "multiple":
|
|
236
|
+
# Multiple selection
|
|
237
|
+
self.console.print(
|
|
238
|
+
"\n[bold]Select multiple options (comma-separated numbers):[/bold]"
|
|
239
|
+
)
|
|
240
|
+
for i, option in enumerate(options, 1):
|
|
241
|
+
label_text = (
|
|
242
|
+
option.get("label", f"Option {i}")
|
|
243
|
+
if isinstance(option, dict)
|
|
244
|
+
else option
|
|
245
|
+
)
|
|
246
|
+
self.console.print(f" {i}. [cyan]{label_text}[/cyan]")
|
|
247
|
+
|
|
248
|
+
min_selections = metadata.get("min", 0)
|
|
249
|
+
max_selections = metadata.get("max", len(options))
|
|
250
|
+
|
|
251
|
+
while True:
|
|
252
|
+
choice_str = Prompt.ask(
|
|
253
|
+
"Select options (e.g., 1,3,4)", console=self.console
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
choices = [int(c.strip()) for c in choice_str.split(",")]
|
|
258
|
+
if all(1 <= c <= len(options) for c in choices):
|
|
259
|
+
if len(choices) < min_selections:
|
|
260
|
+
self.console.print(
|
|
261
|
+
f"[red]Select at least {min_selections} options[/red]"
|
|
262
|
+
)
|
|
263
|
+
continue
|
|
264
|
+
if len(choices) > max_selections:
|
|
265
|
+
self.console.print(
|
|
266
|
+
f"[red]Select at most {max_selections} options[/red]"
|
|
267
|
+
)
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
# Get values for selected options
|
|
271
|
+
selected_values = []
|
|
272
|
+
for choice in choices:
|
|
273
|
+
opt = options[choice - 1]
|
|
274
|
+
if isinstance(opt, dict):
|
|
275
|
+
selected_values.append(opt.get("value", opt.get("label")))
|
|
276
|
+
else:
|
|
277
|
+
selected_values.append(opt)
|
|
278
|
+
value = selected_values
|
|
279
|
+
break
|
|
280
|
+
else:
|
|
281
|
+
self.console.print(
|
|
282
|
+
f"[red]Invalid choice. Enter 1-{len(options)}[/red]"
|
|
283
|
+
)
|
|
284
|
+
except ValueError:
|
|
285
|
+
self.console.print(
|
|
286
|
+
"[red]Invalid input. Enter comma-separated numbers[/red]"
|
|
287
|
+
)
|
|
288
|
+
else:
|
|
289
|
+
# Single selection
|
|
290
|
+
self.console.print("\n[bold]Options:[/bold]")
|
|
291
|
+
for i, option in enumerate(options, 1):
|
|
292
|
+
if isinstance(option, dict):
|
|
293
|
+
label_text = option.get("label", f"Option {i}")
|
|
294
|
+
description = option.get("description", "")
|
|
295
|
+
self.console.print(f" {i}. [cyan]{label_text}[/cyan]")
|
|
296
|
+
if description:
|
|
297
|
+
self.console.print(f" [dim]{description}[/dim]")
|
|
298
|
+
else:
|
|
299
|
+
self.console.print(f" {i}. [cyan]{option}[/cyan]")
|
|
300
|
+
|
|
301
|
+
while True:
|
|
302
|
+
choice_str = Prompt.ask("Select option (number)", console=self.console)
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
choice = int(choice_str)
|
|
306
|
+
if 1 <= choice <= len(options):
|
|
307
|
+
selected = options[choice - 1]
|
|
308
|
+
if isinstance(selected, dict):
|
|
309
|
+
value = selected.get("value", selected.get("label"))
|
|
310
|
+
else:
|
|
311
|
+
value = selected
|
|
312
|
+
break
|
|
313
|
+
else:
|
|
314
|
+
self.console.print(
|
|
315
|
+
f"[red]Invalid choice. Enter 1-{len(options)}[/red]"
|
|
316
|
+
)
|
|
317
|
+
except ValueError:
|
|
318
|
+
self.console.print("[red]Invalid input. Enter a number[/red]")
|
|
319
|
+
|
|
320
|
+
elif request_type == "review":
|
|
321
|
+
self.console.print("\n[bold]Review Options:[/bold]")
|
|
322
|
+
self.console.print(" 1. [green]Approve[/green] - Accept as-is")
|
|
323
|
+
self.console.print(" 2. [yellow]Edit[/yellow] - Provide changes")
|
|
324
|
+
self.console.print(" 3. [red]Reject[/red] - Reject and request redo")
|
|
325
|
+
|
|
326
|
+
while True:
|
|
327
|
+
choice = Prompt.ask(
|
|
328
|
+
"Your decision",
|
|
329
|
+
choices=["1", "2", "3", "approve", "edit", "reject"],
|
|
330
|
+
default="1",
|
|
331
|
+
console=self.console,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
if choice in ["1", "approve"]:
|
|
335
|
+
decision = "approved"
|
|
336
|
+
feedback = None
|
|
337
|
+
break
|
|
338
|
+
elif choice in ["2", "edit"]:
|
|
339
|
+
decision = "approved"
|
|
340
|
+
feedback = Prompt.ask("What changes would you like?", console=self.console)
|
|
341
|
+
break
|
|
342
|
+
elif choice in ["3", "reject"]:
|
|
343
|
+
decision = "rejected"
|
|
344
|
+
feedback = Prompt.ask("Why are you rejecting?", console=self.console)
|
|
345
|
+
break
|
|
346
|
+
|
|
347
|
+
value = {"decision": decision, "feedback": feedback}
|
|
348
|
+
|
|
349
|
+
else:
|
|
350
|
+
# Default: input type
|
|
351
|
+
placeholder = metadata.get("placeholder", "")
|
|
352
|
+
multiline = metadata.get("multiline", False)
|
|
353
|
+
|
|
354
|
+
if multiline:
|
|
355
|
+
self.console.print("[dim](Enter text, press Ctrl+D when done)[/dim]")
|
|
356
|
+
lines = []
|
|
357
|
+
try:
|
|
358
|
+
while True:
|
|
359
|
+
line = Prompt.ask("", console=self.console, show_default=False)
|
|
360
|
+
lines.append(line)
|
|
361
|
+
except EOFError:
|
|
362
|
+
value = "\n".join(lines)
|
|
363
|
+
else:
|
|
364
|
+
prompt_text = "Enter value"
|
|
365
|
+
if placeholder:
|
|
366
|
+
prompt_text = f"{prompt_text} ({placeholder})"
|
|
367
|
+
|
|
368
|
+
default_str = str(default_value) if default_value is not None else None
|
|
369
|
+
value = Prompt.ask(prompt_text, default=default_str, console=self.console)
|
|
370
|
+
|
|
371
|
+
# Store response if required or if value was provided
|
|
372
|
+
if required or value:
|
|
373
|
+
responses[item_id] = value
|
|
374
|
+
|
|
375
|
+
self.console.print() # Add spacing between items
|
|
376
|
+
|
|
377
|
+
# Display summary
|
|
378
|
+
self.console.print("[bold green]✓ All inputs collected[/bold green]")
|
|
379
|
+
self.console.print("\n[bold]Summary:[/bold]")
|
|
380
|
+
for item_id, value in responses.items():
|
|
381
|
+
# Find the label for this item_id
|
|
382
|
+
item_label = next(
|
|
383
|
+
(item.get("label", item_id) for item in items if item.get("item_id") == item_id),
|
|
384
|
+
item_id,
|
|
385
|
+
)
|
|
386
|
+
value_str = (
|
|
387
|
+
str(value) if not isinstance(value, list) else ", ".join(str(v) for v in value)
|
|
388
|
+
)
|
|
389
|
+
if len(value_str) > 60:
|
|
390
|
+
value_str = value_str[:57] + "..."
|
|
391
|
+
self.console.print(f" [cyan]{item_label}:[/cyan] {value_str}")
|
|
392
|
+
|
|
393
|
+
return HITLResponse(
|
|
394
|
+
value=responses, responded_at=datetime.now(timezone.utc), timed_out=False
|
|
395
|
+
)
|
|
396
|
+
|
|
175
397
|
def check_pending_response(self, procedure_id: str, message_id: str) -> Optional[HITLResponse]:
|
|
176
398
|
"""
|
|
177
399
|
Check for pending response (not used in CLI mode).
|