tactus 0.34.0__py3-none-any.whl → 0.35.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 (81) hide show
  1. tactus/__init__.py +1 -1
  2. tactus/adapters/broker_log.py +17 -14
  3. tactus/adapters/channels/__init__.py +17 -15
  4. tactus/adapters/channels/base.py +16 -7
  5. tactus/adapters/channels/broker.py +43 -13
  6. tactus/adapters/channels/cli.py +19 -15
  7. tactus/adapters/channels/host.py +15 -6
  8. tactus/adapters/channels/ipc.py +82 -31
  9. tactus/adapters/channels/sse.py +41 -23
  10. tactus/adapters/cli_hitl.py +19 -19
  11. tactus/adapters/cli_log.py +4 -4
  12. tactus/adapters/control_loop.py +138 -99
  13. tactus/adapters/cost_collector_log.py +9 -9
  14. tactus/adapters/file_storage.py +56 -52
  15. tactus/adapters/http_callback_log.py +23 -13
  16. tactus/adapters/ide_log.py +17 -9
  17. tactus/adapters/lua_tools.py +4 -5
  18. tactus/adapters/mcp.py +16 -19
  19. tactus/adapters/mcp_manager.py +46 -30
  20. tactus/adapters/memory.py +9 -9
  21. tactus/adapters/plugins.py +42 -42
  22. tactus/broker/client.py +75 -78
  23. tactus/broker/protocol.py +57 -57
  24. tactus/broker/server.py +252 -197
  25. tactus/cli/app.py +3 -1
  26. tactus/cli/control.py +2 -2
  27. tactus/core/config_manager.py +181 -135
  28. tactus/core/dependencies/registry.py +66 -48
  29. tactus/core/dsl_stubs.py +222 -163
  30. tactus/core/exceptions.py +10 -1
  31. tactus/core/execution_context.py +152 -112
  32. tactus/core/lua_sandbox.py +72 -64
  33. tactus/core/message_history_manager.py +138 -43
  34. tactus/core/mocking.py +41 -27
  35. tactus/core/output_validator.py +49 -44
  36. tactus/core/registry.py +94 -80
  37. tactus/core/runtime.py +211 -176
  38. tactus/core/template_resolver.py +16 -16
  39. tactus/core/yaml_parser.py +55 -45
  40. tactus/docs/extractor.py +7 -6
  41. tactus/ide/server.py +119 -78
  42. tactus/primitives/control.py +10 -6
  43. tactus/primitives/file.py +48 -46
  44. tactus/primitives/handles.py +47 -35
  45. tactus/primitives/host.py +29 -27
  46. tactus/primitives/human.py +154 -137
  47. tactus/primitives/json.py +22 -23
  48. tactus/primitives/log.py +26 -26
  49. tactus/primitives/message_history.py +285 -31
  50. tactus/primitives/model.py +15 -9
  51. tactus/primitives/procedure.py +86 -64
  52. tactus/primitives/procedure_callable.py +58 -51
  53. tactus/primitives/retry.py +31 -29
  54. tactus/primitives/session.py +42 -29
  55. tactus/primitives/state.py +54 -43
  56. tactus/primitives/step.py +9 -13
  57. tactus/primitives/system.py +34 -21
  58. tactus/primitives/tool.py +44 -31
  59. tactus/primitives/tool_handle.py +76 -54
  60. tactus/primitives/toolset.py +25 -22
  61. tactus/sandbox/config.py +4 -4
  62. tactus/sandbox/container_runner.py +161 -107
  63. tactus/sandbox/docker_manager.py +20 -20
  64. tactus/sandbox/entrypoint.py +16 -14
  65. tactus/sandbox/protocol.py +15 -15
  66. tactus/stdlib/classify/llm.py +1 -3
  67. tactus/stdlib/core/validation.py +0 -3
  68. tactus/testing/pydantic_eval_runner.py +1 -1
  69. tactus/utils/asyncio_helpers.py +27 -0
  70. tactus/utils/cost_calculator.py +7 -7
  71. tactus/utils/model_pricing.py +11 -12
  72. tactus/utils/safe_file_library.py +156 -132
  73. tactus/utils/safe_libraries.py +27 -27
  74. tactus/validation/error_listener.py +18 -5
  75. tactus/validation/semantic_visitor.py +392 -333
  76. tactus/validation/validator.py +89 -49
  77. {tactus-0.34.0.dist-info → tactus-0.35.0.dist-info}/METADATA +12 -3
  78. {tactus-0.34.0.dist-info → tactus-0.35.0.dist-info}/RECORD +81 -80
  79. {tactus-0.34.0.dist-info → tactus-0.35.0.dist-info}/WHEEL +0 -0
  80. {tactus-0.34.0.dist-info → tactus-0.35.0.dist-info}/entry_points.txt +0 -0
  81. {tactus-0.34.0.dist-info → tactus-0.35.0.dist-info}/licenses/LICENSE +0 -0
