tactus 0.32.2__py3-none-any.whl → 0.34.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. tactus/__init__.py +1 -1
  2. tactus/adapters/__init__.py +18 -1
  3. tactus/adapters/broker_log.py +127 -34
  4. tactus/adapters/channels/__init__.py +153 -0
  5. tactus/adapters/channels/base.py +174 -0
  6. tactus/adapters/channels/broker.py +179 -0
  7. tactus/adapters/channels/cli.py +448 -0
  8. tactus/adapters/channels/host.py +225 -0
  9. tactus/adapters/channels/ipc.py +297 -0
  10. tactus/adapters/channels/sse.py +305 -0
  11. tactus/adapters/cli_hitl.py +223 -1
  12. tactus/adapters/control_loop.py +879 -0
  13. tactus/adapters/file_storage.py +35 -2
  14. tactus/adapters/ide_log.py +7 -1
  15. tactus/backends/http_backend.py +0 -1
  16. tactus/broker/client.py +31 -1
  17. tactus/broker/server.py +416 -92
  18. tactus/cli/app.py +270 -7
  19. tactus/cli/control.py +393 -0
  20. tactus/core/config_manager.py +33 -6
  21. tactus/core/dsl_stubs.py +102 -18
  22. tactus/core/execution_context.py +265 -8
  23. tactus/core/lua_sandbox.py +8 -9
  24. tactus/core/registry.py +19 -2
  25. tactus/core/runtime.py +235 -27
  26. tactus/docker/Dockerfile.pypi +49 -0
  27. tactus/docs/__init__.py +33 -0
  28. tactus/docs/extractor.py +326 -0
  29. tactus/docs/html_renderer.py +72 -0
  30. tactus/docs/models.py +121 -0
  31. tactus/docs/templates/base.html +204 -0
  32. tactus/docs/templates/index.html +58 -0
  33. tactus/docs/templates/module.html +96 -0
  34. tactus/dspy/agent.py +382 -22
  35. tactus/dspy/broker_lm.py +57 -6
  36. tactus/dspy/config.py +14 -3
  37. tactus/dspy/history.py +2 -1
  38. tactus/dspy/module.py +136 -11
  39. tactus/dspy/signature.py +0 -1
  40. tactus/ide/server.py +300 -9
  41. tactus/primitives/human.py +619 -47
  42. tactus/primitives/system.py +0 -1
  43. tactus/protocols/__init__.py +25 -0
  44. tactus/protocols/control.py +427 -0
  45. tactus/protocols/notification.py +207 -0
  46. tactus/sandbox/container_runner.py +79 -11
  47. tactus/sandbox/docker_manager.py +23 -0
  48. tactus/sandbox/entrypoint.py +26 -0
  49. tactus/sandbox/protocol.py +3 -0
  50. tactus/stdlib/README.md +77 -0
  51. tactus/stdlib/__init__.py +27 -1
  52. tactus/stdlib/classify/__init__.py +165 -0
  53. tactus/stdlib/classify/classify.spec.tac +195 -0
  54. tactus/stdlib/classify/classify.tac +257 -0
  55. tactus/stdlib/classify/fuzzy.py +282 -0
  56. tactus/stdlib/classify/llm.py +319 -0
  57. tactus/stdlib/classify/primitive.py +287 -0
  58. tactus/stdlib/core/__init__.py +57 -0
  59. tactus/stdlib/core/base.py +320 -0
  60. tactus/stdlib/core/confidence.py +211 -0
  61. tactus/stdlib/core/models.py +161 -0
  62. tactus/stdlib/core/retry.py +171 -0
  63. tactus/stdlib/core/validation.py +274 -0
  64. tactus/stdlib/extract/__init__.py +125 -0
  65. tactus/stdlib/extract/llm.py +330 -0
  66. tactus/stdlib/extract/primitive.py +256 -0
  67. tactus/stdlib/tac/tactus/classify/base.tac +51 -0
  68. tactus/stdlib/tac/tactus/classify/fuzzy.tac +87 -0
  69. tactus/stdlib/tac/tactus/classify/index.md +77 -0
  70. tactus/stdlib/tac/tactus/classify/init.tac +29 -0
  71. tactus/stdlib/tac/tactus/classify/llm.tac +150 -0
  72. tactus/stdlib/tac/tactus/classify.spec.tac +191 -0
  73. tactus/stdlib/tac/tactus/extract/base.tac +138 -0
  74. tactus/stdlib/tac/tactus/extract/index.md +96 -0
  75. tactus/stdlib/tac/tactus/extract/init.tac +27 -0
  76. tactus/stdlib/tac/tactus/extract/llm.tac +201 -0
  77. tactus/stdlib/tac/tactus/extract.spec.tac +153 -0
  78. tactus/stdlib/tac/tactus/generate/base.tac +142 -0
  79. tactus/stdlib/tac/tactus/generate/index.md +195 -0
  80. tactus/stdlib/tac/tactus/generate/init.tac +28 -0
  81. tactus/stdlib/tac/tactus/generate/llm.tac +169 -0
  82. tactus/stdlib/tac/tactus/generate.spec.tac +210 -0
  83. tactus/testing/behave_integration.py +171 -7
  84. tactus/testing/context.py +0 -1
  85. tactus/testing/evaluation_runner.py +0 -1
  86. tactus/testing/gherkin_parser.py +0 -1
  87. tactus/testing/mock_hitl.py +0 -1
  88. tactus/testing/mock_tools.py +0 -1
  89. tactus/testing/models.py +0 -1
  90. tactus/testing/steps/builtin.py +0 -1
  91. tactus/testing/steps/custom.py +81 -22
  92. tactus/testing/steps/registry.py +0 -1
  93. tactus/testing/test_runner.py +7 -1
  94. tactus/validation/semantic_visitor.py +11 -5
  95. tactus/validation/validator.py +0 -1
  96. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/METADATA +14 -2
  97. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/RECORD +100 -49
  98. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/WHEEL +0 -0
  99. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/entry_points.txt +0 -0
  100. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,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()
@@ -1,7 +1,11 @@
1
1
  """
2
2
  CLI HITL Handler for interactive human-in-the-loop interactions.
3
3
 
4
- Provides interactive prompts for approval, input, review, and escalation.
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).