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,179 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Broker Control Channel for container-to-host HITL requests.
|
|
3
|
+
|
|
4
|
+
Used inside Docker containers to forward control requests through the broker
|
|
5
|
+
to the host's SSE channel (or other host-side control channels).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from tactus.adapters.channels.base import InProcessChannel
|
|
14
|
+
from tactus.protocols.control import (
|
|
15
|
+
ChannelCapabilities,
|
|
16
|
+
ControlRequest,
|
|
17
|
+
ControlResponse,
|
|
18
|
+
DeliveryResult,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BrokerControlChannel(InProcessChannel):
|
|
25
|
+
"""
|
|
26
|
+
Control channel that forwards requests through broker to host.
|
|
27
|
+
|
|
28
|
+
Used when running inside a container with broker transport.
|
|
29
|
+
The broker server on the host relays to the actual control channels
|
|
30
|
+
(SSE, CLI, etc.) and returns responses.
|
|
31
|
+
|
|
32
|
+
Architecture:
|
|
33
|
+
- Container sends control.request via BrokerClient
|
|
34
|
+
- Host broker receives and forwards to SSE channel
|
|
35
|
+
- SSE channel delivers to IDE UI
|
|
36
|
+
- User responds in IDE
|
|
37
|
+
- Response flows back through broker to container
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, client):
|
|
41
|
+
"""
|
|
42
|
+
Initialize broker control channel.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
client: BrokerClient instance for communication with host
|
|
46
|
+
"""
|
|
47
|
+
super().__init__()
|
|
48
|
+
from tactus.broker.client import BrokerClient
|
|
49
|
+
|
|
50
|
+
if not isinstance(client, BrokerClient):
|
|
51
|
+
raise TypeError(f"Expected BrokerClient, got {type(client)}")
|
|
52
|
+
|
|
53
|
+
self._client = client
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def channel_id(self) -> str:
|
|
57
|
+
return "broker"
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def capabilities(self) -> ChannelCapabilities:
|
|
61
|
+
# Mirror SSE capabilities since broker relays to SSE
|
|
62
|
+
return ChannelCapabilities(
|
|
63
|
+
supports_approval=True,
|
|
64
|
+
supports_input=True,
|
|
65
|
+
supports_review=True,
|
|
66
|
+
supports_escalation=True,
|
|
67
|
+
supports_select=True,
|
|
68
|
+
supports_upload=True,
|
|
69
|
+
supports_inputs=True, # Batched inputs
|
|
70
|
+
supports_interactive_buttons=True,
|
|
71
|
+
is_synchronous=False, # Async relay through broker
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
async def initialize(self) -> None:
|
|
75
|
+
"""Initialize broker control channel (broker already connected)."""
|
|
76
|
+
logger.info(f"{self.channel_id}: initializing...")
|
|
77
|
+
# Broker client already initialized by BrokerLogHandler setup
|
|
78
|
+
logger.info(f"{self.channel_id}: ready (via broker)")
|
|
79
|
+
|
|
80
|
+
async def send(self, request: ControlRequest) -> DeliveryResult:
|
|
81
|
+
"""
|
|
82
|
+
Send control request through broker to host.
|
|
83
|
+
|
|
84
|
+
The request is serialized and sent via broker's control.request method.
|
|
85
|
+
The host will relay to its SSE channel and return the response.
|
|
86
|
+
"""
|
|
87
|
+
logger.info(f"{self.channel_id}: sending control request {request.request_id} via broker")
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
# Serialize request to JSON-compatible dict
|
|
91
|
+
request_data = request.model_dump(mode="json")
|
|
92
|
+
|
|
93
|
+
# Send via broker and wait for response events
|
|
94
|
+
async for event in self._client._request("control.request", {"request": request_data}):
|
|
95
|
+
event_type = event.get("event")
|
|
96
|
+
|
|
97
|
+
if event_type == "delivered":
|
|
98
|
+
# Request successfully delivered to host channels
|
|
99
|
+
logger.debug(f"{self.channel_id}: request {request.request_id} delivered")
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
elif event_type == "response":
|
|
103
|
+
# Got response from host
|
|
104
|
+
response_data = event.get("data", {})
|
|
105
|
+
response = ControlResponse(
|
|
106
|
+
request_id=request.request_id,
|
|
107
|
+
value=response_data.get("value"),
|
|
108
|
+
responded_at=datetime.now(timezone.utc),
|
|
109
|
+
timed_out=response_data.get("timed_out", False),
|
|
110
|
+
channel_id=response_data.get("channel_id", "sse"),
|
|
111
|
+
responder_id=response_data.get("responder_id"),
|
|
112
|
+
)
|
|
113
|
+
logger.info(f"{self.channel_id}: received response for {request.request_id}")
|
|
114
|
+
self._response_queue.put_nowait(response)
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
elif event_type == "timeout":
|
|
118
|
+
# Host-side timeout
|
|
119
|
+
logger.warning(f"{self.channel_id}: timeout for {request.request_id}")
|
|
120
|
+
response = ControlResponse(
|
|
121
|
+
request_id=request.request_id,
|
|
122
|
+
value=request.default_value,
|
|
123
|
+
responded_at=datetime.now(timezone.utc),
|
|
124
|
+
timed_out=True,
|
|
125
|
+
channel_id="broker",
|
|
126
|
+
)
|
|
127
|
+
self._response_queue.put_nowait(response)
|
|
128
|
+
break
|
|
129
|
+
|
|
130
|
+
elif event_type == "error":
|
|
131
|
+
# Delivery or processing error
|
|
132
|
+
error = event.get("error", {})
|
|
133
|
+
error_msg = error.get("message", "Unknown broker error")
|
|
134
|
+
logger.error(f"{self.channel_id}: error for {request.request_id}: {error_msg}")
|
|
135
|
+
raise RuntimeError(f"Broker control request failed: {error_msg}")
|
|
136
|
+
|
|
137
|
+
return DeliveryResult(
|
|
138
|
+
channel_id=self.channel_id,
|
|
139
|
+
external_message_id=request.request_id,
|
|
140
|
+
delivered_at=datetime.now(timezone.utc),
|
|
141
|
+
success=True,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.error(f"{self.channel_id}: failed to send {request.request_id}: {e}")
|
|
146
|
+
return DeliveryResult(
|
|
147
|
+
channel_id=self.channel_id,
|
|
148
|
+
external_message_id=request.request_id,
|
|
149
|
+
delivered_at=datetime.now(timezone.utc),
|
|
150
|
+
success=False,
|
|
151
|
+
error_message=str(e),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def from_environment(cls) -> Optional["BrokerControlChannel"]:
|
|
156
|
+
"""
|
|
157
|
+
Create BrokerControlChannel from environment if broker is available.
|
|
158
|
+
|
|
159
|
+
Checks for TACTUS_BROKER_SOCKET environment variable and creates
|
|
160
|
+
a broker client if found.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
BrokerControlChannel instance if broker available, None otherwise
|
|
164
|
+
"""
|
|
165
|
+
socket_path = os.environ.get("TACTUS_BROKER_SOCKET")
|
|
166
|
+
if not socket_path:
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
from tactus.broker.client import BrokerClient
|
|
171
|
+
|
|
172
|
+
client = BrokerClient(socket_path)
|
|
173
|
+
logger.info(
|
|
174
|
+
f"BrokerControlChannel: initialized from environment (socket={socket_path})"
|
|
175
|
+
)
|
|
176
|
+
return cls(client)
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.warning(f"BrokerControlChannel: failed to initialize from environment: {e}")
|
|
179
|
+
return None
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI control channel implementation.
|
|
3
|
+
|
|
4
|
+
Provides interactive command-line prompts for control loop interactions.
|
|
5
|
+
Uses Rich for formatting and the host channel pattern for interruptibility.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Optional, Any
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.prompt import Prompt, Confirm
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
from tactus.protocols.control import (
|
|
19
|
+
ControlRequest,
|
|
20
|
+
ControlRequestType,
|
|
21
|
+
ControlOption,
|
|
22
|
+
ChannelCapabilities,
|
|
23
|
+
)
|
|
24
|
+
from tactus.adapters.channels.host import HostControlChannel
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def format_time_ago(dt: datetime) -> str:
|
|
30
|
+
"""Format datetime as human-readable time ago string."""
|
|
31
|
+
now = datetime.now(timezone.utc)
|
|
32
|
+
delta = now - dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else now - dt
|
|
33
|
+
|
|
34
|
+
seconds = int(delta.total_seconds())
|
|
35
|
+
if seconds < 60:
|
|
36
|
+
return f"{seconds} seconds"
|
|
37
|
+
minutes = seconds // 60
|
|
38
|
+
if minutes < 60:
|
|
39
|
+
return f"{minutes} minute{'s' if minutes != 1 else ''}"
|
|
40
|
+
hours = minutes // 60
|
|
41
|
+
if hours < 24:
|
|
42
|
+
return f"{hours} hour{'s' if hours != 1 else ''}"
|
|
43
|
+
days = hours // 24
|
|
44
|
+
return f"{days} day{'s' if days != 1 else ''}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CLIControlChannel(HostControlChannel):
|
|
48
|
+
"""
|
|
49
|
+
CLI-based control channel using Rich prompts.
|
|
50
|
+
|
|
51
|
+
Provides interactive command-line prompts for approval, input,
|
|
52
|
+
review, and escalation requests. Can be interrupted if another
|
|
53
|
+
channel responds first.
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
channel = CLIControlChannel()
|
|
57
|
+
await channel.initialize()
|
|
58
|
+
result = await channel.send(request)
|
|
59
|
+
# ... wait for response via receive() or cancellation
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, console: Optional[Console] = None):
|
|
63
|
+
"""
|
|
64
|
+
Initialize CLI control channel.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
console: Rich Console instance (creates new one if not provided)
|
|
68
|
+
"""
|
|
69
|
+
super().__init__()
|
|
70
|
+
self.console = console or Console()
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def channel_id(self) -> str:
|
|
74
|
+
"""Return channel identifier."""
|
|
75
|
+
return "cli"
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def capabilities(self) -> ChannelCapabilities:
|
|
79
|
+
"""CLI supports all request types with immediate responses."""
|
|
80
|
+
return ChannelCapabilities(
|
|
81
|
+
supports_approval=True,
|
|
82
|
+
supports_input=True,
|
|
83
|
+
supports_review=True,
|
|
84
|
+
supports_escalation=True,
|
|
85
|
+
supports_interactive_buttons=False,
|
|
86
|
+
supports_file_attachments=False,
|
|
87
|
+
max_message_length=None,
|
|
88
|
+
is_synchronous=True,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
async def initialize(self) -> None:
|
|
92
|
+
"""Initialize the CLI channel."""
|
|
93
|
+
logger.info(f"{self.channel_id}: initializing...")
|
|
94
|
+
# Check if stdin is a tty
|
|
95
|
+
if not sys.stdin.isatty():
|
|
96
|
+
logger.warning(f"{self.channel_id}: stdin is not a tty, prompts may not work")
|
|
97
|
+
logger.info(f"{self.channel_id}: ready")
|
|
98
|
+
|
|
99
|
+
def _display_request(self, request: ControlRequest) -> None:
|
|
100
|
+
"""
|
|
101
|
+
Display the control request with rich formatting.
|
|
102
|
+
|
|
103
|
+
Shows:
|
|
104
|
+
- Procedure name and subject
|
|
105
|
+
- Elapsed time
|
|
106
|
+
- Input summary (if provided)
|
|
107
|
+
- Prior interactions (if any)
|
|
108
|
+
- The message and options
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
request: The control request to display
|
|
112
|
+
"""
|
|
113
|
+
self.console.print()
|
|
114
|
+
|
|
115
|
+
# Header: procedure name and subject
|
|
116
|
+
header = f"[bold]{request.procedure_name}[/bold]"
|
|
117
|
+
if request.subject:
|
|
118
|
+
header += f": [cyan]{request.subject}[/cyan]"
|
|
119
|
+
self.console.print(header)
|
|
120
|
+
|
|
121
|
+
# Timing
|
|
122
|
+
self.console.print(f"[dim]Started {format_time_ago(request.started_at)} ago[/dim]")
|
|
123
|
+
|
|
124
|
+
# Input summary
|
|
125
|
+
if request.input_summary:
|
|
126
|
+
table = Table(title="Input Data", show_header=False, box=None)
|
|
127
|
+
for key, value in request.input_summary.items():
|
|
128
|
+
table.add_row(f"[dim]{key}:[/dim]", str(value))
|
|
129
|
+
self.console.print(Panel(table, border_style="dim"))
|
|
130
|
+
|
|
131
|
+
# Prior interactions
|
|
132
|
+
if request.prior_interactions:
|
|
133
|
+
self.console.print("\n[dim]Previous decisions:[/dim]")
|
|
134
|
+
for interaction in request.prior_interactions:
|
|
135
|
+
responder = interaction.responded_by or interaction.channel_id
|
|
136
|
+
self.console.print(f" [dim]•[/dim] {responder}: {interaction.response_value}")
|
|
137
|
+
|
|
138
|
+
# The message
|
|
139
|
+
self.console.print()
|
|
140
|
+
self.console.print(
|
|
141
|
+
Panel(
|
|
142
|
+
request.message,
|
|
143
|
+
title=f"[bold]{request.request_type.value.upper()}[/bold]",
|
|
144
|
+
style="yellow",
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def _prompt_for_input(self, request: ControlRequest) -> Optional[Any]:
|
|
149
|
+
"""
|
|
150
|
+
Collect input from the user via CLI prompt.
|
|
151
|
+
|
|
152
|
+
Routes to appropriate handler based on request type.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
request: The control request being handled
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
The user's response value, or None if cancelled
|
|
159
|
+
"""
|
|
160
|
+
if self.is_cancelled():
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
request_type = request.request_type
|
|
164
|
+
if request_type == ControlRequestType.APPROVAL:
|
|
165
|
+
return self._handle_approval(request)
|
|
166
|
+
elif request_type == ControlRequestType.INPUT:
|
|
167
|
+
return self._handle_input(request)
|
|
168
|
+
elif request_type == ControlRequestType.REVIEW:
|
|
169
|
+
return self._handle_review(request)
|
|
170
|
+
elif request_type == ControlRequestType.ESCALATION:
|
|
171
|
+
return self._handle_escalation(request)
|
|
172
|
+
elif request_type == ControlRequestType.INPUTS:
|
|
173
|
+
return self._handle_inputs(request)
|
|
174
|
+
else:
|
|
175
|
+
# Default: treat as input
|
|
176
|
+
return self._handle_input(request)
|
|
177
|
+
|
|
178
|
+
def _handle_approval(self, request: ControlRequest) -> Optional[bool]:
|
|
179
|
+
"""Handle approval request."""
|
|
180
|
+
if self.is_cancelled():
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
default = request.default_value if request.default_value is not None else False
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
approved = Confirm.ask("Approve?", default=default, console=self.console)
|
|
187
|
+
return approved if not self.is_cancelled() else None
|
|
188
|
+
except (EOFError, KeyboardInterrupt):
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
def _handle_input(self, request: ControlRequest) -> Optional[Any]:
|
|
192
|
+
"""Handle input request."""
|
|
193
|
+
if self.is_cancelled():
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
default = str(request.default_value) if request.default_value is not None else None
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
# Check if there are options
|
|
200
|
+
if request.options:
|
|
201
|
+
return self._handle_options(request.options, default)
|
|
202
|
+
else:
|
|
203
|
+
# Free-form input
|
|
204
|
+
value = Prompt.ask("Enter value", default=default, console=self.console)
|
|
205
|
+
return value if not self.is_cancelled() else None
|
|
206
|
+
except (EOFError, KeyboardInterrupt):
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
def _handle_options(
|
|
210
|
+
self, options: list[ControlOption], default: Optional[str]
|
|
211
|
+
) -> Optional[Any]:
|
|
212
|
+
"""Handle options selection."""
|
|
213
|
+
# Display options
|
|
214
|
+
self.console.print("\n[bold]Options:[/bold]")
|
|
215
|
+
for i, option in enumerate(options, 1):
|
|
216
|
+
self.console.print(f" {i}. [cyan]{option.label}[/cyan]")
|
|
217
|
+
if option.description:
|
|
218
|
+
self.console.print(f" [dim]{option.description}[/dim]")
|
|
219
|
+
|
|
220
|
+
# Get choice
|
|
221
|
+
while not self.is_cancelled():
|
|
222
|
+
try:
|
|
223
|
+
choice_str = Prompt.ask(
|
|
224
|
+
"Select option (number)",
|
|
225
|
+
default=default,
|
|
226
|
+
console=self.console,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
choice = int(choice_str)
|
|
231
|
+
if 1 <= choice <= len(options):
|
|
232
|
+
return options[choice - 1].value
|
|
233
|
+
else:
|
|
234
|
+
self.console.print(f"[red]Invalid choice. Enter 1-{len(options)}[/red]")
|
|
235
|
+
except ValueError:
|
|
236
|
+
self.console.print("[red]Invalid input. Enter a number[/red]")
|
|
237
|
+
except (EOFError, KeyboardInterrupt):
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
def _handle_review(self, request: ControlRequest) -> Optional[dict]:
|
|
243
|
+
"""Handle review request."""
|
|
244
|
+
if self.is_cancelled():
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
self.console.print("\n[bold]Review Options:[/bold]")
|
|
248
|
+
self.console.print(" 1. [green]Approve[/green] - Accept as-is")
|
|
249
|
+
self.console.print(" 2. [yellow]Edit[/yellow] - Provide changes")
|
|
250
|
+
self.console.print(" 3. [red]Reject[/red] - Reject and request redo")
|
|
251
|
+
|
|
252
|
+
while not self.is_cancelled():
|
|
253
|
+
try:
|
|
254
|
+
choice = Prompt.ask(
|
|
255
|
+
"Your decision",
|
|
256
|
+
choices=["1", "2", "3", "approve", "edit", "reject"],
|
|
257
|
+
default="1",
|
|
258
|
+
console=self.console,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
if self.is_cancelled():
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
if choice in ["1", "approve"]:
|
|
265
|
+
return {"decision": "approved", "feedback": None, "edited_artifact": None}
|
|
266
|
+
elif choice in ["2", "edit"]:
|
|
267
|
+
feedback = Prompt.ask("What changes would you like?", console=self.console)
|
|
268
|
+
if self.is_cancelled():
|
|
269
|
+
return None
|
|
270
|
+
return {"decision": "approved", "feedback": feedback, "edited_artifact": None}
|
|
271
|
+
elif choice in ["3", "reject"]:
|
|
272
|
+
feedback = Prompt.ask("Why are you rejecting?", console=self.console)
|
|
273
|
+
if self.is_cancelled():
|
|
274
|
+
return None
|
|
275
|
+
return {"decision": "rejected", "feedback": feedback, "edited_artifact": None}
|
|
276
|
+
except (EOFError, KeyboardInterrupt):
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
def _handle_escalation(self, request: ControlRequest) -> Optional[None]:
|
|
282
|
+
"""Handle escalation request (acknowledgment only)."""
|
|
283
|
+
if self.is_cancelled():
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
self.console.print("\n[yellow bold]⚠ This issue requires escalation[/yellow bold]")
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
Confirm.ask(
|
|
290
|
+
"Press Enter to acknowledge and continue",
|
|
291
|
+
default=True,
|
|
292
|
+
show_default=False,
|
|
293
|
+
console=self.console,
|
|
294
|
+
)
|
|
295
|
+
return None if self.is_cancelled() else True
|
|
296
|
+
except (EOFError, KeyboardInterrupt):
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
def _handle_inputs(self, request: ControlRequest) -> Optional[dict]:
|
|
300
|
+
"""Handle batched inputs request (multiple inputs in one interaction)."""
|
|
301
|
+
if self.is_cancelled():
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
items = request.items or []
|
|
305
|
+
|
|
306
|
+
if not items:
|
|
307
|
+
self.console.print("[red]Error: No items found in inputs request[/red]")
|
|
308
|
+
return {}
|
|
309
|
+
|
|
310
|
+
# Display summary
|
|
311
|
+
self.console.print(f"\n[bold cyan]Collecting {len(items)} inputs:[/bold cyan]")
|
|
312
|
+
for idx, item in enumerate(items, 1):
|
|
313
|
+
req_marker = "*" if item.required else ""
|
|
314
|
+
self.console.print(f" {idx}. [cyan]{item.label}[/cyan]{req_marker}")
|
|
315
|
+
self.console.print()
|
|
316
|
+
|
|
317
|
+
# Collect responses for each item
|
|
318
|
+
responses = {}
|
|
319
|
+
|
|
320
|
+
for idx, item in enumerate(items, 1):
|
|
321
|
+
if self.is_cancelled():
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
# Display item panel
|
|
325
|
+
self.console.print(
|
|
326
|
+
Panel(
|
|
327
|
+
item.message,
|
|
328
|
+
title=f"[bold]{idx}/{len(items)}: {item.label}[/bold]",
|
|
329
|
+
style="cyan" if item.required else "blue",
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# Handle based on item type
|
|
334
|
+
try:
|
|
335
|
+
value = None
|
|
336
|
+
if item.request_type == ControlRequestType.APPROVAL:
|
|
337
|
+
default = item.default_value if item.default_value is not None else False
|
|
338
|
+
value = Confirm.ask("Approve?", default=default, console=self.console)
|
|
339
|
+
|
|
340
|
+
elif item.request_type == ControlRequestType.INPUT:
|
|
341
|
+
placeholder = item.metadata.get("placeholder", "") if item.metadata else ""
|
|
342
|
+
multiline = item.metadata.get("multiline", False) if item.metadata else False
|
|
343
|
+
|
|
344
|
+
if multiline:
|
|
345
|
+
self.console.print("[dim](Enter text, press Ctrl+D when done)[/dim]")
|
|
346
|
+
lines = []
|
|
347
|
+
try:
|
|
348
|
+
while not self.is_cancelled():
|
|
349
|
+
line = Prompt.ask("", console=self.console, show_default=False)
|
|
350
|
+
lines.append(line)
|
|
351
|
+
except EOFError:
|
|
352
|
+
value = "\n".join(lines)
|
|
353
|
+
else:
|
|
354
|
+
prompt_text = "Enter value"
|
|
355
|
+
if placeholder:
|
|
356
|
+
prompt_text = f"{prompt_text} ({placeholder})"
|
|
357
|
+
default_str = (
|
|
358
|
+
str(item.default_value) if item.default_value is not None else None
|
|
359
|
+
)
|
|
360
|
+
value = Prompt.ask(prompt_text, default=default_str, console=self.console)
|
|
361
|
+
|
|
362
|
+
elif item.request_type == ControlRequestType.SELECT:
|
|
363
|
+
# For now, use the simple options handler
|
|
364
|
+
# This could be enhanced to support metadata.mode = "multiple"
|
|
365
|
+
value = self._handle_options(item.options, item.default_value)
|
|
366
|
+
|
|
367
|
+
elif item.request_type == ControlRequestType.REVIEW:
|
|
368
|
+
self.console.print("\n[bold]Review Options:[/bold]")
|
|
369
|
+
self.console.print(" 1. [green]Approve[/green] - Accept as-is")
|
|
370
|
+
self.console.print(" 2. [yellow]Edit[/yellow] - Provide changes")
|
|
371
|
+
self.console.print(" 3. [red]Reject[/red] - Reject and request redo")
|
|
372
|
+
|
|
373
|
+
choice = Prompt.ask(
|
|
374
|
+
"Your decision",
|
|
375
|
+
choices=["1", "2", "3", "approve", "edit", "reject"],
|
|
376
|
+
default="1",
|
|
377
|
+
console=self.console,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
if choice in ["1", "approve"]:
|
|
381
|
+
decision = "approved"
|
|
382
|
+
feedback = None
|
|
383
|
+
elif choice in ["2", "edit"]:
|
|
384
|
+
decision = "approved"
|
|
385
|
+
feedback = Prompt.ask("What changes would you like?", console=self.console)
|
|
386
|
+
else: # reject
|
|
387
|
+
decision = "rejected"
|
|
388
|
+
feedback = Prompt.ask("Why are you rejecting?", console=self.console)
|
|
389
|
+
|
|
390
|
+
value = {"decision": decision, "feedback": feedback}
|
|
391
|
+
|
|
392
|
+
else:
|
|
393
|
+
# Default to input
|
|
394
|
+
value = Prompt.ask("Enter value", console=self.console)
|
|
395
|
+
|
|
396
|
+
# Store response if required or if value was provided
|
|
397
|
+
if self.is_cancelled():
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
if item.required or value:
|
|
401
|
+
responses[item.item_id] = value
|
|
402
|
+
|
|
403
|
+
self.console.print() # Add spacing
|
|
404
|
+
|
|
405
|
+
except (EOFError, KeyboardInterrupt):
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
# Display summary
|
|
409
|
+
if not self.is_cancelled():
|
|
410
|
+
self.console.print("[bold green]✓ All inputs collected[/bold green]")
|
|
411
|
+
self.console.print("\n[bold]Summary:[/bold]")
|
|
412
|
+
for item_id, value in responses.items():
|
|
413
|
+
item_label = next(
|
|
414
|
+
(item.label for item in items if item.item_id == item_id), item_id
|
|
415
|
+
)
|
|
416
|
+
value_str = (
|
|
417
|
+
str(value) if not isinstance(value, list) else ", ".join(str(v) for v in value)
|
|
418
|
+
)
|
|
419
|
+
if len(value_str) > 60:
|
|
420
|
+
value_str = value_str[:57] + "..."
|
|
421
|
+
self.console.print(f" [cyan]{item_label}:[/cyan] {value_str}")
|
|
422
|
+
|
|
423
|
+
return None if self.is_cancelled() else responses
|
|
424
|
+
|
|
425
|
+
def _show_cancelled(self, reason: str) -> None:
|
|
426
|
+
"""
|
|
427
|
+
Show cancellation message.
|
|
428
|
+
|
|
429
|
+
Displays a green checkmark and the reason (typically
|
|
430
|
+
"Responded via {channel}").
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
reason: Reason for cancellation
|
|
434
|
+
"""
|
|
435
|
+
self.console.print(f"\n[green]✓ {reason}[/green]")
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def is_cli_available() -> bool:
|
|
439
|
+
"""
|
|
440
|
+
Check if CLI control channel is available.
|
|
441
|
+
|
|
442
|
+
Returns True if stdin is a tty (interactive terminal).
|
|
443
|
+
Used for auto-detection of whether to enable CLI channel.
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
True if CLI is available, False otherwise
|
|
447
|
+
"""
|
|
448
|
+
return sys.stdin.isatty()
|