@@ -11,7 +11,7 @@ import asyncio
11
11
  import logging
12
12
  import uuid
13
13
  from datetime import datetime, timezone
14
- from typing import List, Optional, Dict, Any
14
+ from typing import Any, Optional
15
15
 
16
16
  from tactus.core.exceptions import ProcedureWaitingForHuman
17
17
  from tactus.protocols.control import (
@@ -62,7 +62,7 @@ class ControlLoopHandler:
62
62
 
63
63
  def __init__(
64
64
  self,
65
- channels: List[ControlChannel],
65
+ channels: list[ControlChannel],
66
66
  storage: Optional[StorageBackend] = None,
67
67
  immediate_response_timeout: float = 0.5,
68
68
  execution_context=None,
@@ -83,7 +83,11 @@ class ControlLoopHandler:
83
83
  self._channels_initialized = False
84
84
 
85
85
  channel_ids = [c.channel_id for c in channels]
86
- logger.info(f"ControlLoopHandler initialized with {len(channels)} channels: {channel_ids}")
86
+ logger.info(
87
+ "ControlLoopHandler initialized with %s channels: %s",
88
+ len(channels),
89
+ channel_ids,
90
+ )
87
91
 
88
92
  async def initialize_channels(self) -> None:
89
93
  """
@@ -98,8 +102,8 @@ class ControlLoopHandler:
98
102
  if not self.channels:
99
103
  return
100
104
 
101
- tasks = [channel.initialize() for channel in self.channels]
102
- await asyncio.gather(*tasks, return_exceptions=True)
105
+ initialize_tasks = [channel.initialize() for channel in self.channels]
106
+ await asyncio.gather(*initialize_tasks, return_exceptions=True)
103
107
  self._channels_initialized = True
104
108
 
105
109
  async def shutdown_channels(self) -> None:
@@ -111,30 +115,30 @@ class ControlLoopHandler:
111
115
  if not self.channels:
112
116
  return
113
117
 
114
- tasks = [channel.shutdown() for channel in self.channels]
115
- await asyncio.gather(*tasks, return_exceptions=True)
118
+ shutdown_tasks = [channel.shutdown() for channel in self.channels]
119
+ await asyncio.gather(*shutdown_tasks, return_exceptions=True)
116
120
 
117
121
  def request_interaction(
118
122
  self,
119
123
  procedure_id: str,
120
124
  request_type: str,
121
125
  message: str,
122
- options: Optional[List[Dict[str, Any]]] = None,
126
+ options: Optional[list[dict[str, Any]]] = None,
123
127
  timeout_seconds: Optional[int] = None,
124
128
  default_value: Any = None,
125
- metadata: Optional[Dict[str, Any]] = None,
129
+ metadata: Optional[dict[str, Any]] = None,
126
130
  # Rich context
127
131
  procedure_name: str = "Unknown Procedure",
128
132
  invocation_id: Optional[str] = None,
129
133
  namespace: str = "",
130
134
  subject: Optional[str] = None,
131
135
  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,
136
+ input_summary: Optional[dict[str, Any]] = None,
137
+ conversation: Optional[list[dict[str, Any]]] = None,
138
+ prior_interactions: Optional[list[dict[str, Any]]] = None,
135
139
  # New context architecture
136
- runtime_context: Optional[Dict[str, Any]] = None,
137
- application_context: Optional[List[Dict[str, Any]]] = None,
140
+ runtime_context: Optional[dict[str, Any]] = None,
141
+ application_context: Optional[list[dict[str, Any]]] = None,
138
142
  ) -> ControlResponse:
139
143
  """
140
144
  Request controller interaction by sending to all channels.
@@ -190,25 +194,44 @@ class ControlLoopHandler:
190
194
  )
191
195
 
192
196
  logger.info(
193
- f"Control request {request.request_id} for procedure {procedure_id}: "
194
- f"{request_type} - {message[:50]}..."
197
+ "Control request %s for procedure %s: %s - %s...",
198
+ request.request_id,
199
+ procedure_id,
200
+ request_type,
201
+ message[:50],
195
202
  )
196
203
 
197
204
  # Run the async request flow
198
205
  # Check if we're already in an async context
199
206
  try:
200
- loop = asyncio.get_running_loop()
207
+ event_loop = asyncio.get_running_loop()
208
+ if event_loop.is_closed():
209
+ raise RuntimeError("Running event loop is closed")
210
+
201
211
  # Already in async context - create task and run it
202
212
  # This shouldn't normally happen since request_interaction is sync
203
213
  import nest_asyncio
204
214
 
205
215
  nest_asyncio.apply()
206
- return loop.run_until_complete(self._request_interaction_async(request))
216
+ return event_loop.run_until_complete(self._request_interaction_async(request))
207
217
  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
- )
218
+ # Not in async context - create a temporary event loop.
219
+ previous_event_loop: asyncio.AbstractEventLoop | None = None
220
+ try:
221
+ previous_event_loop = asyncio.get_event_loop()
222
+ except RuntimeError:
223
+ previous_event_loop = None
224
+ else:
225
+ if getattr(previous_event_loop, "is_closed", lambda: False)():
226
+ previous_event_loop = None
227
+
228
+ event_loop = asyncio.new_event_loop()
229
+ try:
230
+ asyncio.set_event_loop(event_loop)
231
+ return event_loop.run_until_complete(self._request_interaction_async(request))
232
+ finally:
233
+ event_loop.close()
234
+ asyncio.set_event_loop(previous_event_loop)
212
235
 
