tactus 0.33.0__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.33.0.dist-info → tactus-0.34.0.dist-info}/METADATA +14 -2
  97. {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/RECORD +100 -49
  98. {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/WHEEL +0 -0
  99. {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/entry_points.txt +0 -0
  100. {tactus-0.33.0.dist-info → tactus-0.34.0.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)