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
tactus/__init__.py CHANGED
@@ -5,7 +5,7 @@ Tactus provides a declarative workflow engine for AI agents with pluggable
5
5
  backends for storage, HITL, and chat recording.
6
6
  """
7
7
 
8
- __version__ = "0.34.0"
8
+ __version__ = "0.35.0"
9
9
 
10
10
  # Core exports
11
11
  from tactus.core.runtime import TactusRuntime
@@ -12,7 +12,7 @@ import logging
12
12
  import os
13
13
  import queue
14
14
  import threading
15
- from typing import Optional
15
+ from typing import Any, Optional
16
16
 
17
17
  from tactus.protocols.models import LogEvent, CostEvent
18
18
 
@@ -35,7 +35,7 @@ class BrokerLogHandler:
35
35
  self.cost_events: list[CostEvent] = []
36
36
 
37
37
  # Thread-safe queue for events to send
38
- self._queue: queue.Queue = queue.Queue()
38
+ self._queue: queue.Queue[dict[str, Any]] = queue.Queue()
39
39
 
40
40
  # Background worker thread
41
41
  self._worker_thread: Optional[threading.Thread] = None
@@ -79,31 +79,31 @@ class BrokerLogHandler:
79
79
  from tactus.broker.client import BrokerClient
80
80
 
81
81
  # Create fresh client in this thread's event loop
82
- loop = asyncio.new_event_loop()
83
- asyncio.set_event_loop(loop)
82
+ event_loop = asyncio.new_event_loop()
83
+ asyncio.set_event_loop(event_loop)
84
84
  client = BrokerClient(self._socket_path)
85
85
 
86
86
  try:
87
87
  while not self._shutdown.is_set():
88
88
  try:
89
89
  # Wait for event with timeout to check shutdown flag
90
- event_dict = self._queue.get(timeout=0.05)
90
+ event_payload = self._queue.get(timeout=0.05)
91
91
 
92
92
  # Send event to broker
93
- loop.run_until_complete(client.emit_event(event_dict))
93
+ event_loop.run_until_complete(client.emit_event(event_payload))
94
94
  self._queue.task_done()
95
95
 
96
96
  except queue.Empty:
97
97
  continue
98
- except Exception as e:
98
+ except Exception as error:
99
99
  # Best effort - don't crash worker on individual failures
100
- logger.debug(f"[BROKER_LOG] Failed to emit event: {e}")
100
+ logger.debug("[BROKER_LOG] Failed to emit event: %s", error)
101
101
  try:
102
102
  self._queue.task_done()
103
103
  except ValueError:
104
104
  pass
105
105
  finally:
106
- loop.close()
106
+ event_loop.close()
107
107
  logger.debug("[BROKER_LOG] Background worker stopped")
108
108
 
109
109
  def log(self, event: LogEvent) -> None:
@@ -118,20 +118,23 @@ class BrokerLogHandler:
118
118
  self.cost_events.append(event)
119
119
 
120
120
  # Serialize to JSON-friendly dict
121
- event_dict = event.model_dump(mode="json")
121
+ event_payload = event.model_dump(mode="json")
122
122
 
123
123
  # Normalize timestamp formatting for downstream consumers.
124
124
  iso_string = event.timestamp.isoformat()
125
- if not (iso_string.endswith("Z") or "+" in iso_string or iso_string.count("-") > 2):
125
+ has_timezone_marker = (
126
+ iso_string.endswith("Z") or "+" in iso_string or iso_string.count("-") > 2
127
+ )
128
+ if not has_timezone_marker:
126
129
  iso_string += "Z"
127
- event_dict["timestamp"] = iso_string
130
+ event_payload["timestamp"] = iso_string
128
131
 
129
132
  # Ensure worker is running
130
133
  self._ensure_worker_started()
131
134
 
132
135
  # Queue event for background sending (non-blocking)
133
136
  try:
134
- self._queue.put_nowait(event_dict)
137
+ self._queue.put_nowait(event_payload)
135
138
  except queue.Full:
136
139
  # Drop event if queue is full (shouldn't happen with unlimited queue)
137
140
  logger.debug("[BROKER_LOG] Queue full, dropping event")
@@ -157,7 +160,7 @@ class BrokerLogHandler:
157
160
 
158
161
  if not self._queue.empty():
159
162
  remaining = self._queue.qsize()
160
- logger.warning(f"[BROKER_LOG] Flush timeout with {remaining} events remaining")
163
+ logger.warning("[BROKER_LOG] Flush timeout with %s events remaining", remaining)
161
164
 
162
165
  # Signal worker to shutdown
163
166
  self._shutdown.set()
@@ -13,7 +13,7 @@ The control loop uses a publish-subscribe pattern with namespace-based routing:
13
13
  - Subscribers can be observers (read-only) or responders (can provide input)
14
14
  """