213
236
  async def _request_interaction_async(self, request: ControlRequest) -> ControlResponse:
214
237
  """
@@ -220,7 +243,7 @@ class ControlLoopHandler:
220
243
  if self.storage:
221
244
  cached_response = self.check_pending_response(request.procedure_id, request.request_id)
222
245
  if cached_response:
223
- logger.info(f"RESUME: Using cached response for {request.request_id}")
246
+ logger.info("RESUME: Using cached response for %s", request.request_id)
224
247
  return cached_response
225
248
 
226
249
  # Initialize channels on first use
@@ -241,14 +264,20 @@ class ControlLoopHandler:
241
264
  deliveries = await self._fanout(request, eligible_channels)
242
265
 
243
266
  # Log delivery results
244
- successful = [d for d in deliveries if d.success]
245
- failed = [d for d in deliveries if not d.success]
267
+ successful = [delivery for delivery in deliveries if delivery.success]
268
+ failed = [delivery for delivery in deliveries if not delivery.success]
246
269
  logger.info(
247
- f"Control request {request.request_id}: "
248
- f"{len(successful)} successful deliveries, {len(failed)} failed"
270
+ "Control request %s: %s successful deliveries, %s failed",
271
+ request.request_id,
272
+ len(successful),
273
+ len(failed),
249
274
  )
250
- for d in failed:
251
- logger.warning(f" Failed delivery to {d.channel_id}: {d.error_message}")
275
+ for delivery in failed:
276
+ logger.warning(
277
+ " Failed delivery to %s: %s",
278
+ delivery.channel_id,
279
+ delivery.error_message,
280
+ )
252
281
 
253
282
  if not successful:
254
283
  raise RuntimeError("All channel deliveries failed")
@@ -264,7 +293,10 @@ class ControlLoopHandler:
264
293
  # Store response for future resume
265
294
  if self.storage:
266
295
  self._store_response(request, response)
267
- logger.info(f"Stored response for {request.request_id} (enables resume)")
296
+ logger.info(
297
+ "Stored response for %s (enables resume)",
298
+ request.request_id,
299
+ )
268
300
 
269
301
  # Cancel all other channels
270
302
  await self._cancel_other_channels(
@@ -283,8 +315,8 @@ class ControlLoopHandler:
283
315
  async def _fanout(
284
316
  self,
285
317
  request: ControlRequest,
286
- channels: List[ControlChannel],
287
- ) -> List[DeliveryResult]:
318
+ channels: list[ControlChannel],
319
+ ) -> list[DeliveryResult]:
288
320
  """
