tactus 0.33.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 +382 -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/server.py +300 -9
- 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.33.0.dist-info → tactus-0.34.1.dist-info}/METADATA +14 -2
- {tactus-0.33.0.dist-info → tactus-0.34.1.dist-info}/RECORD +100 -49
- {tactus-0.33.0.dist-info → tactus-0.34.1.dist-info}/WHEEL +0 -0
- {tactus-0.33.0.dist-info → tactus-0.34.1.dist-info}/entry_points.txt +0 -0
- {tactus-0.33.0.dist-info → tactus-0.34.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Control loop handler for omnichannel controller interactions.
|
|
3
|
+
|
|
4
|
+
Sends control requests to all enabled channels simultaneously and waits
|
|
5
|
+
for the first response. First response wins - other channels are cancelled.
|
|
6
|
+
|
|
7
|
+
Supports both human-in-the-loop (HITL) and model-in-the-loop (MITL) controllers.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
import uuid
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import List, Optional, Dict, Any
|
|
15
|
+
|
|
16
|
+
from tactus.core.exceptions import ProcedureWaitingForHuman
|
|
17
|
+
from tactus.protocols.control import (
|
|
18
|
+
ControlChannel,
|
|
19
|
+
ControlRequest,
|
|
20
|
+
ControlResponse,
|
|
21
|
+
ControlRequestType,
|
|
22
|
+
ControlOption,
|
|
23
|
+
DeliveryResult,
|
|
24
|
+
RuntimeContext,
|
|
25
|
+
BacktraceEntry,
|
|
26
|
+
ContextLink,
|
|
27
|
+
)
|
|
28
|
+
from tactus.protocols.storage import StorageBackend
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ControlLoopHandler:
|
|
34
|
+
"""
|
|
35
|
+
Sends control requests to all enabled channels simultaneously.
|
|
36
|
+
First response wins, others get cancelled.
|
|
37
|
+
|
|
38
|
+
Controllers can be humans (HITL) or models (MITL).
|
|
39
|
+
|
|
40
|
+
Architecture:
|
|
41
|
+
1. Send to ALL enabled channels simultaneously
|
|
42
|
+
2. Wait for first response from ANY channel
|
|
43
|
+
3. Cancel other channels when one responds
|
|
44
|
+
4. No special priority for any channel type
|
|
45
|
+
|
|
46
|
+
For channels like CLI that can respond immediately, this returns quickly.
|
|
47
|
+
For async-only scenarios (e.g., only remote channels), this raises
|
|
48
|
+
ProcedureWaitingForHuman to trigger exit-and-resume pattern.
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
channels = [
|
|
52
|
+
CLIControlChannel(),
|
|
53
|
+
SSEControlChannel(sse_manager),
|
|
54
|
+
TactusCloudChannel(api_url="wss://..."),
|
|
55
|
+
]
|
|
56
|
+
handler = ControlLoopHandler(channels=channels, storage=storage_backend)
|
|
57
|
+
runtime = TactusRuntime(control_handler=handler, ...)
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
# Storage key prefix for pending requests
|
|
61
|
+
PENDING_KEY_PREFIX = "control_pending:"
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
channels: List[ControlChannel],
|
|
66
|
+
storage: Optional[StorageBackend] = None,
|
|
67
|
+
immediate_response_timeout: float = 0.5,
|
|
68
|
+
execution_context=None,
|
|
69
|
+
):
|
|
70
|
+
"""
|
|
71
|
+
Initialize control loop handler.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
channels: List of enabled control channels
|
|
75
|
+
storage: Storage backend for persisting pending requests (optional for sync-only)
|
|
76
|
+
immediate_response_timeout: How long to wait for immediate responses (default 0.5s)
|
|
77
|
+
execution_context: Optional execution context for deterministic request IDs
|
|
78
|
+
"""
|
|
79
|
+
self.channels = channels
|
|
80
|
+
self.storage = storage
|
|
81
|
+
self.immediate_response_timeout = immediate_response_timeout
|
|
82
|
+
self.execution_context = execution_context
|
|
83
|
+
self._channels_initialized = False
|
|
84
|
+
|
|
85
|
+
channel_ids = [c.channel_id for c in channels]
|
|
86
|
+
logger.info(f"ControlLoopHandler initialized with {len(channels)} channels: {channel_ids}")
|
|
87
|
+
|
|
88
|
+
async def initialize_channels(self) -> None:
|
|
89
|
+
"""
|
|
90
|
+
Initialize all channels.
|
|
91
|
+
|
|
92
|
+
Called at procedure start for eager initialization.
|
|
93
|
+
Initializes channels concurrently.
|
|
94
|
+
"""
|
|
95
|
+
if self._channels_initialized:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
if not self.channels:
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
tasks = [channel.initialize() for channel in self.channels]
|
|
102
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
103
|
+
self._channels_initialized = True
|
|
104
|
+
|
|
105
|
+
async def shutdown_channels(self) -> None:
|
|
106
|
+
"""
|
|
107
|
+
Shutdown all channels.
|
|
108
|
+
|
|
109
|
+
Called at procedure end for cleanup.
|
|
110
|
+
"""
|
|
111
|
+
if not self.channels:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
tasks = [channel.shutdown() for channel in self.channels]
|
|
115
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
116
|
+
|
|
117
|
+
def request_interaction(
|
|
118
|
+
self,
|
|
119
|
+
procedure_id: str,
|
|
120
|
+
request_type: str,
|
|
121
|
+
message: str,
|
|
122
|
+
options: Optional[List[Dict[str, Any]]] = None,
|
|
123
|
+
timeout_seconds: Optional[int] = None,
|
|
124
|
+
default_value: Any = None,
|
|
125
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
126
|
+
# Rich context
|
|
127
|
+
procedure_name: str = "Unknown Procedure",
|
|
128
|
+
invocation_id: Optional[str] = None,
|
|
129
|
+
namespace: str = "",
|
|
130
|
+
subject: Optional[str] = None,
|
|
131
|
+
started_at: Optional[datetime] = None,
|
|
132
|
+
input_summary: Optional[Dict[str, Any]] = None,
|
|
133
|
+
conversation: Optional[List[Dict[str, Any]]] = None,
|
|
134
|
+
prior_interactions: Optional[List[Dict[str, Any]]] = None,
|
|
135
|
+
# New context architecture
|
|
136
|
+
runtime_context: Optional[Dict[str, Any]] = None,
|
|
137
|
+
application_context: Optional[List[Dict[str, Any]]] = None,
|
|
138
|
+
) -> ControlResponse:
|
|
139
|
+
"""
|
|
140
|
+
Request controller interaction by sending to all channels.
|
|
141
|
+
|
|
142
|
+
This is the main entry point for control loop interactions.
|
|
143
|
+
Sends to all enabled channels concurrently, waits for first response.
|
|
144
|
+
|
|
145
|
+
For synchronous channels (like CLI), this may return immediately.
|
|
146
|
+
For async-only scenarios, raises ProcedureWaitingForHuman.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
procedure_id: Unique procedure identifier
|
|
150
|
+
request_type: Type of interaction: 'approval', 'input', 'review', 'escalation'
|
|
151
|
+
message: Message to display to the controller
|
|
152
|
+
options: Options for the controller to choose from
|
|
153
|
+
timeout_seconds: Timeout in seconds (None = wait forever)
|
|
154
|
+
default_value: Default value on timeout
|
|
155
|
+
metadata: Additional context and metadata
|
|
156
|
+
procedure_name: Human-readable procedure name
|
|
157
|
+
invocation_id: Unique invocation identifier
|
|
158
|
+
namespace: Namespace for routing/authorization
|
|
159
|
+
subject: Human-readable subject identifier
|
|
160
|
+
started_at: When the invocation started
|
|
161
|
+
input_summary: Summary of key input fields
|
|
162
|
+
conversation: Full conversation history
|
|
163
|
+
prior_interactions: Previous control interactions
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
ControlResponse with controller's answer
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
ProcedureWaitingForHuman: To trigger exit-and-resume for async channels
|
|
170
|
+
"""
|
|
171
|
+
# Build control request
|
|
172
|
+
request = self._build_request(
|
|
173
|
+
procedure_id=procedure_id,
|
|
174
|
+
request_type=request_type,
|
|
175
|
+
message=message,
|
|
176
|
+
options=options,
|
|
177
|
+
timeout_seconds=timeout_seconds,
|
|
178
|
+
default_value=default_value,
|
|
179
|
+
metadata=metadata,
|
|
180
|
+
procedure_name=procedure_name,
|
|
181
|
+
invocation_id=invocation_id,
|
|
182
|
+
namespace=namespace,
|
|
183
|
+
subject=subject,
|
|
184
|
+
started_at=started_at,
|
|
185
|
+
input_summary=input_summary,
|
|
186
|
+
conversation=conversation,
|
|
187
|
+
prior_interactions=prior_interactions,
|
|
188
|
+
runtime_context=runtime_context,
|
|
189
|
+
application_context=application_context,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
logger.info(
|
|
193
|
+
f"Control request {request.request_id} for procedure {procedure_id}: "
|
|
194
|
+
f"{request_type} - {message[:50]}..."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Run the async request flow
|
|
198
|
+
# Check if we're already in an async context
|
|
199
|
+
try:
|
|
200
|
+
loop = asyncio.get_running_loop()
|
|
201
|
+
# Already in async context - create task and run it
|
|
202
|
+
# This shouldn't normally happen since request_interaction is sync
|
|
203
|
+
import nest_asyncio
|
|
204
|
+
|
|
205
|
+
nest_asyncio.apply()
|
|
206
|
+
return loop.run_until_complete(self._request_interaction_async(request))
|
|
207
|
+
except RuntimeError:
|
|
208
|
+
# Not in async context - create new event loop
|
|
209
|
+
return asyncio.get_event_loop().run_until_complete(
|
|
210
|
+
self._request_interaction_async(request)
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
async def _request_interaction_async(self, request: ControlRequest) -> ControlResponse:
|
|
214
|
+
"""
|
|
215
|
+
Async implementation of request_interaction.
|
|
216
|
+
|
|
217
|
+
Sends to all channels, waits for first response.
|
|
218
|
+
"""
|
|
219
|
+
# RESUME FLOW: Check if we already have a cached response from previous run
|
|
220
|
+
if self.storage:
|
|
221
|
+
cached_response = self.check_pending_response(request.procedure_id, request.request_id)
|
|
222
|
+
if cached_response:
|
|
223
|
+
logger.info(f"RESUME: Using cached response for {request.request_id}")
|
|
224
|
+
return cached_response
|
|
225
|
+
|
|
226
|
+
# Initialize channels on first use
|
|
227
|
+
await self.initialize_channels()
|
|
228
|
+
|
|
229
|
+
if not self.channels:
|
|
230
|
+
raise RuntimeError("No control channels available")
|
|
231
|
+
|
|
232
|
+
# Filter channels that support this request type
|
|
233
|
+
eligible_channels = self._get_eligible_channels(request)
|
|
234
|
+
if not eligible_channels:
|
|
235
|
+
raise RuntimeError(
|
|
236
|
+
f"No channels support {request.request_type} requests. "
|
|
237
|
+
f"Available channels: {[c.channel_id for c in self.channels]}"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Send to all eligible channels concurrently
|
|
241
|
+
deliveries = await self._fanout(request, eligible_channels)
|
|
242
|
+
|
|
243
|
+
# Log delivery results
|
|
244
|
+
successful = [d for d in deliveries if d.success]
|
|
245
|
+
failed = [d for d in deliveries if not d.success]
|
|
246
|
+
logger.info(
|
|
247
|
+
f"Control request {request.request_id}: "
|
|
248
|
+
f"{len(successful)} successful deliveries, {len(failed)} failed"
|
|
249
|
+
)
|
|
250
|
+
for d in failed:
|
|
251
|
+
logger.warning(f" Failed delivery to {d.channel_id}: {d.error_message}")
|
|
252
|
+
|
|
253
|
+
if not successful:
|
|
254
|
+
raise RuntimeError("All channel deliveries failed")
|
|
255
|
+
|
|
256
|
+
# Wait for responses from ALL eligible channels, even if delivery failed
|
|
257
|
+
# (e.g., IPC channel with no clients can still get responses when clients connect)
|
|
258
|
+
active_channels = eligible_channels
|
|
259
|
+
|
|
260
|
+
# Wait for first response from any channel
|
|
261
|
+
response = await self._wait_for_first_response(request, active_channels, deliveries)
|
|
262
|
+
|
|
263
|
+
if response:
|
|
264
|
+
# Store response for future resume
|
|
265
|
+
if self.storage:
|
|
266
|
+
self._store_response(request, response)
|
|
267
|
+
logger.info(f"Stored response for {request.request_id} (enables resume)")
|
|
268
|
+
|
|
269
|
+
# Cancel all other channels
|
|
270
|
+
await self._cancel_other_channels(
|
|
271
|
+
request,
|
|
272
|
+
deliveries,
|
|
273
|
+
winning_channel=response.channel_id,
|
|
274
|
+
)
|
|
275
|
+
return response
|
|
276
|
+
|
|
277
|
+
# No immediate response - save state and raise for exit-and-resume
|
|
278
|
+
if self.storage:
|
|
279
|
+
self._store_pending(request, deliveries)
|
|
280
|
+
|
|
281
|
+
raise ProcedureWaitingForHuman(request.procedure_id, request.request_id)
|
|
282
|
+
|
|
283
|
+
async def _fanout(
|
|
284
|
+
self,
|
|
285
|
+
request: ControlRequest,
|
|
286
|
+
channels: List[ControlChannel],
|
|
287
|
+
) -> List[DeliveryResult]:
|
|
288
|
+
"""
|
|
289
|
+
Send request to all channels concurrently.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
request: The control request
|
|
293
|
+
channels: Channels to send to
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
List of delivery results
|
|
297
|
+
"""
|
|
298
|
+
tasks = [self._send_with_error_handling(channel, request) for channel in channels]
|
|
299
|
+
results = await asyncio.gather(*tasks)
|
|
300
|
+
return list(results)
|
|
301
|
+
|
|
302
|
+
async def _send_with_error_handling(
|
|
303
|
+
self,
|
|
304
|
+
channel: ControlChannel,
|
|
305
|
+
request: ControlRequest,
|
|
306
|
+
) -> DeliveryResult:
|
|
307
|
+
"""
|
|
308
|
+
Send with error handling - returns failed result instead of raising.
|
|
309
|
+
"""
|
|
310
|
+
try:
|
|
311
|
+
return await channel.send(request)
|
|
312
|
+
except Exception as e:
|
|
313
|
+
logger.exception(f"Failed to send to {channel.channel_id}")
|
|
314
|
+
return DeliveryResult(
|
|
315
|
+
channel_id=channel.channel_id,
|
|
316
|
+
external_message_id="",
|
|
317
|
+
delivered_at=datetime.now(timezone.utc),
|
|
318
|
+
success=False,
|
|
319
|
+
error_message=str(e),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
async def _wait_for_first_response(
|
|
323
|
+
self,
|
|
324
|
+
request: ControlRequest,
|
|
325
|
+
channels: List[ControlChannel],
|
|
326
|
+
deliveries: List[DeliveryResult],
|
|
327
|
+
) -> Optional[ControlResponse]:
|
|
328
|
+
"""
|
|
329
|
+
Wait for first response from any channel.
|
|
330
|
+
|
|
331
|
+
For synchronous channels (like CLI), this may return quickly.
|
|
332
|
+
For async-only scenarios, returns None after timeout.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
request: The control request
|
|
336
|
+
channels: Active channels to listen to
|
|
337
|
+
deliveries: Delivery results for building response
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
ControlResponse if received, None otherwise
|
|
341
|
+
"""
|
|
342
|
+
# Check if any channel is synchronous (can respond immediately)
|
|
343
|
+
has_sync_channel = any(c.capabilities.is_synchronous for c in channels)
|
|
344
|
+
|
|
345
|
+
# Use longer timeout if we have sync channels
|
|
346
|
+
timeout = self.immediate_response_timeout if not has_sync_channel else 30.0
|
|
347
|
+
|
|
348
|
+
# Create tasks for each channel's receive iterator
|
|
349
|
+
receive_tasks = []
|
|
350
|
+
for channel in channels:
|
|
351
|
+
task = asyncio.create_task(
|
|
352
|
+
self._get_first_from_channel(channel),
|
|
353
|
+
name=f"receive_{channel.channel_id}",
|
|
354
|
+
)
|
|
355
|
+
receive_tasks.append((channel, task))
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
# Wait for first completion
|
|
359
|
+
done, pending = await asyncio.wait(
|
|
360
|
+
[t for _, t in receive_tasks],
|
|
361
|
+
timeout=timeout,
|
|
362
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# Cancel pending tasks
|
|
366
|
+
for task in pending:
|
|
367
|
+
task.cancel()
|
|
368
|
+
try:
|
|
369
|
+
await task
|
|
370
|
+
except asyncio.CancelledError:
|
|
371
|
+
pass
|
|
372
|
+
|
|
373
|
+
# Return first successful response
|
|
374
|
+
for task in done:
|
|
375
|
+
try:
|
|
376
|
+
response = task.result()
|
|
377
|
+
if response:
|
|
378
|
+
return response
|
|
379
|
+
except asyncio.CancelledError:
|
|
380
|
+
pass
|
|
381
|
+
except Exception as e:
|
|
382
|
+
logger.debug(f"Task exception: {e}")
|
|
383
|
+
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
except asyncio.TimeoutError:
|
|
387
|
+
# Cancel all tasks
|
|
388
|
+
for _, task in receive_tasks:
|
|
389
|
+
task.cancel()
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
async def _get_first_from_channel(self, channel: ControlChannel) -> Optional[ControlResponse]:
|
|
393
|
+
"""Get first response from a channel's receive iterator."""
|
|
394
|
+
try:
|
|
395
|
+
async for response in channel.receive():
|
|
396
|
+
logger.info(f"Received response from {channel.channel_id}: {response.request_id}")
|
|
397
|
+
return response
|
|
398
|
+
except asyncio.CancelledError:
|
|
399
|
+
raise
|
|
400
|
+
except Exception as e:
|
|
401
|
+
logger.debug(f"Error receiving from {channel.channel_id}: {e}")
|
|
402
|
+
return None
|
|
403
|
+
return None
|
|
404
|
+
|
|
405
|
+
async def _cancel_other_channels(
|
|
406
|
+
self,
|
|
407
|
+
request: ControlRequest,
|
|
408
|
+
deliveries: List[DeliveryResult],
|
|
409
|
+
winning_channel: Optional[str],
|
|
410
|
+
) -> None:
|
|
411
|
+
"""
|
|
412
|
+
Cancel all channels except the one that responded.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
request: The control request
|
|
416
|
+
deliveries: Delivery results with external message IDs
|
|
417
|
+
winning_channel: Channel that provided the response
|
|
418
|
+
"""
|
|
419
|
+
reason = f"Responded via {winning_channel}" if winning_channel else "Request cancelled"
|
|
420
|
+
|
|
421
|
+
tasks = []
|
|
422
|
+
for delivery in deliveries:
|
|
423
|
+
if delivery.success and delivery.channel_id != winning_channel:
|
|
424
|
+
channel = self._get_channel_by_id(delivery.channel_id)
|
|
425
|
+
if channel:
|
|
426
|
+
tasks.append(
|
|
427
|
+
self._cancel_with_error_handling(
|
|
428
|
+
channel,
|
|
429
|
+
delivery.external_message_id,
|
|
430
|
+
reason,
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
if tasks:
|
|
435
|
+
await asyncio.gather(*tasks)
|
|
436
|
+
|
|
437
|
+
async def _cancel_with_error_handling(
|
|
438
|
+
self,
|
|
439
|
+
channel: ControlChannel,
|
|
440
|
+
external_message_id: str,
|
|
441
|
+
reason: str,
|
|
442
|
+
) -> None:
|
|
443
|
+
"""Cancel with error handling to prevent one failure from affecting others."""
|
|
444
|
+
try:
|
|
445
|
+
await channel.cancel(external_message_id, reason)
|
|
446
|
+
except Exception:
|
|
447
|
+
logger.exception(f"Failed to cancel on {channel.channel_id}")
|
|
448
|
+
|
|
449
|
+
def _get_eligible_channels(self, request: ControlRequest) -> List[ControlChannel]:
|
|
450
|
+
"""Get channels that support the given request type."""
|
|
451
|
+
eligible = []
|
|
452
|
+
for channel in self.channels:
|
|
453
|
+
if self._channel_supports_request(channel, request):
|
|
454
|
+
eligible.append(channel)
|
|
455
|
+
return eligible
|
|
456
|
+
|
|
457
|
+
def _channel_supports_request(
|
|
458
|
+
self,
|
|
459
|
+
channel: ControlChannel,
|
|
460
|
+
request: ControlRequest,
|
|
461
|
+
) -> bool:
|
|
462
|
+
"""Check if a channel supports the given request type."""
|
|
463
|
+
caps = channel.capabilities
|
|
464
|
+
request_type = request.request_type
|
|
465
|
+
|
|
466
|
+
if request_type == ControlRequestType.APPROVAL:
|
|
467
|
+
return caps.supports_approval
|
|
468
|
+
elif request_type == ControlRequestType.INPUT:
|
|
469
|
+
return caps.supports_input
|
|
470
|
+
elif request_type == ControlRequestType.REVIEW:
|
|
471
|
+
return caps.supports_review
|
|
472
|
+
elif request_type == ControlRequestType.ESCALATION:
|
|
473
|
+
return caps.supports_escalation
|
|
474
|
+
else:
|
|
475
|
+
return True
|
|
476
|
+
|
|
477
|
+
def _get_channel_by_id(self, channel_id: str) -> Optional[ControlChannel]:
|
|
478
|
+
"""Get channel by its ID."""
|
|
479
|
+
for channel in self.channels:
|
|
480
|
+
if channel.channel_id == channel_id:
|
|
481
|
+
return channel
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
def _build_request(
|
|
485
|
+
self,
|
|
486
|
+
procedure_id: str,
|
|
487
|
+
request_type: str,
|
|
488
|
+
message: str,
|
|
489
|
+
options: Optional[List[Dict[str, Any]]] = None,
|
|
490
|
+
timeout_seconds: Optional[int] = None,
|
|
491
|
+
default_value: Any = None,
|
|
492
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
493
|
+
procedure_name: str = "Unknown Procedure",
|
|
494
|
+
invocation_id: Optional[str] = None,
|
|
495
|
+
namespace: str = "",
|
|
496
|
+
subject: Optional[str] = None,
|
|
497
|
+
started_at: Optional[datetime] = None,
|
|
498
|
+
input_summary: Optional[Dict[str, Any]] = None,
|
|
499
|
+
conversation: Optional[List[Dict[str, Any]]] = None,
|
|
500
|
+
prior_interactions: Optional[List[Dict[str, Any]]] = None,
|
|
501
|
+
runtime_context: Optional[Dict[str, Any]] = None,
|
|
502
|
+
application_context: Optional[List[Dict[str, Any]]] = None,
|
|
503
|
+
) -> ControlRequest:
|
|
504
|
+
"""Build a ControlRequest from the provided parameters."""
|
|
505
|
+
# CRITICAL: Generate deterministic request_id based on checkpoint position AND run_id
|
|
506
|
+
# Including run_id ensures different runs don't collide in the response cache
|
|
507
|
+
# This allows resume flow to find cached responses within the same run, but not across runs
|
|
508
|
+
checkpoint_position = None
|
|
509
|
+
if self.execution_context and hasattr(self.execution_context, "next_position"):
|
|
510
|
+
checkpoint_position = self.execution_context.next_position()
|
|
511
|
+
|
|
512
|
+
# Get run_id from execution context to ensure cache isolation between runs
|
|
513
|
+
run_id_part = "unknown"
|
|
514
|
+
if self.execution_context and hasattr(self.execution_context, "current_run_id"):
|
|
515
|
+
if self.execution_context.current_run_id:
|
|
516
|
+
# Use first 8 chars of run_id for brevity
|
|
517
|
+
run_id_part = self.execution_context.current_run_id[:8]
|
|
518
|
+
|
|
519
|
+
if checkpoint_position is not None:
|
|
520
|
+
# Deterministic ID: procedure_id:run_id:position
|
|
521
|
+
request_id = f"{procedure_id}:{run_id_part}:pos{checkpoint_position}"
|
|
522
|
+
else:
|
|
523
|
+
# Fallback to random ID (backward compatibility for contexts without position tracking)
|
|
524
|
+
request_id = f"{procedure_id}:{run_id_part}:{uuid.uuid4().hex[:12]}"
|
|
525
|
+
|
|
526
|
+
# Convert options to ControlOption objects
|
|
527
|
+
control_options = []
|
|
528
|
+
if options:
|
|
529
|
+
for opt in options:
|
|
530
|
+
control_options.append(
|
|
531
|
+
ControlOption(
|
|
532
|
+
label=opt.get("label", ""),
|
|
533
|
+
value=opt.get("value", opt.get("label", "")),
|
|
534
|
+
style=opt.get("style", "default"),
|
|
535
|
+
description=opt.get("description"),
|
|
536
|
+
)
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
# Extract items from metadata if this is an 'inputs' request
|
|
540
|
+
items = []
|
|
541
|
+
if request_type == "inputs":
|
|
542
|
+
logger.debug(
|
|
543
|
+
f"Processing inputs request, metadata type: {type(metadata)}, value: {metadata}"
|
|
544
|
+
)
|
|
545
|
+
if metadata and isinstance(metadata, dict) and "items" in metadata:
|
|
546
|
+
from tactus.protocols.control import ControlRequestItem
|
|
547
|
+
|
|
548
|
+
items_data = metadata.get("items", [])
|
|
549
|
+
logger.debug(f"Found {len(items_data)} items in metadata")
|
|
550
|
+
for item_dict in items_data:
|
|
551
|
+
# Convert dict to ControlRequestItem
|
|
552
|
+
items.append(ControlRequestItem(**item_dict))
|
|
553
|
+
|
|
554
|
+
# Convert runtime_context dict to RuntimeContext object
|
|
555
|
+
runtime_ctx_obj = None
|
|
556
|
+
if runtime_context:
|
|
557
|
+
# Convert backtrace entries
|
|
558
|
+
backtrace_entries = []
|
|
559
|
+
for bt in runtime_context.get("backtrace", []):
|
|
560
|
+
backtrace_entries.append(
|
|
561
|
+
BacktraceEntry(
|
|
562
|
+
checkpoint_type=bt.get("checkpoint_type", "unknown"),
|
|
563
|
+
line=bt.get("line"),
|
|
564
|
+
function_name=bt.get("function_name"),
|
|
565
|
+
duration_ms=bt.get("duration_ms"),
|
|
566
|
+
)
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
# Parse started_at if it's a string
|
|
570
|
+
started_at_dt = None
|
|
571
|
+
if runtime_context.get("started_at"):
|
|
572
|
+
started_at_str = runtime_context["started_at"]
|
|
573
|
+
if isinstance(started_at_str, str):
|
|
574
|
+
from dateutil.parser import parse
|
|
575
|
+
|
|
576
|
+
started_at_dt = parse(started_at_str)
|
|
577
|
+
else:
|
|
578
|
+
started_at_dt = started_at_str
|
|
579
|
+
|
|
580
|
+
runtime_ctx_obj = RuntimeContext(
|
|
581
|
+
source_line=runtime_context.get("source_line"),
|
|
582
|
+
source_file=runtime_context.get("source_file"),
|
|
583
|
+
checkpoint_position=runtime_context.get("checkpoint_position", 0),
|
|
584
|
+
procedure_name=runtime_context.get("procedure_name", ""),
|
|
585
|
+
invocation_id=runtime_context.get("invocation_id", ""),
|
|
586
|
+
started_at=started_at_dt,
|
|
587
|
+
elapsed_seconds=runtime_context.get("elapsed_seconds", 0.0),
|
|
588
|
+
backtrace=backtrace_entries,
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
# Convert application_context dicts to ContextLink objects
|
|
592
|
+
app_ctx_objs = []
|
|
593
|
+
if application_context:
|
|
594
|
+
for link in application_context:
|
|
595
|
+
app_ctx_objs.append(
|
|
596
|
+
ContextLink(
|
|
597
|
+
name=link.get("name", ""),
|
|
598
|
+
value=link.get("value", ""),
|
|
599
|
+
url=link.get("url"),
|
|
600
|
+
)
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
return ControlRequest(
|
|
604
|
+
request_id=request_id,
|
|
605
|
+
procedure_id=procedure_id,
|
|
606
|
+
procedure_name=procedure_name,
|
|
607
|
+
invocation_id=invocation_id or procedure_id,
|
|
608
|
+
namespace=namespace,
|
|
609
|
+
subject=subject,
|
|
610
|
+
started_at=started_at or datetime.now(timezone.utc),
|
|
611
|
+
elapsed_seconds=(
|
|
612
|
+
int(
|
|
613
|
+
(
|
|
614
|
+
datetime.now(timezone.utc) - (started_at or datetime.now(timezone.utc))
|
|
615
|
+
).total_seconds()
|
|
616
|
+
)
|
|
617
|
+
if started_at
|
|
618
|
+
else 0
|
|
619
|
+
),
|
|
620
|
+
request_type=ControlRequestType(request_type),
|
|
621
|
+
message=message,
|
|
622
|
+
options=control_options,
|
|
623
|
+
timeout_seconds=timeout_seconds,
|
|
624
|
+
default_value=default_value,
|
|
625
|
+
items=items,
|
|
626
|
+
input_summary=input_summary or {},
|
|
627
|
+
conversation=[], # TODO: Convert conversation dicts
|
|
628
|
+
prior_interactions=[], # TODO: Convert prior_interactions dicts
|
|
629
|
+
runtime_context=runtime_ctx_obj,
|
|
630
|
+
application_context=app_ctx_objs,
|
|
631
|
+
metadata=metadata or {},
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
def _store_pending(self, request: ControlRequest, deliveries: List[DeliveryResult]) -> None:
|
|
635
|
+
"""Store pending request in storage backend."""
|
|
636
|
+
if not self.storage:
|
|
637
|
+
return
|
|
638
|
+
|
|
639
|
+
key = f"{self.PENDING_KEY_PREFIX}{request.request_id}"
|
|
640
|
+
state = self.storage.get_state(request.procedure_id) or {}
|
|
641
|
+
state[key] = {
|
|
642
|
+
"request": request.model_dump(mode="json"),
|
|
643
|
+
"deliveries": [d.model_dump(mode="json") for d in deliveries],
|
|
644
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
645
|
+
}
|
|
646
|
+
self.storage.set_state(request.procedure_id, state)
|
|
647
|
+
|
|
648
|
+
def _store_response(self, request: ControlRequest, response: ControlResponse) -> None:
|
|
649
|
+
"""Store response to a pending request (enables resume)."""
|
|
650
|
+
if not self.storage:
|
|
651
|
+
return
|
|
652
|
+
|
|
653
|
+
key = f"{self.PENDING_KEY_PREFIX}{request.request_id}"
|
|
654
|
+
state = self.storage.get_state(request.procedure_id) or {}
|
|
655
|
+
|
|
656
|
+
# Add response to existing pending request (if it exists) or create new entry
|
|
657
|
+
if key in state:
|
|
658
|
+
state[key]["response"] = response.model_dump(mode="json")
|
|
659
|
+
state[key]["responded_at"] = datetime.now(timezone.utc).isoformat()
|
|
660
|
+
else:
|
|
661
|
+
# Request wasn't pending (immediate response), still store for resume
|
|
662
|
+
state[key] = {
|
|
663
|
+
"request": request.model_dump(mode="json"),
|
|
664
|
+
"response": response.model_dump(mode="json"),
|
|
665
|
+
"responded_at": datetime.now(timezone.utc).isoformat(),
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
self.storage.set_state(request.procedure_id, state)
|
|
669
|
+
|
|
670
|
+
def check_pending_response(
|
|
671
|
+
self,
|
|
672
|
+
procedure_id: str,
|
|
673
|
+
message_id: str,
|
|
674
|
+
) -> Optional[ControlResponse]:
|
|
675
|
+
"""
|
|
676
|
+
Check if there's a response to a pending control request.
|
|
677
|
+
|
|
678
|
+
Used during resume flow to check if controller has responded.
|
|
679
|
+
|
|
680
|
+
Args:
|
|
681
|
+
procedure_id: Unique procedure identifier
|
|
682
|
+
message_id: Request ID (message_id in this context)
|
|
683
|
+
|
|
684
|
+
Returns:
|
|
685
|
+
ControlResponse if response exists, None otherwise
|
|
686
|
+
"""
|
|
687
|
+
if not self.storage:
|
|
688
|
+
return None
|
|
689
|
+
|
|
690
|
+
key = f"{self.PENDING_KEY_PREFIX}{message_id}"
|
|
691
|
+
state = self.storage.get_state(procedure_id) or {}
|
|
692
|
+
|
|
693
|
+
if key in state:
|
|
694
|
+
pending = state[key]
|
|
695
|
+
if pending.get("response"):
|
|
696
|
+
logger.info(f"Found response for request {message_id}")
|
|
697
|
+
return ControlResponse.model_validate(pending["response"])
|
|
698
|
+
|
|
699
|
+
return None
|
|
700
|
+
|
|
701
|
+
def cancel_pending_request(self, procedure_id: str, message_id: str) -> None:
|
|
702
|
+
"""
|
|
703
|
+
Cancel a pending control request.
|
|
704
|
+
|
|
705
|
+
Args:
|
|
706
|
+
procedure_id: Unique procedure identifier
|
|
707
|
+
message_id: Request ID to cancel
|
|
708
|
+
"""
|
|
709
|
+
if not self.storage:
|
|
710
|
+
return
|
|
711
|
+
|
|
712
|
+
key = f"{self.PENDING_KEY_PREFIX}{message_id}"
|
|
713
|
+
state = self.storage.get_state(procedure_id) or {}
|
|
714
|
+
|
|
715
|
+
if key in state:
|
|
716
|
+
del state[key]
|
|
717
|
+
self.storage.set_state(procedure_id, state)
|
|
718
|
+
logger.info(f"Cancelled control request {message_id}")
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
class ControlLoopHITLAdapter:
|
|
722
|
+
"""
|
|
723
|
+
Adapter that makes ControlLoopHandler compatible with the HITLHandler protocol.
|
|
724
|
+
|
|
725
|
+
This allows the new ControlLoopHandler to work with existing runtime code
|
|
726
|
+
that expects the old HITLHandler interface (request: HITLRequest parameter).
|
|
727
|
+
|
|
728
|
+
The adapter converts between HITLRequest/HITLResponse and the expanded
|
|
729
|
+
parameter format that ControlLoopHandler uses.
|
|
730
|
+
"""
|
|
731
|
+
|
|
732
|
+
def __init__(self, control_handler: ControlLoopHandler, execution_context=None):
|
|
733
|
+
"""
|
|
734
|
+
Initialize adapter.
|
|
735
|
+
|
|
736
|
+
Args:
|
|
737
|
+
control_handler: The ControlLoopHandler to wrap
|
|
738
|
+
execution_context: ExecutionContext for gathering rich metadata
|
|
739
|
+
"""
|
|
740
|
+
self.control_handler = control_handler
|
|
741
|
+
self.execution_context = execution_context
|
|
742
|
+
|
|
743
|
+
def request_interaction(self, procedure_id: str, request, execution_context=None):
|
|
744
|
+
"""
|
|
745
|
+
Request interaction using HITLRequest format.
|
|
746
|
+
|
|
747
|
+
Converts HITLRequest to ControlLoopHandler's expanded parameters.
|
|
748
|
+
|
|
749
|
+
Args:
|
|
750
|
+
procedure_id: Procedure identifier
|
|
751
|
+
request: HITLRequest (old format) or dict with request details
|
|
752
|
+
execution_context: Optional execution context for rich metadata
|
|
753
|
+
|
|
754
|
+
Returns:
|
|
755
|
+
HITLResponse (converted from ControlResponse)
|
|
756
|
+
|
|
757
|
+
Raises:
|
|
758
|
+
ProcedureWaitingForHuman: To trigger exit-and-resume
|
|
759
|
+
"""
|
|
760
|
+
from tactus.protocols.models import HITLResponse
|
|
761
|
+
|
|
762
|
+
# Extract request fields (handle both HITLRequest objects and dicts)
|
|
763
|
+
if hasattr(request, "request_type"):
|
|
764
|
+
request_type = request.request_type
|
|
765
|
+
message = request.message
|
|
766
|
+
options = request.options
|
|
767
|
+
timeout_seconds = request.timeout_seconds
|
|
768
|
+
default_value = request.default_value
|
|
769
|
+
metadata = request.metadata or {}
|
|
770
|
+
else:
|
|
771
|
+
request_type = request.get("request_type")
|
|
772
|
+
message = request.get("message")
|
|
773
|
+
options = request.get("options")
|
|
774
|
+
timeout_seconds = request.get("timeout_seconds")
|
|
775
|
+
default_value = request.get("default_value")
|
|
776
|
+
metadata = request.get("metadata", {})
|
|
777
|
+
|
|
778
|
+
# Use provided execution_context or fall back to instance one
|
|
779
|
+
ctx = execution_context or self.execution_context
|
|
780
|
+
|
|
781
|
+
# CRITICAL: Pass execution_context to control_handler for deterministic request IDs
|
|
782
|
+
# This allows _build_request to use next_position() for stable request_id generation
|
|
783
|
+
old_ctx = self.control_handler.execution_context
|
|
784
|
+
if ctx:
|
|
785
|
+
self.control_handler.execution_context = ctx
|
|
786
|
+
|
|
787
|
+
# Gather rich context from execution context if available
|
|
788
|
+
procedure_name = "Unknown Procedure"
|
|
789
|
+
invocation_id = None
|
|
790
|
+
subject = None
|
|
791
|
+
started_at = None
|
|
792
|
+
input_summary = None
|
|
793
|
+
conversation = None
|
|
794
|
+
prior_interactions = None
|
|
795
|
+
|
|
796
|
+
if ctx:
|
|
797
|
+
procedure_name = getattr(ctx, "procedure_name", procedure_name)
|
|
798
|
+
invocation_id = getattr(ctx, "invocation_id", invocation_id)
|
|
799
|
+
|
|
800
|
+
# Try to get additional context if methods exist
|
|
801
|
+
if hasattr(ctx, "get_subject"):
|
|
802
|
+
subject = ctx.get_subject()
|
|
803
|
+
if hasattr(ctx, "get_started_at"):
|
|
804
|
+
started_at = ctx.get_started_at()
|
|
805
|
+
if hasattr(ctx, "get_input_summary"):
|
|
806
|
+
input_summary = ctx.get_input_summary()
|
|
807
|
+
if hasattr(ctx, "get_conversation_history"):
|
|
808
|
+
conversation = ctx.get_conversation_history()
|
|
809
|
+
if hasattr(ctx, "get_prior_control_interactions"):
|
|
810
|
+
prior_interactions = ctx.get_prior_control_interactions()
|
|
811
|
+
|
|
812
|
+
# Get runtime context for HITL display (new context architecture)
|
|
813
|
+
runtime_context = None
|
|
814
|
+
if ctx and hasattr(ctx, "get_runtime_context"):
|
|
815
|
+
runtime_context = ctx.get_runtime_context()
|
|
816
|
+
|
|
817
|
+
# Application context would be passed from the host application
|
|
818
|
+
# For now, we don't have a way to pass it through, but the protocol supports it
|
|
819
|
+
application_context = None
|
|
820
|
+
|
|
821
|
+
try:
|
|
822
|
+
# Call ControlLoopHandler with expanded parameters
|
|
823
|
+
control_response = self.control_handler.request_interaction(
|
|
824
|
+
procedure_id=procedure_id,
|
|
825
|
+
request_type=request_type,
|
|
826
|
+
message=message,
|
|
827
|
+
options=options,
|
|
828
|
+
timeout_seconds=timeout_seconds,
|
|
829
|
+
default_value=default_value,
|
|
830
|
+
metadata=metadata,
|
|
831
|
+
# Rich context
|
|
832
|
+
procedure_name=procedure_name,
|
|
833
|
+
invocation_id=invocation_id,
|
|
834
|
+
namespace=metadata.get("namespace", ""),
|
|
835
|
+
subject=subject,
|
|
836
|
+
started_at=started_at,
|
|
837
|
+
input_summary=input_summary,
|
|
838
|
+
conversation=conversation,
|
|
839
|
+
prior_interactions=prior_interactions,
|
|
840
|
+
# New context architecture
|
|
841
|
+
runtime_context=runtime_context,
|
|
842
|
+
application_context=application_context,
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
# Convert ControlResponse to HITLResponse
|
|
846
|
+
return HITLResponse(
|
|
847
|
+
value=control_response.value,
|
|
848
|
+
responded_at=control_response.responded_at,
|
|
849
|
+
timed_out=control_response.timed_out,
|
|
850
|
+
responder_id=control_response.responder_id,
|
|
851
|
+
channel=control_response.channel_id,
|
|
852
|
+
)
|
|
853
|
+
finally:
|
|
854
|
+
# Restore original execution context
|
|
855
|
+
self.control_handler.execution_context = old_ctx
|
|
856
|
+
|
|
857
|
+
def check_pending_response(self, procedure_id: str, message_id: str):
|
|
858
|
+
"""
|
|
859
|
+
Check for pending response.
|
|
860
|
+
|
|
861
|
+
Delegates to ControlLoopHandler and converts response format.
|
|
862
|
+
"""
|
|
863
|
+
from tactus.protocols.models import HITLResponse
|
|
864
|
+
|
|
865
|
+
control_response = self.control_handler.check_pending_response(procedure_id, message_id)
|
|
866
|
+
if control_response is None:
|
|
867
|
+
return None
|
|
868
|
+
|
|
869
|
+
return HITLResponse(
|
|
870
|
+
value=control_response.value,
|
|
871
|
+
responded_at=control_response.responded_at,
|
|
872
|
+
timed_out=control_response.timed_out,
|
|
873
|
+
responder_id=control_response.responder_id,
|
|
874
|
+
channel=control_response.channel_id,
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
def cancel_pending_request(self, procedure_id: str, message_id: str) -> None:
|
|
878
|
+
"""Cancel pending request - delegates to ControlLoopHandler."""
|
|
879
|
+
self.control_handler.cancel_pending_request(procedure_id, message_id)
|