15
15
 
16
- from typing import List, Dict, Any, Optional
16
+ from typing import Any, Optional
17
17
  import logging
18
18
  import sys
19
19
 
@@ -31,7 +31,7 @@ _CHANNEL_LOADERS = {
31
31
  }
32
32
 
33
33
 
34
- def load_channel(channel_id: str, config: Dict[str, Any]) -> Optional[ControlChannel]:
34
+ def load_channel(channel_id: str, config: dict[str, Any]) -> Optional[ControlChannel]:
35
35
  """
36
36
  Load a control channel by ID.
37
37
 
@@ -43,7 +43,7 @@ def load_channel(channel_id: str, config: Dict[str, Any]) -> Optional[ControlCha
43
43
  ControlChannel instance or None if loading fails
44
44
  """
45
45
  if channel_id not in _CHANNEL_LOADERS:
46
- logger.warning(f"Unknown or not yet implemented channel: {channel_id}")
46
+ logger.warning("Unknown or not yet implemented channel: %s", channel_id)
47
47
  return None
48
48
 
49
49
  module_path = _CHANNEL_LOADERS[channel_id]
@@ -55,19 +55,21 @@ def load_channel(channel_id: str, config: Dict[str, Any]) -> Optional[ControlCha
55
55
  module = importlib.import_module(module_name)
56
56
  channel_class = getattr(module, class_name)
57
57
  return channel_class(**config)
58
- except ImportError as e:
58
+ except ImportError as error:
59
59
  logger.warning(
60
- f"Failed to load {channel_id} channel. "
61
- f"Ensure dependencies are installed. "
62
- f"Error: {e}"
60
+ "Failed to load %s channel. Ensure dependencies are installed. Error: %s",
61
+ channel_id,
62
+ error,
63
63
  )
64
64
  return None
65
- except Exception as e:
66
- logger.exception(f"Failed to initialize {channel_id} channel: {e}")
65
+ except Exception as error:
66
+ logger.exception("Failed to initialize %s channel: %s", channel_id, error)
67
67
  return None
68
68
 
69
69
 
70
- def load_channels_from_config(config: Optional[ControlLoopConfig] = None) -> List[ControlChannel]:
70
+ def load_channels_from_config(
71
+ config: Optional[ControlLoopConfig] = None,
72
+ ) -> list[ControlChannel]:
71
73
  """
72
74
  Load control channels based on configuration and context.
73
75
 
@@ -81,7 +83,7 @@ def load_channels_from_config(config: Optional[ControlLoopConfig] = None) -> Lis
81
83
  Returns:
82
84
  List of enabled ControlChannel instances
83
85
  """
84
- channels: List[ControlChannel] = []
86
+ channels: list[ControlChannel] = []
85
87
 
86
88
  if config is None:
87
89
  config = ControlLoopConfig()
@@ -101,11 +103,11 @@ def load_channels_from_config(config: Optional[ControlLoopConfig] = None) -> Lis
101
103
  continue
102
104
 
103
105
  # Remove 'enabled' from config before passing to constructor
104
- init_config = {k: v for k, v in channel_config.items() if k != "enabled"}
106
+ init_config = {key: value for key, value in channel_config.items() if key != "enabled"}
105
107
  channel = load_channel(channel_id, init_config)
106
108
  if channel:
107
109
  channels.append(channel)
108
- logger.info(f"Loaded control channel: {channel_id}")
110
+ logger.info("Loaded control channel: %s", channel_id)
109
111
 
110
112
  # If no channels configured, use defaults
111
113
  if not config.channels:
@@ -114,7 +116,7 @@ def load_channels_from_config(config: Optional[ControlLoopConfig] = None) -> Lis
114
116
  return channels
115
117
 
116
118
 
117
- def load_default_channels(procedure_id: Optional[str] = None) -> List[ControlChannel]:
119
+ def load_default_channels(procedure_id: Optional[str] = None) -> list[ControlChannel]:
118
120
  """
119
121
  Load default control channels based on context.
120
122
 
@@ -128,7 +130,7 @@ def load_default_channels(procedure_id: Optional[str] = None) -> List[ControlCha
128
130
  Returns:
129
131
  List of enabled ControlChannel instances
130
132
  """
131
- channels: List[ControlChannel] = []
133
+ channels: list[ControlChannel] = []
132
134
 
133
135
  # CLI channel - auto-detect based on tty
134
136
  if sys.stdin.isatty():
@@ -75,8 +75,8 @@ class InProcessChannel(ABC):
75
75
 
76
76
  Default: no-op. Override for auth handshakes, connections, etc.
77
77
  """
78
- logger.info(f"{self.channel_id}: initializing...")
79
- logger.info(f"{self.channel_id}: ready")
78
+ logger.info("%s: initializing...", self.channel_id)
79
+ logger.info("%s: ready", self.channel_id)
80
80
 
81
81
  @abstractmethod
82
82
  async def send(self, request: ControlRequest) -> DeliveryResult:
@@ -112,7 +112,11 @@ class InProcessChannel(ABC):
112
112
  self._response_queue.get(),
113
113
  timeout=0.5,
114
114
  )
115
- logger.info(f"{self.channel_id}: received response for {response.request_id}")
115
+ logger.info(
116
+ "%s: received response for %s",
117
+ self.channel_id,
118
+ response.request_id,
119
+ )
116
120
  yield response
117
121
  except asyncio.TimeoutError:
118
122
  continue
@@ -130,7 +134,12 @@ class InProcessChannel(ABC):
130
134
  external_message_id: Channel-specific message ID
131
135
  reason: Reason for cancellation
132
136
  """
133
- logger.debug(f"{self.channel_id}: cancelling {external_message_id}: {reason}")
137
+ logger.debug(
138
+ "%s: cancelling %s: %s",
139
+ self.channel_id,
140
+ external_message_id,
141
+ reason,
142
+ )
134
143
 
135
144
  async def shutdown(self) -> None:
136
145
  """
@@ -139,7 +148,7 @@ class InProcessChannel(ABC):
139
148
  Default: sets shutdown event to stop receive loop.
140
149
  Override for additional cleanup (close connections, etc.).
141
150
  """
142
- logger.info(f"{self.channel_id}: shutting down")
151
+ logger.info("%s: shutting down", self.channel_id)
143
152
  self._shutdown_event.set()
144
153
 
145
154
  def push_response(self, response: ControlResponse) -> None:
@@ -156,8 +165,8 @@ class InProcessChannel(ABC):
156
165
  """
157
166
  try:
158
167
  self._response_queue.put_nowait(response)
159
- except Exception as e:
160
- logger.error(f"{self.channel_id}: failed to queue response: {e}")
168
+ except Exception as error:
169
+ logger.error("%s: failed to queue response: %s", self.channel_id, error)
161
170
 
162
171
  def push_response_threadsafe(
163
172
  self, response: ControlResponse, loop: asyncio.AbstractEventLoop
@@ -73,9 +73,9 @@ class BrokerControlChannel(InProcessChannel):
73
73
 
74
74
  async def initialize(self) -> None:
75
75
  """Initialize broker control channel (broker already connected)."""
76
- logger.info(f"{self.channel_id}: initializing...")
76
+ logger.info("%s: initializing...", self.channel_id)
77
77
  # Broker client already initialized by BrokerLogHandler setup
78
- logger.info(f"{self.channel_id}: ready (via broker)")
78
+ logger.info("%s: ready (via broker)", self.channel_id)
79
79
 
80
80
  async def send(self, request: ControlRequest) -> DeliveryResult:
81
81
  """
@@ -84,7 +84,11 @@ class BrokerControlChannel(InProcessChannel):
84
84
  The request is serialized and sent via broker's control.request method.
85
85
  The host will relay to its SSE channel and return the response.
86
86
  """
87
- logger.info(f"{self.channel_id}: sending control request {request.request_id} via broker")
87
+ logger.info(
88
+ "%s: sending control request %s via broker",
89
+ self.channel_id,
90
+ request.request_id,
91
+ )
88
92
 
89
93
  try:
90
94
  # Serialize request to JSON-compatible dict
@@ -96,7 +100,11 @@ class BrokerControlChannel(InProcessChannel):
96
100
 
97
101
  if event_type == "delivered":
98
102
  # Request successfully delivered to host channels
99
- logger.debug(f"{self.channel_id}: request {request.request_id} delivered")
103
+ logger.debug(
104
+ "%s: request %s delivered",
105
+ self.channel_id,
106
+ request.request_id,
107
+ )
100
108
  continue
101
109
 
102
110
  elif event_type == "response":
@@ -110,13 +118,21 @@ class BrokerControlChannel(InProcessChannel):
110
118
  channel_id=response_data.get("channel_id", "sse"),
111
119
  responder_id=response_data.get("responder_id"),
112
120
  )
113
- logger.info(f"{self.channel_id}: received response for {request.request_id}")
121
+ logger.info(
122
+ "%s: received response for %s",
123
+ self.channel_id,
124
+ request.request_id,
125
+ )
114
126
  self._response_queue.put_nowait(response)
115
127
  break
116
128
 
117
129
  elif event_type == "timeout":
118
130
  # Host-side timeout
119
- logger.warning(f"{self.channel_id}: timeout for {request.request_id}")
131
+ logger.warning(
132
+ "%s: timeout for %s",
133
+ self.channel_id,
134
+ request.request_id,
135
+ )
120
136
  response = ControlResponse(
121
137
  request_id=request.request_id,
122
138
  value=request.default_value,
@@ -131,7 +147,12 @@ class BrokerControlChannel(InProcessChannel):
131
147
  # Delivery or processing error
132
148
  error = event.get("error", {})
133
149
  error_msg = error.get("message", "Unknown broker error")
134
- logger.error(f"{self.channel_id}: error for {request.request_id}: {error_msg}")
150
+ logger.error(
151
+ "%s: error for %s: %s",
152
+ self.channel_id,
153
+ request.request_id,
154
+ error_msg,
155
+ )
135
156
  raise RuntimeError(f"Broker control request failed: {error_msg}")
136
157
 
137
158
  return DeliveryResult(
@@ -141,14 +162,19 @@ class BrokerControlChannel(InProcessChannel):
141
162
  success=True,
142
163
  )
143
164
 
144
- except Exception as e:
145
- logger.error(f"{self.channel_id}: failed to send {request.request_id}: {e}")
165
+ except Exception as error:
166
+ logger.error(
167
+ "%s: failed to send %s: %s",
168
+ self.channel_id,
169
+ request.request_id,
170
+ error,
171
+ )
146
172
  return DeliveryResult(
147
173
  channel_id=self.channel_id,
148
174
  external_message_id=request.request_id,
149
175
  delivered_at=datetime.now(timezone.utc),
150
176
  success=False,
151
- error_message=str(e),
177
+ error_message=str(error),
152
178
  )
153
179
 
154
180
  @classmethod
@@ -171,9 +197,13 @@ class BrokerControlChannel(InProcessChannel):
171
197
 
172
198
  client = BrokerClient(socket_path)
173
199
  logger.info(
174
- f"BrokerControlChannel: initialized from environment (socket={socket_path})"
200
+ "BrokerControlChannel: initialized from environment (socket=%s)",
201
+ socket_path,
175
202
  )
176
203
  return cls(client)
177
- except Exception as e:
178
- logger.warning(f"BrokerControlChannel: failed to initialize from environment: {e}")
204
+ except Exception as error:
205
+ logger.warning(
206
+ "BrokerControlChannel: failed to initialize from environment: %s",
207
+ error,
208
+ )
179
209
  return None
@@ -26,10 +26,14 @@ from tactus.adapters.channels.host import HostControlChannel
26
26
  logger = logging.getLogger(__name__)
27
27
 
28
28
 
29
- def format_time_ago(dt: datetime) -> str:
29
+ def format_time_ago(timestamp: datetime) -> str:
30
30
  """Format datetime as human-readable time ago string."""
31
31
  now = datetime.now(timezone.utc)
32
- delta = now - dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else now - dt
32
+ delta = (
33
+ now - timestamp.replace(tzinfo=timezone.utc)
34
+ if timestamp.tzinfo is None
35
+ else now - timestamp
36
+ )
33
37
 
34
38
  seconds = int(delta.total_seconds())
35
39
  if seconds < 60:
@@ -90,11 +94,11 @@ class CLIControlChannel(HostControlChannel):
90
94
 
91
95
  async def initialize(self) -> None:
92
96
  """Initialize the CLI channel."""
93
- logger.info(f"{self.channel_id}: initializing...")
97
+ logger.info("%s: initializing...", self.channel_id)
94
98
  # Check if stdin is a tty
95
99
  if not sys.stdin.isatty():
96
- logger.warning(f"{self.channel_id}: stdin is not a tty, prompts may not work")
97
- logger.info(f"{self.channel_id}: ready")
100
+ logger.warning("%s: stdin is not a tty, prompts may not work", self.channel_id)
101
+ logger.info("%s: ready", self.channel_id)
98
102
 
99
103
  def _display_request(self, request: ControlRequest) -> None:
100
104
  """
@@ -163,13 +167,13 @@ class CLIControlChannel(HostControlChannel):
163
167
  request_type = request.request_type
164
168
  if request_type == ControlRequestType.APPROVAL:
165
169
  return self._handle_approval(request)
166
- elif request_type == ControlRequestType.INPUT:
170
+ if request_type == ControlRequestType.INPUT:
167
171
  return self._handle_input(request)
168
- elif request_type == ControlRequestType.REVIEW:
172
+ if request_type == ControlRequestType.REVIEW:
169
173
  return self._handle_review(request)
170
- elif request_type == ControlRequestType.ESCALATION:
174
+ if request_type == ControlRequestType.ESCALATION:
171
175
  return self._handle_escalation(request)
172
- elif request_type == ControlRequestType.INPUTS:
176
+ if request_type == ControlRequestType.INPUTS:
173
177
  return self._handle_inputs(request)
174
178
  else:
175
179
  # Default: treat as input
@@ -212,8 +216,8 @@ class CLIControlChannel(HostControlChannel):
212
216
  """Handle options selection."""
213
217
  # Display options
214
218
  self.console.print("\n[bold]Options:[/bold]")
215
- for i, option in enumerate(options, 1):
216
- self.console.print(f" {i}. [cyan]{option.label}[/cyan]")
219
+ for index, option in enumerate(options, 1):
220
+ self.console.print(f" {index}. [cyan]{option.label}[/cyan]")
217
221
  if option.description:
218
222
  self.console.print(f" [dim]{option.description}[/dim]")
219
223
 
@@ -309,15 +313,15 @@ class CLIControlChannel(HostControlChannel):
309
313
 
310
314
  # Display summary
311
315
  self.console.print(f"\n[bold cyan]Collecting {len(items)} inputs:[/bold cyan]")
312
- for idx, item in enumerate(items, 1):
316
+ for index, item in enumerate(items, 1):
313
317
  req_marker = "*" if item.required else ""
314
- self.console.print(f" {idx}. [cyan]{item.label}[/cyan]{req_marker}")
318
+ self.console.print(f" {index}. [cyan]{item.label}[/cyan]{req_marker}")
315
319
  self.console.print()
316
320
 
317
321
  # Collect responses for each item
318
322
  responses = {}
319
323
 
320
- for idx, item in enumerate(items, 1):
324
+ for index, item in enumerate(items, 1):
321
325
  if self.is_cancelled():
322
326
  return None
323
327
 
@@ -325,7 +329,7 @@ class CLIControlChannel(HostControlChannel):
325
329
  self.console.print(
326
330
  Panel(
327
331
  item.message,
328
- title=f"[bold]{idx}/{len(items)}: {item.label}[/bold]",
332
+ title=f"[bold]{index}/{len(items)}: {item.label}[/bold]",
329
333
  style="cyan" if item.required else "blue",
330
334
  )
331
335
  )
@@ -15,7 +15,7 @@ import asyncio
15
15
  import logging
16
16
  import threading
17
17
  from abc import abstractmethod
18
- from typing import Optional
18
+ from typing import Any, Optional
19
19
  from datetime import datetime, timezone
20
20
 
21
21
  from tactus.protocols.control import (
@@ -84,7 +84,11 @@ class HostControlChannel(InProcessChannel):
84
84
  Returns:
85
85
  DeliveryResult indicating successful delivery
86
86
  """
87
- logger.info(f"{self.channel_id}: sending notification for {request.request_id}")
87
+ logger.info(
88
+ "%s: sending notification for %s",
89
+ self.channel_id,
90
+ request.request_id,
91
+ )
88
92
 
89
93
  # Store for background thread access
90
94
  self._current_request = request
@@ -122,7 +126,12 @@ class HostControlChannel(InProcessChannel):
122
126
  external_message_id: Request ID (same as sent)
123
127
  reason: Reason for cancellation (e.g., "Responded via tactus_cloud")
124
128
  """
125
- logger.debug(f"{self.channel_id}: cancelling {external_message_id}: {reason}")
129
+ logger.debug(
130
+ "%s: cancelling %s: %s",
131
+ self.channel_id,
132
+ external_message_id,
133
+ reason,
134
+ )
126
135
  self._cancel_event.set()
127
136
  self._show_cancelled(reason)
128
137
 
@@ -167,9 +176,9 @@ class HostControlChannel(InProcessChannel):
167
176
  else:
168
177
  self.push_response(response)
169
178
 
170
- except Exception as e:
179
+ except Exception as error:
171
180
  if not self._cancel_event.is_set():
172
- logger.error(f"{self.channel_id}: input error: {e}")
181
+ logger.error("%s: input error: %s", self.channel_id, error)
173
182
 
174
183
  @abstractmethod
175
184
  def _display_request(self, request: ControlRequest) -> None:
@@ -185,7 +194,7 @@ class HostControlChannel(InProcessChannel):
185
194
  ...
186
195
 
187
196
  @abstractmethod
188
- def _prompt_for_input(self, request: ControlRequest) -> Optional[any]:
197
+ def _prompt_for_input(self, request: ControlRequest) -> Optional[Any]:
189
198
  """
190
199
  Collect input from the user.
191
200