289
321
  Send request to all channels concurrently.
290
322
 
@@ -295,8 +327,8 @@ class ControlLoopHandler:
295
327
  Returns:
296
328
  List of delivery results
297
329
  """
298
- tasks = [self._send_with_error_handling(channel, request) for channel in channels]
299
- results = await asyncio.gather(*tasks)
330
+ send_tasks = [self._send_with_error_handling(channel, request) for channel in channels]
331
+ results = await asyncio.gather(*send_tasks)
300
332
  return list(results)
301
333
 
302
334
  async def _send_with_error_handling(
@@ -309,21 +341,21 @@ class ControlLoopHandler:
309
341
  """
310
342
  try:
311
343
  return await channel.send(request)
312
- except Exception as e:
313
- logger.exception(f"Failed to send to {channel.channel_id}")
344
+ except Exception as error:
345
+ logger.exception("Failed to send to %s", channel.channel_id)
314
346
  return DeliveryResult(
315
347
  channel_id=channel.channel_id,
316
348
  external_message_id="",
317
349
  delivered_at=datetime.now(timezone.utc),
318
350
  success=False,
319
- error_message=str(e),
351
+ error_message=str(error),
320
352
  )
321
353
 
322
354
  async def _wait_for_first_response(
323
355
  self,
324
356
  request: ControlRequest,
325
- channels: List[ControlChannel],
326
- deliveries: List[DeliveryResult],
357
+ channels: list[ControlChannel],
358
+ deliveries: list[DeliveryResult],
327
359
  ) -> Optional[ControlResponse]:
328
360
  """
329
361
  Wait for first response from any channel.
@@ -346,7 +378,7 @@ class ControlLoopHandler:
346
378
  timeout = self.immediate_response_timeout if not has_sync_channel else 30.0
347
379
 
348
380
  # Create tasks for each channel's receive iterator
349
- receive_tasks = []
381
+ receive_tasks: list[tuple[ControlChannel, asyncio.Task[Optional[ControlResponse]]]] = []
350
382
  for channel in channels:
351
383
  task = asyncio.create_task(
352
384
  self._get_first_from_channel(channel),
@@ -378,8 +410,8 @@ class ControlLoopHandler:
378
410
  return response
379
411
  except asyncio.CancelledError:
380
412
  pass
381
- except Exception as e:
382
- logger.debug(f"Task exception: {e}")
413
+ except Exception as error:
414
+ logger.debug("Task exception: %s", error)
383
415
 
384
416
  return None
385
417
 
@@ -393,19 +425,23 @@ class ControlLoopHandler:
393
425
  """Get first response from a channel's receive iterator."""
394
426
  try:
395
427
  async for response in channel.receive():
396
- logger.info(f"Received response from {channel.channel_id}: {response.request_id}")
428
+ logger.info(
429
+ "Received response from %s: %s",
430
+ channel.channel_id,
431
+ response.request_id,
432
+ )
397
433
  return response
398
434
  except asyncio.CancelledError:
399
435
  raise
400
- except Exception as e:
401
- logger.debug(f"Error receiving from {channel.channel_id}: {e}")
436
+ except Exception as error:
437
+ logger.debug("Error receiving from %s: %s", channel.channel_id, error)
402
438
  return None
403
439
  return None
404
440
 
405
441
  async def _cancel_other_channels(
406
442
  self,
407
443
  request: ControlRequest,
408
- deliveries: List[DeliveryResult],
444
+ deliveries: list[DeliveryResult],
409
445
  winning_channel: Optional[str],
410
446
  ) -> None:
