tactus 0.33.0__py3-none-any.whl → 0.34.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tactus/__init__.py +1 -1
- tactus/adapters/__init__.py +18 -1
- tactus/adapters/broker_log.py +127 -34
- tactus/adapters/channels/__init__.py +153 -0
- tactus/adapters/channels/base.py +174 -0
- tactus/adapters/channels/broker.py +179 -0
- tactus/adapters/channels/cli.py +448 -0
- tactus/adapters/channels/host.py +225 -0
- tactus/adapters/channels/ipc.py +297 -0
- tactus/adapters/channels/sse.py +305 -0
- tactus/adapters/cli_hitl.py +223 -1
- tactus/adapters/control_loop.py +879 -0
- tactus/adapters/file_storage.py +35 -2
- tactus/adapters/ide_log.py +7 -1
- tactus/backends/http_backend.py +0 -1
- tactus/broker/client.py +31 -1
- tactus/broker/server.py +416 -92
- tactus/cli/app.py +270 -7
- tactus/cli/control.py +393 -0
- tactus/core/config_manager.py +33 -6
- tactus/core/dsl_stubs.py +102 -18
- tactus/core/execution_context.py +265 -8
- tactus/core/lua_sandbox.py +8 -9
- tactus/core/registry.py +19 -2
- tactus/core/runtime.py +235 -27
- tactus/docker/Dockerfile.pypi +49 -0
- tactus/docs/__init__.py +33 -0
- tactus/docs/extractor.py +326 -0
- tactus/docs/html_renderer.py +72 -0
- tactus/docs/models.py +121 -0
- tactus/docs/templates/base.html +204 -0
- tactus/docs/templates/index.html +58 -0
- tactus/docs/templates/module.html +96 -0
- tactus/dspy/agent.py +382 -22
- tactus/dspy/broker_lm.py +57 -6
- tactus/dspy/config.py +14 -3
- tactus/dspy/history.py +2 -1
- tactus/dspy/module.py +136 -11
- tactus/dspy/signature.py +0 -1
- tactus/ide/server.py +300 -9
- tactus/primitives/human.py +619 -47
- tactus/primitives/system.py +0 -1
- tactus/protocols/__init__.py +25 -0
- tactus/protocols/control.py +427 -0
- tactus/protocols/notification.py +207 -0
- tactus/sandbox/container_runner.py +79 -11
- tactus/sandbox/docker_manager.py +23 -0
- tactus/sandbox/entrypoint.py +26 -0
- tactus/sandbox/protocol.py +3 -0
- tactus/stdlib/README.md +77 -0
- tactus/stdlib/__init__.py +27 -1
- tactus/stdlib/classify/__init__.py +165 -0
- tactus/stdlib/classify/classify.spec.tac +195 -0
- tactus/stdlib/classify/classify.tac +257 -0
- tactus/stdlib/classify/fuzzy.py +282 -0
- tactus/stdlib/classify/llm.py +319 -0
- tactus/stdlib/classify/primitive.py +287 -0
- tactus/stdlib/core/__init__.py +57 -0
- tactus/stdlib/core/base.py +320 -0
- tactus/stdlib/core/confidence.py +211 -0
- tactus/stdlib/core/models.py +161 -0
- tactus/stdlib/core/retry.py +171 -0
- tactus/stdlib/core/validation.py +274 -0
- tactus/stdlib/extract/__init__.py +125 -0
- tactus/stdlib/extract/llm.py +330 -0
- tactus/stdlib/extract/primitive.py +256 -0
- tactus/stdlib/tac/tactus/classify/base.tac +51 -0
- tactus/stdlib/tac/tactus/classify/fuzzy.tac +87 -0
- tactus/stdlib/tac/tactus/classify/index.md +77 -0
- tactus/stdlib/tac/tactus/classify/init.tac +29 -0
- tactus/stdlib/tac/tactus/classify/llm.tac +150 -0
- tactus/stdlib/tac/tactus/classify.spec.tac +191 -0
- tactus/stdlib/tac/tactus/extract/base.tac +138 -0
- tactus/stdlib/tac/tactus/extract/index.md +96 -0
- tactus/stdlib/tac/tactus/extract/init.tac +27 -0
- tactus/stdlib/tac/tactus/extract/llm.tac +201 -0
- tactus/stdlib/tac/tactus/extract.spec.tac +153 -0
- tactus/stdlib/tac/tactus/generate/base.tac +142 -0
- tactus/stdlib/tac/tactus/generate/index.md +195 -0
- tactus/stdlib/tac/tactus/generate/init.tac +28 -0
- tactus/stdlib/tac/tactus/generate/llm.tac +169 -0
- tactus/stdlib/tac/tactus/generate.spec.tac +210 -0
- tactus/testing/behave_integration.py +171 -7
- tactus/testing/context.py +0 -1
- tactus/testing/evaluation_runner.py +0 -1
- tactus/testing/gherkin_parser.py +0 -1
- tactus/testing/mock_hitl.py +0 -1
- tactus/testing/mock_tools.py +0 -1
- tactus/testing/models.py +0 -1
- tactus/testing/steps/builtin.py +0 -1
- tactus/testing/steps/custom.py +81 -22
- tactus/testing/steps/registry.py +0 -1
- tactus/testing/test_runner.py +7 -1
- tactus/validation/semantic_visitor.py +11 -5
- tactus/validation/validator.py +0 -1
- {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/METADATA +14 -2
- {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/RECORD +100 -49
- {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/WHEEL +0 -0
- {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/entry_points.txt +0 -0
- {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/licenses/LICENSE +0 -0
tactus/primitives/system.py
CHANGED
tactus/protocols/__init__.py
CHANGED
|
@@ -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}
|