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.
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.1.dist-info}/METADATA +14 -2
  97. {tactus-0.33.0.dist-info → tactus-0.34.1.dist-info}/RECORD +100 -49
  98. {tactus-0.33.0.dist-info → tactus-0.34.1.dist-info}/WHEEL +0 -0
  99. {tactus-0.33.0.dist-info → tactus-0.34.1.dist-info}/entry_points.txt +0 -0
  100. {tactus-0.33.0.dist-info → tactus-0.34.1.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.33.0"
8
+ __version__ = "0.34.1"
9
9
 
10
10
  # Core exports
11
11
  from tactus.core.runtime import TactusRuntime
@@ -4,6 +4,23 @@ Tactus adapters - Built-in implementations of Tactus protocols.
4
4
 
5
5
  from tactus.adapters.memory import MemoryStorage
6
6
  from tactus.adapters.file_storage import FileStorage
7
+
8
+ # Legacy HITL handler (maintained for backward compatibility)
7
9
  from tactus.adapters.cli_hitl import CLIHITLHandler
8
10
 
9
- __all__ = ["MemoryStorage", "FileStorage", "CLIHITLHandler"]
11
+ # New control loop architecture
12
+ from tactus.adapters.control_loop import ControlLoopHandler
13
+ from tactus.adapters.channels import load_channels_from_config, load_default_channels
14
+ from tactus.adapters.channels.cli import CLIControlChannel
15
+
16
+ __all__ = [
17
+ "MemoryStorage",
18
+ "FileStorage",
19
+ # Legacy HITL
20
+ "CLIHITLHandler",
21
+ # New control loop
22
+ "ControlLoopHandler",
23
+ "CLIControlChannel",
24
+ "load_channels_from_config",
25
+ "load_default_channels",
26
+ ]
@@ -1,52 +1,118 @@
1
1
  """
2
- Broker log handler for container event streaming over UDS.
2
+ Broker log handler for container event streaming.
3
3
 
4
4
  Used inside the runtime container to forward structured log events to the
5
- host-side broker without requiring container networking.
5
+ host-side broker for real-time streaming to the IDE frontend.
6
6
  """
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
10
  import asyncio
11
+ import logging
12
+ import os
13
+ import queue
11
14
  import threading
12
15
  from typing import Optional
13
16
 
14
- from tactus.broker.client import BrokerClient
15
17
  from tactus.protocols.models import LogEvent, CostEvent
16
18
 
19
+ logger = logging.getLogger(__name__)
20
+
17
21
 
18
22
  class BrokerLogHandler:
19
23
  """
20
- Log handler that forwards events to the broker via Unix domain socket.
24
+ Log handler that forwards events to the broker via background thread.
25
+
26
+ Uses a dedicated thread with its own event loop to ensure events are
27
+ sent in real-time, regardless of whether the main execution yields control.
28
+ This enables true streaming of agent responses to the frontend.
21
29
 
22
30
  The broker socket path is read from `TACTUS_BROKER_SOCKET`.
23
31
  """
24
32
 
25
- def __init__(self, client: BrokerClient):
26
- self._client = client
33
+ def __init__(self, socket_path: str):
34
+ self._socket_path = socket_path
27
35
  self.cost_events: list[CostEvent] = []
28
- self._loop: Optional[asyncio.AbstractEventLoop] = None
29
- self._loop_lock = threading.Lock()
30
36
 
31
- def _get_or_create_loop(self) -> asyncio.AbstractEventLoop:
32
- """Get the event loop, creating one if needed for cross-thread calls."""
33
- with self._loop_lock:
34
- if self._loop is None or self._loop.is_closed():
35
- try:
36
- self._loop = asyncio.get_running_loop()
37
- except RuntimeError:
38
- # No running loop - create a new one
39
- self._loop = asyncio.new_event_loop()
40
- return self._loop
37
+ # Thread-safe queue for events to send
38
+ self._queue: queue.Queue = queue.Queue()
39
+
40
+ # Background worker thread
41
+ self._worker_thread: Optional[threading.Thread] = None
42
+ self._shutdown = threading.Event()
43
+ self._started = False
44
+ self._start_lock = threading.Lock()
45
+
46
+ @property
47
+ def supports_streaming(self) -> bool:
48
+ """Broker handler supports real-time streaming."""
49
+ return True
41
50
 
42
51
  @classmethod
43
52
  def from_environment(cls) -> Optional["BrokerLogHandler"]:
44
- client = BrokerClient.from_environment()
45
- if client is None:
53
+ """Create handler from TACTUS_BROKER_SOCKET environment variable."""
54
+ socket_path = os.environ.get("TACTUS_BROKER_SOCKET")
55
+ if not socket_path:
46
56
  return None
47
- return cls(client)
57
+ return cls(socket_path)
58
+
59
+ def _ensure_worker_started(self) -> None:
60
+ """Start background worker thread if not already running."""
61
+ with self._start_lock:
62
+ if not self._started:
63
+ self._worker_thread = threading.Thread(
64
+ target=self._worker,
65
+ name="BrokerLogWorker",
66
+ daemon=True,
67
+ )
68
+ self._worker_thread.start()
69
+ self._started = True
70
+ logger.debug("[BROKER_LOG] Background worker started")
71
+
72
+ def _worker(self) -> None:
73
+ """
74
+ Background thread that sends events to broker.
75
+
76
+ Runs in its own event loop to avoid blocking the main execution.
77
+ Creates a fresh BrokerClient connection for thread safety.
78
+ """
79
+ from tactus.broker.client import BrokerClient
80
+
81
+ # Create fresh client in this thread's event loop
82
+ loop = asyncio.new_event_loop()
83
+ asyncio.set_event_loop(loop)
84
+ client = BrokerClient(self._socket_path)
85
+
86
+ try:
87
+ while not self._shutdown.is_set():
88
+ try:
89
+ # Wait for event with timeout to check shutdown flag
90
+ event_dict = self._queue.get(timeout=0.05)
91
+
92
+ # Send event to broker
93
+ loop.run_until_complete(client.emit_event(event_dict))
94
+ self._queue.task_done()
95
+
96
+ except queue.Empty:
97
+ continue
98
+ except Exception as e:
99
+ # Best effort - don't crash worker on individual failures
100
+ logger.debug(f"[BROKER_LOG] Failed to emit event: {e}")
101
+ try:
102
+ self._queue.task_done()
103
+ except ValueError:
104
+ pass
105
+ finally:
106
+ loop.close()
107
+ logger.debug("[BROKER_LOG] Background worker stopped")
48
108
 
49
109
  def log(self, event: LogEvent) -> None:
110
+ """
111
+ Forward an event to the broker.
112
+
113
+ Events are queued and sent by a background thread, enabling
114
+ real-time streaming without blocking procedure execution.
115
+ """
50
116
  # Track cost events for aggregation (mirrors IDELogHandler behavior)
51
117
  if isinstance(event, CostEvent):
52
118
  self.cost_events.append(event)
@@ -60,17 +126,44 @@ class BrokerLogHandler:
60
126
  iso_string += "Z"
61
127
  event_dict["timestamp"] = iso_string
62
128
 
63
- # Best-effort forwarding; never crash the procedure due to streaming.
129
+ # Ensure worker is running
130
+ self._ensure_worker_started()
131
+
132
+ # Queue event for background sending (non-blocking)
64
133
  try:
65
- # Try to get the running loop first
66
- try:
67
- loop = asyncio.get_running_loop()
68
- # We're in an async context - schedule and don't wait
69
- loop.create_task(self._client.emit_event(event_dict))
70
- except RuntimeError:
71
- # No running loop - we're being called from a sync thread.
72
- # Use asyncio.run() which creates a new event loop for this call.
73
- asyncio.run(self._client.emit_event(event_dict))
74
- except Exception:
75
- # Swallow errors; container remains networkless and secretless even if streaming fails.
76
- pass
134
+ self._queue.put_nowait(event_dict)
135
+ except queue.Full:
136
+ # Drop event if queue is full (shouldn't happen with unlimited queue)
137
+ logger.debug("[BROKER_LOG] Queue full, dropping event")
138
+
139
+ async def flush(self) -> None:
140
+ """
141
+ Wait for all queued events to be sent.
142
+
143
+ Call this before procedure completion to ensure all events
144
+ are delivered to the broker before the container exits.
145
+ """
146
+ if not self._started:
147
+ return
148
+
149
+ # Wait for queue to drain
150
+ max_wait = 5.0 # Maximum wait time in seconds
151
+ poll_interval = 0.05
152
+ elapsed = 0.0
153
+
154
+ while not self._queue.empty() and elapsed < max_wait:
155
+ await asyncio.sleep(poll_interval)
156
+ elapsed += poll_interval
157
+
158
+ if not self._queue.empty():
159
+ remaining = self._queue.qsize()
160
+ logger.warning(f"[BROKER_LOG] Flush timeout with {remaining} events remaining")
161
+
162
+ # Signal worker to shutdown
163
+ self._shutdown.set()
164
+
165
+ # Wait for worker thread to finish
166
+ if self._worker_thread and self._worker_thread.is_alive():
167
+ self._worker_thread.join(timeout=1.0)
168
+ if self._worker_thread.is_alive():
169
+ logger.warning("[BROKER_LOG] Worker thread did not stop cleanly")
@@ -0,0 +1,153 @@
1
+ """
2
+ Control channel implementations for omnichannel control loop.
3
+
4
+ This package contains control channel plugins:
5
+ - CLI: Command-line interface for terminal control (host app pattern)
6
+ - IDE/SSE: Server-Sent Events for VSCode extension (Phase 2)
7
+ - Tactus Cloud: WebSocket API for companion app (Phase 5)
8
+ - Additional channels as needed: Slack, Teams, Email, etc.
9
+
10
+ The control loop uses a publish-subscribe pattern with namespace-based routing:
11
+ - Publishers (Tactus runtimes) emit control requests to namespaces
12
+ - Subscribers (controllers) subscribe to namespace patterns
13
+ - Subscribers can be observers (read-only) or responders (can provide input)
14
+ """
15
+
16
+ from typing import List, Dict, Any, Optional
17
+ import logging
18
+ import sys
19
+
20
+ from tactus.protocols.control import ControlChannel, ControlLoopConfig
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ # Channel registry for lazy loading
26
+ _CHANNEL_LOADERS = {
27
+ "cli": "tactus.adapters.channels.cli:CLIControlChannel",
28
+ "ipc": "tactus.adapters.channels.ipc:IPCControlChannel",
29
+ # "ide": "tactus.adapters.channels.ide_sse:SSEControlChannel", # Phase 2
30
+ # "tactus_cloud": "tactus.adapters.channels.tactus_cloud:TactusCloudChannel", # Phase 5
31
+ }
32
+
33
+
34
+ def load_channel(channel_id: str, config: Dict[str, Any]) -> Optional[ControlChannel]:
35
+ """
36
+ Load a control channel by ID.
37
+
38
+ Args:
39
+ channel_id: Channel identifier (e.g., 'cli', 'ide', 'tactus_cloud')
40
+ config: Channel configuration dict
41
+
42
+ Returns:
43
+ ControlChannel instance or None if loading fails
44
+ """
45
+ if channel_id not in _CHANNEL_LOADERS:
46
+ logger.warning(f"Unknown or not yet implemented channel: {channel_id}")
47
+ return None
48
+
49
+ module_path = _CHANNEL_LOADERS[channel_id]
50
+ module_name, class_name = module_path.rsplit(":", 1)
51
+
52
+ try:
53
+ import importlib
54
+
55
+ module = importlib.import_module(module_name)
56
+ channel_class = getattr(module, class_name)
57
+ return channel_class(**config)
58
+ except ImportError as e:
59
+ logger.warning(
60
+ f"Failed to load {channel_id} channel. "
61
+ f"Ensure dependencies are installed. "
62
+ f"Error: {e}"
63
+ )
64
+ return None
65
+ except Exception as e:
66
+ logger.exception(f"Failed to initialize {channel_id} channel: {e}")
67
+ return None
68
+
69
+
70
+ def load_channels_from_config(config: Optional[ControlLoopConfig] = None) -> List[ControlChannel]:
71
+ """
72
+ Load control channels based on configuration and context.
73
+
74
+ By default:
75
+ - CLI channel is enabled if stdin is a tty (interactive terminal)
76
+ - Other channels are loaded based on configuration
77
+
78
+ Args:
79
+ config: Optional control loop configuration
80
+
81
+ Returns:
82
+ List of enabled ControlChannel instances
83
+ """
84
+ channels: List[ControlChannel] = []
85
+
86
+ if config is None:
87
+ config = ControlLoopConfig()
88
+
89
+ # Process each configured channel
90
+ for channel_id, channel_config in config.channels.items():
91
+ enabled = channel_config.get("enabled", False)
92
+
93
+ # Special handling for CLI with auto-detection
94
+ if channel_id == "cli":
95
+ if enabled == "auto" or enabled is None:
96
+ enabled = sys.stdin.isatty()
97
+ elif isinstance(enabled, str):
98
+ enabled = enabled.lower() == "true"
99
+
100
+ if not enabled:
101
+ continue
102
+
103
+ # Remove 'enabled' from config before passing to constructor
104
+ init_config = {k: v for k, v in channel_config.items() if k != "enabled"}
105
+ channel = load_channel(channel_id, init_config)
106
+ if channel:
107
+ channels.append(channel)
108
+ logger.info(f"Loaded control channel: {channel_id}")
109
+
110
+ # If no channels configured, use defaults
111
+ if not config.channels:
112
+ channels = load_default_channels()
113
+
114
+ return channels
115
+
116
+
117
+ def load_default_channels(procedure_id: Optional[str] = None) -> List[ControlChannel]:
118
+ """
119
+ Load default control channels based on context.
120
+
121
+ By default:
122
+ - CLI channel is enabled if stdin is a tty (interactive terminal)
123
+ - IPC channel is always enabled (allows control CLI to connect)
124
+
125
+ Args:
126
+ procedure_id: Optional procedure ID for IPC socket path
127
+
128
+ Returns:
129
+ List of enabled ControlChannel instances
130
+ """
131
+ channels: List[ControlChannel] = []
132
+
133
+ # CLI channel - auto-detect based on tty
134
+ if sys.stdin.isatty():
135
+ from tactus.adapters.channels.cli import CLIControlChannel
136
+
137
+ channels.append(CLIControlChannel())
138
+ logger.info("Loaded CLI control channel (auto-detected tty)")
139
+
140
+ # IPC channel - always enabled for control CLI connectivity
141
+ from tactus.adapters.channels.ipc import IPCControlChannel
142
+
143
+ channels.append(IPCControlChannel(procedure_id=procedure_id))
144
+ logger.info("Loaded IPC control channel")
145
+
146
+ return channels
147
+
148
+
149
+ __all__ = [
150
+ "load_channel",
151
+ "load_channels_from_config",
152
+ "load_default_channels",
153
+ ]
@@ -0,0 +1,174 @@
1
+ """
2
+ Base classes for control channel implementations.
3
+
4
+ Provides InProcessChannel for channels that work with asyncio in-process.
5
+ A DaemonChannel base class may be added later if needed for channels
6
+ requiring separate processes (e.g., Discord WebSocket gateway).
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ from abc import ABC, abstractmethod
12
+ from typing import AsyncIterator
13
+
14
+ from tactus.protocols.control import (
15
+ ControlRequest,
16
+ ControlResponse,
17
+ ChannelCapabilities,
18
+ DeliveryResult,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class InProcessChannel(ABC):
25
+ """
26
+ Base class for control channels that run in-process with asyncio.
27
+
28
+ Suitable for:
29
+ - Host app channels (CLI, IDE server, Jupyter)
30
+ - HTTP webhooks (Slack, Teams)
31
+ - Queue polling (SQS, Redis)
32
+ - Email (SMTP send)
33
+ - WebSocket clients (Tactus Cloud)
34
+
35
+ These channels coexist with the asyncio event loop and don't need
36
+ separate processes.
37
+
38
+ Subclasses must implement:
39
+ - channel_id property
40
+ - capabilities property
41
+ - send() method
42
+
43
+ Default implementations provided for:
44
+ - initialize() - no-op
45
+ - receive() - yields from internal response queue
46
+ - cancel() - no-op
47
+ - shutdown() - no-op
48
+
49
+ The response queue pattern:
50
+ - External handlers (webhooks, stdin readers, etc.) call push_response()
51
+ - receive() yields from the queue
52
+ - This bridges sync/async boundaries cleanly
53
+ """
54
+
55
+ def __init__(self):
56
+ """Initialize the channel with an internal response queue."""
57
+ self._response_queue: asyncio.Queue[ControlResponse] = asyncio.Queue()
58
+ self._shutdown_event = asyncio.Event()
59
+
60
+ @property
61
+ @abstractmethod
62
+ def channel_id(self) -> str:
63
+ """Unique identifier for this channel."""
64
+ ...
65
+
66
+ @property
67
+ @abstractmethod
68
+ def capabilities(self) -> ChannelCapabilities:
69
+ """Return channel capabilities."""
70
+ ...
71
+
72
+ async def initialize(self) -> None:
73
+ """
74
+ Initialize the channel.
75
+
76
+ Default: no-op. Override for auth handshakes, connections, etc.
77
+ """
78
+ logger.info(f"{self.channel_id}: initializing...")
79
+ logger.info(f"{self.channel_id}: ready")
80
+
81
+ @abstractmethod
82
+ async def send(self, request: ControlRequest) -> DeliveryResult:
83
+ """
84
+ Send a control request to this channel.
85
+
86
+ Subclass must implement channel-specific send logic.
87
+
88
+ Args:
89
+ request: ControlRequest with full context
90
+
91
+ Returns:
92
+ DeliveryResult with delivery status
93
+ """
94
+ ...
95
+
96
+ async def receive(self) -> AsyncIterator[ControlResponse]:
97
+ """
98
+ Yield responses as they arrive from the internal queue.
99
+
100
+ External handlers (webhooks, stdin readers, etc.) call push_response()
101
+ to add responses to the queue.
102
+
103
+ Override for polling-based channels that need custom receive logic.
104
+
105
+ Yields:
106
+ ControlResponse as they are received
107
+ """
108
+ while not self._shutdown_event.is_set():
109
+ try:
110
+ # Use wait_for with timeout to check shutdown periodically
111
+ response = await asyncio.wait_for(
112
+ self._response_queue.get(),
113
+ timeout=0.5,
114
+ )
115
+ logger.info(f"{self.channel_id}: received response for {response.request_id}")
116
+ yield response
117
+ except asyncio.TimeoutError:
118
+ continue
119
+ except asyncio.CancelledError:
120
+ break
121
+
122
+ async def cancel(self, external_message_id: str, reason: str) -> None:
123
+ """
124
+ Cancel or update a request when resolved via another channel.
125
+
126
+ Default: no-op. Override for channels that support message updates
127
+ (e.g., editing Slack messages, updating UI).
128
+
129
+ Args:
130
+ external_message_id: Channel-specific message ID
131
+ reason: Reason for cancellation
132
+ """
133
+ logger.debug(f"{self.channel_id}: cancelling {external_message_id}: {reason}")
134
+
135
+ async def shutdown(self) -> None:
136
+ """
137
+ Clean shutdown of the channel.
138
+
139
+ Default: sets shutdown event to stop receive loop.
140
+ Override for additional cleanup (close connections, etc.).
141
+ """
142
+ logger.info(f"{self.channel_id}: shutting down")
143
+ self._shutdown_event.set()
144
+
145
+ def push_response(self, response: ControlResponse) -> None:
146
+ """
147
+ Push a response to the internal queue.
148
+
149
+ Called by external handlers (webhooks, stdin readers, etc.) when
150
+ a response is received. This bridges sync/async boundaries.
151
+
152
+ Thread-safe: can be called from non-async contexts.
153
+
154
+ Args:
155
+ response: ControlResponse to add to queue
156
+ """
157
+ try:
158
+ self._response_queue.put_nowait(response)
159
+ except Exception as e:
160
+ logger.error(f"{self.channel_id}: failed to queue response: {e}")
161
+
162
+ def push_response_threadsafe(
163
+ self, response: ControlResponse, loop: asyncio.AbstractEventLoop
164
+ ) -> None:
165
+ """
166
+ Push a response to the queue from another thread.
167
+
168
+ Use this when calling from a background thread (e.g., stdin reader).
169
+
170
+ Args:
171
+ response: ControlResponse to add to queue
172
+ loop: The event loop to use for thread-safe call
173
+ """
174
+ loop.call_soon_threadsafe(self._response_queue.put_nowait, response)