411
447
  """
@@ -444,11 +480,11 @@ class ControlLoopHandler:
444
480
  try:
445
481
  await channel.cancel(external_message_id, reason)
446
482
  except Exception:
447
- logger.exception(f"Failed to cancel on {channel.channel_id}")
483
+ logger.exception("Failed to cancel on %s", channel.channel_id)
448
484
 
449
- def _get_eligible_channels(self, request: ControlRequest) -> List[ControlChannel]:
485
+ def _get_eligible_channels(self, request: ControlRequest) -> list[ControlChannel]:
450
486
  """Get channels that support the given request type."""
451
- eligible = []
487
+ eligible: list[ControlChannel] = []
452
488
  for channel in self.channels:
453
489
  if self._channel_supports_request(channel, request):
454
490
  eligible.append(channel)
@@ -486,20 +522,20 @@ class ControlLoopHandler:
486
522
  procedure_id: str,
487
523
  request_type: str,
488
524
  message: str,
489
- options: Optional[List[Dict[str, Any]]] = None,
525
+ options: Optional[list[dict[str, Any]]] = None,
490
526
  timeout_seconds: Optional[int] = None,
491
527
  default_value: Any = None,
492
- metadata: Optional[Dict[str, Any]] = None,
528
+ metadata: Optional[dict[str, Any]] = None,
493
529
  procedure_name: str = "Unknown Procedure",
494
530
  invocation_id: Optional[str] = None,
495
531
  namespace: str = "",
496
532
  subject: Optional[str] = None,
497
533
  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,
534
+ input_summary: Optional[dict[str, Any]] = None,
535
+ conversation: Optional[list[dict[str, Any]]] = None,
536
+ prior_interactions: Optional[list[dict[str, Any]]] = None,
537
+ runtime_context: Optional[dict[str, Any]] = None,
538
+ application_context: Optional[list[dict[str, Any]]] = None,
503
539
  ) -> ControlRequest:
504
540
  """Build a ControlRequest from the provided parameters."""
505
541
  # CRITICAL: Generate deterministic request_id based on checkpoint position AND run_id
@@ -510,21 +546,21 @@ class ControlLoopHandler:
510
546
  checkpoint_position = self.execution_context.next_position()
511
547
 
512
548
  # Get run_id from execution context to ensure cache isolation between runs
513
- run_id_part = "unknown"
549
+ run_id_prefix = "unknown"
514
550
  if self.execution_context and hasattr(self.execution_context, "current_run_id"):
515
551
  if self.execution_context.current_run_id:
516
552
  # Use first 8 chars of run_id for brevity
517
- run_id_part = self.execution_context.current_run_id[:8]
553
+ run_id_prefix = self.execution_context.current_run_id[:8]
518
554
 
519
555
  if checkpoint_position is not None:
520
556
  # Deterministic ID: procedure_id:run_id:position
521
- request_id = f"{procedure_id}:{run_id_part}:pos{checkpoint_position}"
557
+ request_id = f"{procedure_id}:{run_id_prefix}:pos{checkpoint_position}"
522
558
  else:
523
559
  # Fallback to random ID (backward compatibility for contexts without position tracking)
524
- request_id = f"{procedure_id}:{run_id_part}:{uuid.uuid4().hex[:12]}"
560
+ request_id = f"{procedure_id}:{run_id_prefix}:{uuid.uuid4().hex[:12]}"
525
561
 
526
562
  # Convert options to ControlOption objects
527
- control_options = []
563
+ control_options: list[ControlOption] = []
528
564
  if options:
529
565
  for opt in options:
530
566
  control_options.append(
@@ -540,14 +576,16 @@ class ControlLoopHandler:
540
576
  items = []
541
577
  if request_type == "inputs":
542
578
  logger.debug(
543
- f"Processing inputs request, metadata type: {type(metadata)}, value: {metadata}"
579
+ "Processing inputs request, metadata type: %s, value: %s",
580
+ type(metadata),
581
+ metadata,
544
582
  )
545
583
  if metadata and isinstance(metadata, dict) and "items" in metadata:
546
584
  from tactus.protocols.control import ControlRequestItem
547
585
 
548
- items_data = metadata.get("items", [])
549
- logger.debug(f"Found {len(items_data)} items in metadata")
550
- for item_dict in items_data:
586
+ item_entries = metadata.get("items", [])
587
+ logger.debug("Found %s items in metadata", len(item_entries))
588
+ for item_dict in item_entries:
551
589
  # Convert dict to ControlRequestItem
552
590
  items.append(ControlRequestItem(**item_dict))
553
591
 
@@ -589,7 +627,7 @@ class ControlLoopHandler:
589
627
  )
590
628
 
591
629
  # Convert application_context dicts to ContextLink objects
592
- app_ctx_objs = []
630
+ app_ctx_objs: list[ContextLink] = []
593
631
  if application_context:
594
632
  for link in application_context:
595
633
  app_ctx_objs.append(
@@ -631,7 +669,7 @@ class ControlLoopHandler:
631
669
  metadata=metadata or {},
632
670
  )
633
671
 
634
- def _store_pending(self, request: ControlRequest, deliveries: List[DeliveryResult]) -> None:
672
+ def _store_pending(self, request: ControlRequest, deliveries: list[DeliveryResult]) -> None:
635
673
  """Store pending request in storage backend."""
636
674
  if not self.storage:
637
675
  return
@@ -670,7 +708,7 @@ class ControlLoopHandler:
670
708
  def check_pending_response(
671
709
  self,
672
710
  procedure_id: str,
673
- message_id: str,
711
+ request_id: str,
674
712
  ) -> Optional[ControlResponse]:
675
713
  """
