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
@@ -10,7 +10,6 @@ from __future__ import annotations
10
10
  import logging
11
11
  from typing import Any, Dict, Optional
12
12
 
13
-
14
13
  logger = logging.getLogger(__name__)
15
14
 
16
15
 
@@ -13,6 +13,20 @@ from tactus.protocols.models import (
13
13
  ChatMessage,
14
14
  )
15
15
 
16
+ # Control loop protocol and models
17
+ from tactus.protocols.control import (
18
+ ControlChannel,
19
+ ControlRequest,
20
+ ControlResponse,
21
+ ControlRequestType,
22
+ ControlOption,
23
+ ControlInteraction,
24
+ ConversationMessage,
25
+ ChannelCapabilities,
26
+ DeliveryResult,
27
+ ControlLoopConfig,
28
+ )
29
+
16
30
  # Protocols
17
31
  from tactus.protocols.storage import StorageBackend
18
32
  from tactus.protocols.hitl import HITLHandler
@@ -28,6 +42,17 @@ __all__ = [
28
42
  "HITLRequest",
29
43
  "HITLResponse",
30
44
  "ChatMessage",
45
+ # Control loop
46
+ "ControlChannel",
47
+ "ControlRequest",
48
+ "ControlResponse",
49
+ "ControlRequestType",
50
+ "ControlOption",
51
+ "ControlInteraction",
52
+ "ConversationMessage",
53
+ "ChannelCapabilities",
54
+ "DeliveryResult",
55
+ "ControlLoopConfig",
31
56
  # Protocols
32
57
  "StorageBackend",
33
58
  "HITLHandler",
@@ -0,0 +1,427 @@
1
+ """
2
+ Control loop protocol for omnichannel controller interactions.
3
+
4
+ Defines the interface for control channels that enable both human-in-the-loop (HITL)
5
+ and model-in-the-loop (MITL) interactions. Controllers can be humans or AI models.
6
+
7
+ The control loop uses a publish-subscribe pattern with namespace-based routing:
8
+ - Tactus runtimes (publishers) emit control requests to namespaces
9
+ - Controllers (subscribers) subscribe to namespace patterns
10
+ - Subscribers can be observers (read-only) or responders (can provide input)
11
+ """
12
+
13
+ from typing import Protocol, Optional, List, Dict, Any, AsyncIterator, runtime_checkable
14
+ from pydantic import BaseModel, Field
15
+ from datetime import datetime, timezone
16
+ from enum import Enum
17
+
18
+
19
+ def utc_now() -> datetime:
20
+ """Return current UTC time as a timezone-aware datetime."""
21
+ return datetime.now(timezone.utc)
22
+
23
+
24
+ class ControlRequestType(str, Enum):
25
+ """Types of control requests."""
26
+
27
+ APPROVAL = "approval"
28
+ INPUT = "input"
29
+ SELECT = "select" # Single or multiple choice selection
30
+ REVIEW = "review"
31
+ ESCALATION = "escalation"
32
+ UPLOAD = "upload" # File upload
33
+ INPUTS = "inputs" # Batched inputs (multiple requests in one)
34
+ CUSTOM = "custom" # Custom component type (uses metadata.component_type for routing)
35
+
36
+
37
+ class ControlOption(BaseModel):
38
+ """An option for a control request."""
39
+
40
+ label: str = Field(..., description="Display label for the option")
41
+ value: Any = Field(..., description="Value to return if selected")
42
+ style: str = Field(
43
+ default="default", description="Style hint: primary, danger, secondary, default"
44
+ )
45
+ description: Optional[str] = Field(
46
+ default=None, description="Optional description for the option"
47
+ )
48
+
49
+ model_config = {"arbitrary_types_allowed": True}
50
+
51
+
52
+ class ConversationMessage(BaseModel):
53
+ """A message in the conversation history."""
54
+
55
+ role: str = Field(..., description="Message role: agent, user, tool, system")
56
+ content: str = Field(..., description="Message content")
57
+ timestamp: datetime = Field(..., description="When the message was created")
58
+ tool_name: Optional[str] = Field(default=None, description="Tool name if role is 'tool'")
59
+ tool_input: Optional[Dict[str, Any]] = Field(default=None, description="Tool input parameters")
60
+ tool_output: Optional[Any] = Field(default=None, description="Tool output/result")
61
+
62
+ model_config = {"arbitrary_types_allowed": True}
63
+
64
+
65
+ class ControlInteraction(BaseModel):
66
+ """A prior control interaction in the same invocation."""
67
+
68
+ request_type: str = Field(..., description="Type of the original request")
69
+ message: str = Field(..., description="The original request message")
70
+ response_value: Any = Field(..., description="The response value")
71
+ responded_by: Optional[str] = Field(
72
+ default=None, description="Who responded (user ID or channel)"
73
+ )
74
+ responded_at: datetime = Field(..., description="When the response was received")
75
+ channel_id: str = Field(..., description="Channel that provided the response")
76
+
77
+ model_config = {"arbitrary_types_allowed": True}
78
+
79
+
80
+ class BacktraceEntry(BaseModel):
81
+ """Single entry in the execution backtrace."""
82
+
83
+ checkpoint_type: str = Field(
84
+ ..., description="Type of checkpoint (e.g., 'hitl', 'llm', 'tool')"
85
+ )
86
+ line: Optional[int] = Field(default=None, description="Source line number")
87
+ function_name: Optional[str] = Field(default=None, description="Function/procedure name")
88
+ duration_ms: Optional[float] = Field(default=None, description="Duration at this checkpoint")
89
+
90
+ model_config = {"arbitrary_types_allowed": True}
91
+
92
+
93
+ class RuntimeContext(BaseModel):
94
+ """
95
+ Context automatically captured from the Tactus runtime.
96
+
97
+ Includes source location, execution position, and backtrace.
98
+ This context is universally available regardless of how procedures are stored.
99
+ """
100
+
101
+ source_line: Optional[int] = Field(
102
+ default=None, description="Line number where request originated"
103
+ )
104
+ source_file: Optional[str] = Field(default=None, description="Source file path (if available)")
105
+ checkpoint_position: int = Field(default=0, description="Position in execution log")
106
+ procedure_name: str = Field(default="", description="Name of the running procedure")
107
+ invocation_id: str = Field(default="", description="Unique identifier for this execution")
108
+ started_at: Optional[datetime] = Field(default=None, description="When execution began")
109
+ elapsed_seconds: float = Field(default=0.0, description="Time since execution started")
110
+ backtrace: List[BacktraceEntry] = Field(
111
+ default_factory=list,
112
+ description="Execution path to reach this point",
113
+ )
114
+
115
+ model_config = {"arbitrary_types_allowed": True}
116
+
117
+
118
+ class ContextLink(BaseModel):
119
+ """
120
+ Application-provided context reference.
121
+
122
+ Allows host applications to inject domain-specific context
123
+ with optional deep links back to the source system.
124
+ """
125
+
126
+ name: str = Field(..., description="Display label (e.g., 'Evaluation', 'Customer')")
127
+ value: str = Field(..., description="Display value (e.g., 'Monthly QA Review')")
128
+ url: Optional[str] = Field(default=None, description="Optional deep link URL")
129
+
130
+ model_config = {"arbitrary_types_allowed": True}
131
+
132
+
133
+ class ControlRequestItem(BaseModel):
134
+ """
135
+ A single input item within a batched request.
136
+
137
+ Used when request_type='inputs' to specify multiple inputs
138
+ that should be collected together in a single interaction.
139
+ """
140
+
141
+ item_id: str = Field(..., description="Unique ID within batch (used as response key)")
142
+ label: str = Field(..., description="Short semantic label for tabs/UI")
143
+ request_type: ControlRequestType = Field(
144
+ ..., description="Type of input (approval, input, select, etc.)"
145
+ )
146
+ message: str = Field(..., description="Message to display")
147
+ options: List[ControlOption] = Field(
148
+ default_factory=list,
149
+ description="Options for select/review types",
150
+ )
151
+ default_value: Any = Field(default=None, description="Default value")
152
+ required: bool = Field(default=True, description="Whether this item must be completed")
153
+ metadata: Dict[str, Any] = Field(
154
+ default_factory=dict,
155
+ description="Type-specific metadata",
156
+ )
157
+
158
+ model_config = {"arbitrary_types_allowed": True}
159
+
160
+
161
+ class ControlRequest(BaseModel):
162
+ """
163
+ A request for controller input.
164
+
165
+ Includes rich context for decision-making: conversation history,
166
+ prior interactions, input summary, and namespace for routing.
167
+ """
168
+
169
+ # Identity
170
+ request_id: str = Field(..., description="Unique request identifier")
171
+ procedure_id: str = Field(..., description="Procedure that initiated the request")
172
+ procedure_name: str = Field(..., description="Human-readable procedure name")
173
+ invocation_id: str = Field(..., description="Unique invocation identifier")
174
+
175
+ # Routing
176
+ namespace: str = Field(
177
+ default="",
178
+ description="Namespace for routing/authorization (e.g., 'operations/incidents/level3')",
179
+ )
180
+
181
+ # Subject identification
182
+ subject: Optional[str] = Field(
183
+ default=None,
184
+ description="Human-readable subject identifier (e.g., 'John Doe', 'Order #12345')",
185
+ )
186
+
187
+ # Timing
188
+ started_at: datetime = Field(..., description="When the invocation started")
189
+ elapsed_seconds: int = Field(default=0, description="Seconds since invocation started")
190
+
191
+ # The request itself
192
+ request_type: ControlRequestType = Field(..., description="Type of interaction requested")
193
+ message: str = Field(..., description="Message to display to the controller")
194
+ label: Optional[str] = Field(
195
+ default=None,
196
+ description="Short semantic label for UI (e.g., tab name, chip label)",
197
+ )
198
+ options: List[ControlOption] = Field(
199
+ default_factory=list,
200
+ description="Options for the controller to choose from",
201
+ )
202
+ timeout_seconds: Optional[int] = Field(
203
+ default=None,
204
+ description="Timeout in seconds (None = wait forever)",
205
+ )
206
+ default_value: Any = Field(default=None, description="Default value on timeout")
207
+
208
+ # For batched inputs (request_type='inputs')
209
+ items: List[ControlRequestItem] = Field(
210
+ default_factory=list,
211
+ description="Individual input items (used when request_type='inputs')",
212
+ )
213
+
214
+ # Rich context
215
+ input_summary: Dict[str, Any] = Field(
216
+ default_factory=dict,
217
+ description="Summary of key input fields for context",
218
+ )
219
+ conversation: List[ConversationMessage] = Field(
220
+ default_factory=list,
221
+ description="Full conversation history with tool calls",
222
+ )
223
+ prior_interactions: List[ControlInteraction] = Field(
224
+ default_factory=list,
225
+ description="Previous control interactions in this invocation",
226
+ )
227
+
228
+ # New context architecture (Phase 5)
229
+ runtime_context: Optional[RuntimeContext] = Field(
230
+ default=None,
231
+ description="Automatically captured runtime context (source location, backtrace)",
232
+ )
233
+ application_context: List[ContextLink] = Field(
234
+ default_factory=list,
235
+ description="Host application-provided context links",
236
+ )
237
+
238
+ # Additional metadata
239
+ metadata: Dict[str, Any] = Field(
240
+ default_factory=dict,
241
+ description="Additional context and metadata",
242
+ )
243
+
244
+ model_config = {"arbitrary_types_allowed": True}
245
+
246
+
247
+ class ControlResponse(BaseModel):
248
+ """Response from a controller."""
249
+
250
+ request_id: str = Field(..., description="Request ID this responds to")
251
+ value: Any = Field(..., description="The response value from the controller")
252
+ responded_at: datetime = Field(
253
+ default_factory=utc_now, description="When the response was received"
254
+ )
255
+ timed_out: bool = Field(default=False, description="Whether the response timed out")
256
+ channel_id: Optional[str] = Field(
257
+ default=None, description="Channel that provided the response"
258
+ )
259
+ responder_id: Optional[str] = Field(
260
+ default=None, description="Controller identifier (user ID, model ID)"
261
+ )
262
+ responder_name: Optional[str] = Field(default=None, description="Display name of the responder")
263
+
264
+ model_config = {"arbitrary_types_allowed": True}
265
+
266
+
267
+ class ChannelCapabilities(BaseModel):
268
+ """Advertised capabilities of a control channel."""
269
+
270
+ supports_approval: bool = Field(default=True, description="Can handle approval requests")
271
+ supports_input: bool = Field(default=True, description="Can handle input requests")
272
+ supports_review: bool = Field(default=True, description="Can handle review requests")
273
+ supports_escalation: bool = Field(default=True, description="Can handle escalation alerts")
274
+ supports_select: bool = Field(default=True, description="Can handle select/choice requests")
275
+ supports_inputs: bool = Field(default=True, description="Can handle batched input requests")
276
+ supports_upload: bool = Field(default=False, description="Can handle file upload requests")
277
+ supports_interactive_buttons: bool = Field(
278
+ default=False,
279
+ description="Can render interactive buttons for responses",
280
+ )
281
+ supports_file_attachments: bool = Field(
282
+ default=False,
283
+ description="Can include file attachments",
284
+ )
285
+ max_message_length: Optional[int] = Field(
286
+ default=None,
287
+ description="Maximum message length (None = unlimited)",
288
+ )
289
+ is_synchronous: bool = Field(
290
+ default=False,
291
+ description="True if channel provides immediate responses (like CLI)",
292
+ )
293
+
294
+ model_config = {"arbitrary_types_allowed": True}
295
+
296
+
297
+ class DeliveryResult(BaseModel):
298
+ """Result of sending a control request to a channel."""
299
+
300
+ channel_id: str = Field(..., description="Channel identifier")
301
+ external_message_id: str = Field(
302
+ ...,
303
+ description="Channel-specific message ID for tracking/cancellation",
304
+ )
305
+ delivered_at: datetime = Field(..., description="When the request was delivered")
306
+ success: bool = Field(..., description="Whether delivery succeeded")
307
+ error_message: Optional[str] = Field(
308
+ default=None,
309
+ description="Error message if delivery failed",
310
+ )
311
+
312
+ model_config = {"arbitrary_types_allowed": True}
313
+
314
+
315
+ @runtime_checkable
316
+ class ControlChannel(Protocol):
317
+ """
318
+ Protocol for control channel implementations.
319
+
320
+ Control channels deliver requests to controllers (human or model) and
321
+ receive responses. The architecture is transport-agnostic - channels
322
+ decide how they communicate (WebSocket, SSE, polling, etc.).
323
+
324
+ Lifecycle:
325
+ 1. initialize() - Called at procedure start (eager initialization)
326
+ 2. send() - Send control request to the channel
327
+ 3. receive() - Yield responses as they arrive
328
+ 4. cancel() - Cancel/update when resolved via another channel
329
+ 5. shutdown() - Clean up on procedure end
330
+ """
331
+
332
+ @property
333
+ def channel_id(self) -> str:
334
+ """
335
+ Unique identifier for this channel.
336
+
337
+ Examples: 'cli', 'ide', 'tactus_cloud', 'slack'
338
+ """
339
+ ...
340
+
341
+ @property
342
+ def capabilities(self) -> ChannelCapabilities:
343
+ """
344
+ Return channel capabilities.
345
+
346
+ Used for routing decisions and UI adaptation.
347
+ """
348
+ ...
349
+
350
+ async def initialize(self) -> None:
351
+ """
352
+ Initialize the channel.
353
+
354
+ Called eagerly at procedure start to avoid blocking on slow
355
+ initialization (OAuth handshakes, WebSocket connections, etc.)
356
+ when a control request arrives.
357
+ """
358
+ ...
359
+
360
+ async def send(
361
+ self,
362
+ request: ControlRequest,
363
+ ) -> DeliveryResult:
364
+ """
365
+ Send a control request to this channel.
366
+
367
+ Args:
368
+ request: ControlRequest with full context
369
+
370
+ Returns:
371
+ DeliveryResult with delivery status and external message ID
372
+ """
373
+ ...
374
+
375
+ async def receive(self) -> AsyncIterator[ControlResponse]:
376
+ """
377
+ Yield responses as they arrive.
378
+
379
+ How responses arrive is channel-specific:
380
+ - CLI: Background thread reading stdin
381
+ - IDE/SSE: HTTP POST from extension
382
+ - WebSocket: Messages from connected clients
383
+ - Webhook: HTTP callbacks from external services
384
+
385
+ Yields:
386
+ ControlResponse as they are received
387
+ """
388
+ ...
389
+
390
+ async def cancel(self, external_message_id: str, reason: str) -> None:
391
+ """
392
+ Cancel or update a request when resolved via another channel.
393
+
394
+ Args:
395
+ external_message_id: Channel-specific message ID from DeliveryResult
396
+ reason: Reason for cancellation (e.g., "Responded via tactus_cloud")
397
+ """
398
+ ...
399
+
400
+ async def shutdown(self) -> None:
401
+ """
402
+ Clean shutdown of the channel.
403
+
404
+ Called at procedure end or on error. Should clean up resources,
405
+ close connections, etc.
406
+ """
407
+ ...
408
+
409
+
410
+ class ControlChannelConfig(BaseModel):
411
+ """Base configuration for control channels."""
412
+
413
+ enabled: bool = Field(default=False, description="Whether this channel is enabled")
414
+
415
+ model_config = {"arbitrary_types_allowed": True, "extra": "allow"}
416
+
417
+
418
+ class ControlLoopConfig(BaseModel):
419
+ """Top-level control loop configuration."""
420
+
421
+ enabled: bool = Field(default=True, description="Enable control loop system")
422
+ channels: Dict[str, Dict[str, Any]] = Field(
423
+ default_factory=dict,
424
+ description="Per-channel configuration",
425
+ )
426
+
427
+ model_config = {"arbitrary_types_allowed": True}
@@ -0,0 +1,207 @@
1
+ """
2
+ Notification channel protocol for omnichannel HITL notifications.
3
+
4
+ Defines the interface for notification channel plugins that can send
5
+ HITL requests to external systems (Slack, Discord, Teams, etc.).
6
+ """
7
+
8
+ from typing import Protocol, Optional, List, Dict, Any, runtime_checkable
9
+ from pydantic import BaseModel, Field
10
+ from datetime import datetime, timezone
11
+
12
+ from tactus.protocols.models import HITLRequest, HITLResponse
13
+
14
+
15
+ def utc_now() -> datetime:
16
+ """Return current UTC time as a timezone-aware datetime."""
17
+ return datetime.now(timezone.utc)
18
+
19
+
20
+ class ChannelCapabilities(BaseModel):
21
+ """Advertised capabilities of a notification channel."""
22
+
23
+ supports_approval: bool = Field(default=True, description="Can handle approval requests")
24
+ supports_input: bool = Field(default=True, description="Can handle input requests")
25
+ supports_review: bool = Field(default=True, description="Can handle review requests")
26
+ supports_escalation: bool = Field(default=True, description="Can handle escalation alerts")
27
+ supports_interactive_buttons: bool = Field(
28
+ default=False, description="Can render interactive buttons for responses"
29
+ )
30
+ supports_file_attachments: bool = Field(
31
+ default=False, description="Can include file attachments"
32
+ )
33
+ max_message_length: Optional[int] = Field(
34
+ default=None, description="Maximum message length (None = unlimited)"
35
+ )
36
+
37
+ model_config = {"arbitrary_types_allowed": True}
38
+
39
+
40
+ class NotificationDeliveryResult(BaseModel):
41
+ """Result of sending a notification to a channel."""
42
+
43
+ channel_id: str = Field(..., description="Channel identifier (e.g., 'slack', 'discord')")
44
+ external_message_id: str = Field(
45
+ ..., description="Channel-specific message ID for tracking/cancellation"
46
+ )
47
+ delivered_at: datetime = Field(..., description="When the notification was delivered")
48
+ success: bool = Field(..., description="Whether delivery succeeded")
49
+ error_message: Optional[str] = Field(
50
+ default=None, description="Error message if delivery failed"
51
+ )
52
+
53
+ model_config = {"arbitrary_types_allowed": True}
54
+
55
+
56
+ class PendingNotification(BaseModel):
57
+ """Tracks a pending HITL notification across multiple channels."""
58
+
59
+ request_id: str = Field(..., description="Unique request identifier")
60
+ procedure_id: str = Field(..., description="Procedure that initiated the request")
61
+ request: HITLRequest = Field(..., description="The original HITL request")
62
+ deliveries: List[NotificationDeliveryResult] = Field(
63
+ default_factory=list, description="Delivery results for each channel"
64
+ )
65
+ created_at: datetime = Field(
66
+ default_factory=utc_now, description="When the notification was created"
67
+ )
68
+ callback_url: str = Field(..., description="URL where channels should POST responses")
69
+ responded: bool = Field(default=False, description="Whether a response was received")
70
+ response: Optional[HITLResponse] = Field(default=None, description="The response if received")
71
+ response_channel: Optional[str] = Field(
72
+ default=None, description="Which channel provided the response"
73
+ )
74
+
75
+ model_config = {"arbitrary_types_allowed": True}
76
+
77
+
78
+ class HITLResponsePayload(BaseModel):
79
+ """Payload sent by notification channels when a user responds."""
80
+
81
+ channel_id: str = Field(..., description="Channel that received the response")
82
+ value: Any = Field(..., description="The response value from the user")
83
+ responder_id: Optional[str] = Field(
84
+ default=None, description="Channel-specific user identifier"
85
+ )
86
+ responder_name: Optional[str] = Field(default=None, description="Display name of the responder")
87
+ metadata: Dict[str, Any] = Field(
88
+ default_factory=dict, description="Additional channel-specific metadata"
89
+ )
90
+
91
+ model_config = {"arbitrary_types_allowed": True}
92
+
93
+
94
+ class HITLResponseResult(BaseModel):
95
+ """Result of processing an HITL response."""
96
+
97
+ success: bool = Field(..., description="Whether the response was processed successfully")
98
+ error: Optional[str] = Field(default=None, description="Error message if processing failed")
99
+ procedure_id: Optional[str] = Field(
100
+ default=None, description="Procedure ID if response was accepted"
101
+ )
102
+ response: Optional[HITLResponse] = Field(
103
+ default=None, description="The HITLResponse if accepted"
104
+ )
105
+ already_responded: bool = Field(
106
+ default=False, description="True if another channel already responded"
107
+ )
108
+
109
+ model_config = {"arbitrary_types_allowed": True}
110
+
111
+
112
+ @runtime_checkable
113
+ class NotificationChannel(Protocol):
114
+ """
115
+ Protocol for notification channel plugins.
116
+
117
+ Each implementation handles a specific platform (Slack, Discord, Teams, etc.).
118
+ Channels can be notification-only (fire-and-forget like email) or interactive
119
+ (can receive responses via buttons).
120
+ """
121
+
122
+ @property
123
+ def channel_id(self) -> str:
124
+ """
125
+ Unique identifier for this channel.
126
+
127
+ Examples: 'slack', 'discord', 'teams', 'email'
128
+ """
129
+ ...
130
+
131
+ @property
132
+ def capabilities(self) -> ChannelCapabilities:
133
+ """
134
+ Return channel capabilities.
135
+
136
+ Used for routing decisions (e.g., don't send approval requests
137
+ to channels that can't handle interactive responses).
138
+ """
139
+ ...
140
+
141
+ async def send_notification(
142
+ self,
143
+ procedure_id: str,
144
+ request_id: str,
145
+ request: HITLRequest,
146
+ callback_url: str,
147
+ ) -> NotificationDeliveryResult:
148
+ """
149
+ Send HITL request notification to this channel.
150
+
151
+ The channel should:
152
+ 1. Format the request appropriately for the platform
153
+ 2. Include callback_url in any interactive elements
154
+ 3. Return a delivery result with the external message ID
155
+
156
+ Args:
157
+ procedure_id: Unique procedure identifier
158
+ request_id: Unique request identifier for this HITL interaction
159
+ request: HITLRequest with interaction details
160
+ callback_url: URL where responses should be POSTed
161
+
162
+ Returns:
163
+ NotificationDeliveryResult with delivery status and message ID
164
+ """
165
+ ...
166
+
167
+ async def cancel_notification(
168
+ self,
169
+ external_message_id: str,
170
+ reason: str = "Resolved via another channel",
171
+ ) -> None:
172
+ """
173
+ Cancel or update a notification (e.g., mark as resolved, disable buttons).
174
+
175
+ Called when a response is received from another channel.
176
+
177
+ Args:
178
+ external_message_id: Channel-specific message ID from delivery result
179
+ reason: Reason for cancellation (for display)
180
+ """
181
+ ...
182
+
183
+
184
+ class NotificationChannelConfig(BaseModel):
185
+ """Base configuration for notification channels."""
186
+
187
+ enabled: bool = Field(default=False, description="Whether this channel is enabled")
188
+
189
+ model_config = {"arbitrary_types_allowed": True, "extra": "allow"}
190
+
191
+
192
+ class NotificationsConfig(BaseModel):
193
+ """Top-level notifications configuration."""
194
+
195
+ enabled: bool = Field(default=False, description="Enable notification system")
196
+ callback_base_url: Optional[str] = Field(
197
+ default=None,
198
+ description="Base URL for response callbacks (e.g., 'https://my-tactus.example.com')",
199
+ )
200
+ signing_secret: Optional[str] = Field(
201
+ default=None, description="Secret for signing/verifying response webhooks"
202
+ )
203
+ channels: Dict[str, Dict[str, Any]] = Field(
204
+ default_factory=dict, description="Per-channel configuration"
205
+ )
206
+
207
+ model_config = {"arbitrary_types_allowed": True}