676
714
  Check if there's a response to a pending control request.
@@ -679,7 +717,7 @@ class ControlLoopHandler:
679
717
 
680
718
  Args:
681
719
  procedure_id: Unique procedure identifier
682
- message_id: Request ID (message_id in this context)
720
+ request_id: Request ID (message_id in this context)
683
721
 
684
722
  Returns:
685
723
  ControlResponse if response exists, None otherwise
@@ -687,35 +725,35 @@ class ControlLoopHandler:
687
725
  if not self.storage:
688
726
  return None
689
727
 
690
- key = f"{self.PENDING_KEY_PREFIX}{message_id}"
728
+ key = f"{self.PENDING_KEY_PREFIX}{request_id}"
691
729
  state = self.storage.get_state(procedure_id) or {}
692
730
 
693
731
  if key in state:
694
732
  pending = state[key]
695
733
  if pending.get("response"):
696
- logger.info(f"Found response for request {message_id}")
734
+ logger.info("Found response for request %s", request_id)
697
735
  return ControlResponse.model_validate(pending["response"])
698
736
 
699
737
  return None
700
738
 
701
- def cancel_pending_request(self, procedure_id: str, message_id: str) -> None:
739
+ def cancel_pending_request(self, procedure_id: str, request_id: str) -> None:
702
740
  """
703
741
  Cancel a pending control request.
704
742
 
705
743
  Args:
706
744
  procedure_id: Unique procedure identifier
707
- message_id: Request ID to cancel
745
+ request_id: Request ID to cancel
708
746
  """
709
747
  if not self.storage:
710
748
  return
711
749
 
712
- key = f"{self.PENDING_KEY_PREFIX}{message_id}"
750
+ key = f"{self.PENDING_KEY_PREFIX}{request_id}"
713
751
  state = self.storage.get_state(procedure_id) or {}
714
752
 
715
753
  if key in state:
716
754
  del state[key]
717
755
  self.storage.set_state(procedure_id, state)
718
- logger.info(f"Cancelled control request {message_id}")
756
+ logger.info("Cancelled control request %s", request_id)
719
757
 
720
758
 
721
759
  class ControlLoopHITLAdapter:
@@ -776,13 +814,13 @@ class ControlLoopHITLAdapter:
776
814
  metadata = request.get("metadata", {})
777
815
 
778
816
  # Use provided execution_context or fall back to instance one
779
- ctx = execution_context or self.execution_context
817
+ execution_context_to_use = execution_context or self.execution_context
780
818
 
781
819
  # CRITICAL: Pass execution_context to control_handler for deterministic request IDs
782
820
  # 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
821
+ previous_execution_context = self.control_handler.execution_context
822
+ if execution_context_to_use:
823
+ self.control_handler.execution_context = execution_context_to_use
786
824
 
787
825
  # Gather rich context from execution context if available
788
826
  procedure_name = "Unknown Procedure"
@@ -793,26 +831,26 @@ class ControlLoopHITLAdapter:
793
831
  conversation = None
794
832
  prior_interactions = None
795
833
 
796
- if ctx:
797
- procedure_name = getattr(ctx, "procedure_name", procedure_name)
798
- invocation_id = getattr(ctx, "invocation_id", invocation_id)
834
+ if execution_context_to_use:
835
+ procedure_name = getattr(execution_context_to_use, "procedure_name", procedure_name)
836
+ invocation_id = getattr(execution_context_to_use, "invocation_id", invocation_id)
799
837
 
800
838
  # 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()
839
+ if hasattr(execution_context_to_use, "get_subject"):
840
+ subject = execution_context_to_use.get_subject()
841
+ if hasattr(execution_context_to_use, "get_started_at"):
842
+ started_at = execution_context_to_use.get_started_at()
843
+ if hasattr(execution_context_to_use, "get_input_summary"):
844
+ input_summary = execution_context_to_use.get_input_summary()
845
+ if hasattr(execution_context_to_use, "get_conversation_history"):
846
+ conversation = execution_context_to_use.get_conversation_history()
847
+ if hasattr(execution_context_to_use, "get_prior_control_interactions"):
848
+ prior_interactions = execution_context_to_use.get_prior_control_interactions()
811
849
 
812
850
  # Get runtime context for HITL display (new context architecture)
813
851
  runtime_context = None
814
- if ctx and hasattr(ctx, "get_runtime_context"):
815
- runtime_context = ctx.get_runtime_context()
852
+ if execution_context_to_use and hasattr(execution_context_to_use, "get_runtime_context"):
853
+ runtime_context = execution_context_to_use.get_runtime_context()
816
854
 
817
855
  # Application context would be passed from the host application
818
856
  # For now, we don't have a way to pass it through, but the protocol supports it
@@ -852,9 +890,10 @@ class ControlLoopHITLAdapter:
852
890
  )
853
891
  finally:
854
892
  # Restore original execution context
855
- self.control_handler.execution_context = old_ctx
893
+ if execution_context_to_use:
894
+ self.control_handler.execution_context = previous_execution_context
856
895
 
857
- def check_pending_response(self, procedure_id: str, message_id: str):
896
+ def check_pending_response(self, procedure_id: str, request_id: str):
858
897
  """
859
898
  Check for pending response.
860
899
 
@@ -862,7 +901,7 @@ class ControlLoopHITLAdapter:
862
901
  """
863
902
  from tactus.protocols.models import HITLResponse
864
903
 
865
- control_response = self.control_handler.check_pending_response(procedure_id, message_id)
904
+ control_response = self.control_handler.check_pending_response(procedure_id, request_id)
866
905
  if control_response is None:
867
906
  return None
868
907
 
@@ -874,6 +913,6 @@ class ControlLoopHITLAdapter:
874
913
  channel=control_response.channel_id,
875
914
  )
876
915
 
877
- def cancel_pending_request(self, procedure_id: str, message_id: str) -> None:
916
+ def cancel_pending_request(self, procedure_id: str, request_id: str) -> None:
878
917
  """Cancel pending request - delegates to ControlLoopHandler."""
879
- self.control_handler.cancel_pending_request(procedure_id, message_id)
918
+ self.control_handler.cancel_pending_request(procedure_id, request_id)
@@ -7,9 +7,8 @@ without enabling streaming UI behavior.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- import logging
11
10
  import json
12
- from typing import List
11
+ import logging
13
12
 
14
13
  from tactus.protocols.models import CostEvent, LogEvent
15
14
 
@@ -29,7 +28,7 @@ class CostCollectorLogHandler:
29
28
  supports_streaming = False
30
29
 
31
30
  def __init__(self):
32
- self.cost_events: List[CostEvent] = []
31
+ self.cost_events: list[CostEvent] = []
33
32
  logger.debug("CostCollectorLogHandler initialized")
34
33
 
35
34
  def log(self, event: LogEvent) -> None:
@@ -41,16 +40,17 @@ class CostCollectorLogHandler:
41
40
  if isinstance(event, LogEvent):
42
41
  event_logger = logging.getLogger(event.logger_name or "procedure")
43
42
 
44
- msg = event.message
43
+ message_text = event.message
45
44
  if event.context:
46
- msg = f"{msg}\nContext: {json.dumps(event.context, indent=2, default=str)}"
45
+ context_json = json.dumps(event.context, indent=2, default=str)
46
+ message_text = f"{message_text}\nContext: {context_json}"
47
47
 
48
48
  level = (event.level or "INFO").upper()
49
49
  if level == "DEBUG":
50
- event_logger.debug(msg)
50
+ event_logger.debug(message_text)
51
51
  elif level in ("WARN", "WARNING"):
52
- event_logger.warning(msg)
52
+ event_logger.warning(message_text)
53
53
  elif level == "ERROR":
54
- event_logger.error(msg)
54
+ event_logger.error(message_text)
55
55
  else:
56
- event_logger.info(msg)
56
+ event_logger.info(message_text)