attune-ai 2.1.5__py3-none-any.whl → 2.2.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.
- attune/cli/__init__.py +3 -59
- attune/cli/commands/batch.py +4 -12
- attune/cli/commands/cache.py +7 -15
- attune/cli/commands/provider.py +17 -0
- attune/cli/commands/routing.py +3 -1
- attune/cli/commands/setup.py +122 -0
- attune/cli/commands/tier.py +1 -3
- attune/cli/commands/workflow.py +31 -0
- attune/cli/parsers/cache.py +1 -0
- attune/cli/parsers/help.py +1 -3
- attune/cli/parsers/provider.py +7 -0
- attune/cli/parsers/routing.py +1 -3
- attune/cli/parsers/setup.py +7 -0
- attune/cli/parsers/status.py +1 -3
- attune/cli/parsers/tier.py +1 -3
- attune/cli_minimal.py +9 -3
- attune/cli_router.py +9 -7
- attune/cli_unified.py +3 -0
- attune/dashboard/app.py +3 -1
- attune/dashboard/simple_server.py +3 -1
- attune/dashboard/standalone_server.py +7 -3
- attune/mcp/server.py +54 -102
- attune/memory/long_term.py +0 -2
- attune/memory/short_term/__init__.py +84 -0
- attune/memory/short_term/base.py +467 -0
- attune/memory/short_term/batch.py +219 -0
- attune/memory/short_term/caching.py +227 -0
- attune/memory/short_term/conflicts.py +265 -0
- attune/memory/short_term/cross_session.py +122 -0
- attune/memory/short_term/facade.py +655 -0
- attune/memory/short_term/pagination.py +215 -0
- attune/memory/short_term/patterns.py +271 -0
- attune/memory/short_term/pubsub.py +286 -0
- attune/memory/short_term/queues.py +244 -0
- attune/memory/short_term/security.py +300 -0
- attune/memory/short_term/sessions.py +250 -0
- attune/memory/short_term/streams.py +249 -0
- attune/memory/short_term/timelines.py +234 -0
- attune/memory/short_term/transactions.py +186 -0
- attune/memory/short_term/working.py +252 -0
- attune/meta_workflows/cli_commands/__init__.py +3 -0
- attune/meta_workflows/cli_commands/agent_commands.py +0 -4
- attune/meta_workflows/cli_commands/analytics_commands.py +0 -6
- attune/meta_workflows/cli_commands/config_commands.py +0 -5
- attune/meta_workflows/cli_commands/memory_commands.py +0 -5
- attune/meta_workflows/cli_commands/template_commands.py +0 -5
- attune/meta_workflows/cli_commands/workflow_commands.py +0 -6
- attune/models/adaptive_routing.py +4 -8
- attune/models/auth_cli.py +3 -9
- attune/models/auth_strategy.py +2 -4
- attune/models/telemetry/analytics.py +0 -2
- attune/models/telemetry/backend.py +0 -3
- attune/models/telemetry/storage.py +0 -2
- attune/orchestration/_strategies/__init__.py +156 -0
- attune/orchestration/_strategies/base.py +231 -0
- attune/orchestration/_strategies/conditional_strategies.py +373 -0
- attune/orchestration/_strategies/conditions.py +369 -0
- attune/orchestration/_strategies/core_strategies.py +491 -0
- attune/orchestration/_strategies/data_classes.py +64 -0
- attune/orchestration/_strategies/nesting.py +233 -0
- attune/orchestration/execution_strategies.py +58 -1567
- attune/orchestration/meta_orchestrator.py +1 -3
- attune/project_index/scanner.py +1 -3
- attune/project_index/scanner_parallel.py +7 -5
- attune/socratic_router.py +1 -3
- attune/telemetry/agent_coordination.py +9 -3
- attune/telemetry/agent_tracking.py +16 -3
- attune/telemetry/approval_gates.py +22 -5
- attune/telemetry/cli.py +1 -3
- attune/telemetry/commands/dashboard_commands.py +24 -8
- attune/telemetry/event_streaming.py +8 -2
- attune/telemetry/feedback_loop.py +10 -2
- attune/tools.py +1 -0
- attune/workflow_commands.py +1 -3
- attune/workflows/__init__.py +53 -10
- attune/workflows/autonomous_test_gen.py +158 -102
- attune/workflows/base.py +48 -672
- attune/workflows/batch_processing.py +1 -3
- attune/workflows/compat.py +156 -0
- attune/workflows/cost_mixin.py +141 -0
- attune/workflows/data_classes.py +92 -0
- attune/workflows/document_gen/workflow.py +11 -14
- attune/workflows/history.py +62 -37
- attune/workflows/llm_base.py +1 -3
- attune/workflows/migration.py +422 -0
- attune/workflows/output.py +2 -7
- attune/workflows/parsing_mixin.py +427 -0
- attune/workflows/perf_audit.py +3 -1
- attune/workflows/progress.py +9 -11
- attune/workflows/release_prep.py +5 -1
- attune/workflows/routing.py +0 -2
- attune/workflows/secure_release.py +2 -1
- attune/workflows/security_audit.py +19 -14
- attune/workflows/security_audit_phase3.py +28 -22
- attune/workflows/seo_optimization.py +27 -27
- attune/workflows/test_gen/test_templates.py +1 -4
- attune/workflows/test_gen/workflow.py +0 -2
- attune/workflows/test_gen_behavioral.py +6 -19
- attune/workflows/test_gen_parallel.py +6 -4
- {attune_ai-2.1.5.dist-info → attune_ai-2.2.0.dist-info}/METADATA +4 -3
- {attune_ai-2.1.5.dist-info → attune_ai-2.2.0.dist-info}/RECORD +116 -91
- {attune_ai-2.1.5.dist-info → attune_ai-2.2.0.dist-info}/entry_points.txt +0 -2
- attune_healthcare/monitors/monitoring/__init__.py +9 -9
- attune_llm/agent_factory/__init__.py +6 -6
- attune_llm/commands/__init__.py +10 -10
- attune_llm/commands/models.py +3 -3
- attune_llm/config/__init__.py +8 -8
- attune_llm/learning/__init__.py +3 -3
- attune_llm/learning/extractor.py +5 -3
- attune_llm/learning/storage.py +5 -3
- attune_llm/security/__init__.py +17 -17
- attune_llm/utils/tokens.py +3 -1
- attune/cli_legacy.py +0 -3978
- attune/memory/short_term.py +0 -2192
- attune/workflows/manage_docs.py +0 -87
- attune/workflows/test5.py +0 -125
- {attune_ai-2.1.5.dist-info → attune_ai-2.2.0.dist-info}/WHEEL +0 -0
- {attune_ai-2.1.5.dist-info → attune_ai-2.2.0.dist-info}/licenses/LICENSE +0 -0
- {attune_ai-2.1.5.dist-info → attune_ai-2.2.0.dist-info}/licenses/LICENSE_CHANGE_ANNOUNCEMENT.md +0 -0
- {attune_ai-2.1.5.dist-info → attune_ai-2.2.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Data sanitization - PII scrubbing and secrets detection.
|
|
2
|
+
|
|
3
|
+
This module handles security-sensitive data processing:
|
|
4
|
+
- PII (Personally Identifiable Information) scrubbing
|
|
5
|
+
- Secrets detection (API keys, passwords, tokens)
|
|
6
|
+
- Data sanitization before storage
|
|
7
|
+
|
|
8
|
+
Classes:
|
|
9
|
+
DataSanitizer: Handles PII and secrets scrubbing
|
|
10
|
+
|
|
11
|
+
Dependencies:
|
|
12
|
+
PIIScrubber: External PII detection/removal
|
|
13
|
+
SecretsDetector: External secrets detection
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
>>> from attune.memory.short_term.security import DataSanitizer
|
|
17
|
+
>>> from attune.memory.types import RedisMetrics
|
|
18
|
+
>>> sanitizer = DataSanitizer(
|
|
19
|
+
... pii_scrub_enabled=True,
|
|
20
|
+
... secrets_detection_enabled=True,
|
|
21
|
+
... metrics=RedisMetrics()
|
|
22
|
+
... )
|
|
23
|
+
>>> clean_data, pii_count = sanitizer.sanitize({"email": "user@example.com"})
|
|
24
|
+
>>> print(f"Scrubbed {pii_count} PII items")
|
|
25
|
+
|
|
26
|
+
Copyright 2025 Smart-AI-Memory
|
|
27
|
+
Licensed under Fair Source License 0.9
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
from typing import TYPE_CHECKING, Any
|
|
34
|
+
|
|
35
|
+
import structlog
|
|
36
|
+
|
|
37
|
+
from attune.memory.security.pii_scrubber import PIIScrubber
|
|
38
|
+
from attune.memory.security.secrets_detector import SecretsDetector
|
|
39
|
+
from attune.memory.security.secrets_detector import Severity as SecretSeverity
|
|
40
|
+
from attune.memory.types import RedisMetrics, SecurityError
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
logger = structlog.get_logger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DataSanitizer:
|
|
49
|
+
"""Handles data sanitization for short-term memory.
|
|
50
|
+
|
|
51
|
+
Provides PII scrubbing and secrets detection to protect
|
|
52
|
+
sensitive information from being stored in Redis.
|
|
53
|
+
|
|
54
|
+
PII Detection:
|
|
55
|
+
- Email addresses
|
|
56
|
+
- Social Security Numbers (SSN)
|
|
57
|
+
- Phone numbers
|
|
58
|
+
- Credit card numbers
|
|
59
|
+
|
|
60
|
+
Secrets Detection:
|
|
61
|
+
- API keys
|
|
62
|
+
- Passwords
|
|
63
|
+
- Tokens
|
|
64
|
+
- Private keys
|
|
65
|
+
|
|
66
|
+
Attributes:
|
|
67
|
+
pii_enabled: Whether PII scrubbing is active
|
|
68
|
+
secrets_enabled: Whether secrets detection is active
|
|
69
|
+
metrics: RedisMetrics instance for tracking
|
|
70
|
+
|
|
71
|
+
Example:
|
|
72
|
+
>>> sanitizer = DataSanitizer(
|
|
73
|
+
... pii_scrub_enabled=True,
|
|
74
|
+
... secrets_detection_enabled=True,
|
|
75
|
+
... metrics=RedisMetrics()
|
|
76
|
+
... )
|
|
77
|
+
>>> data = {"user": "john", "email": "john@example.com"}
|
|
78
|
+
>>> clean, count = sanitizer.sanitize(data)
|
|
79
|
+
>>> print(clean) # Email will be redacted
|
|
80
|
+
{'user': 'john', 'email': '[EMAIL]'}
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
pii_scrub_enabled: bool = False,
|
|
86
|
+
secrets_detection_enabled: bool = False,
|
|
87
|
+
metrics: RedisMetrics | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Initialize data sanitizer.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
pii_scrub_enabled: Enable PII scrubbing (emails, SSN, etc.)
|
|
93
|
+
secrets_detection_enabled: Enable secrets detection (API keys, etc.)
|
|
94
|
+
metrics: Optional RedisMetrics for tracking scrub operations
|
|
95
|
+
"""
|
|
96
|
+
self.pii_enabled = pii_scrub_enabled
|
|
97
|
+
self.secrets_enabled = secrets_detection_enabled
|
|
98
|
+
self._metrics = metrics or RedisMetrics()
|
|
99
|
+
|
|
100
|
+
# Initialize scrubbers
|
|
101
|
+
self._pii_scrubber: PIIScrubber | None = None
|
|
102
|
+
self._secrets_detector: SecretsDetector | None = None
|
|
103
|
+
|
|
104
|
+
if pii_scrub_enabled:
|
|
105
|
+
self._pii_scrubber = PIIScrubber(enable_name_detection=False)
|
|
106
|
+
logger.debug(
|
|
107
|
+
"pii_scrubber_enabled",
|
|
108
|
+
message="PII scrubbing active for data sanitizer",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if secrets_detection_enabled:
|
|
112
|
+
self._secrets_detector = SecretsDetector()
|
|
113
|
+
logger.debug(
|
|
114
|
+
"secrets_detector_enabled",
|
|
115
|
+
message="Secrets detection active for data sanitizer",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def metrics(self) -> RedisMetrics:
|
|
120
|
+
"""Get metrics instance."""
|
|
121
|
+
return self._metrics
|
|
122
|
+
|
|
123
|
+
def sanitize(self, data: Any) -> tuple[Any, int]:
|
|
124
|
+
"""Sanitize data by scrubbing PII and checking for secrets.
|
|
125
|
+
|
|
126
|
+
Performs two-step sanitization:
|
|
127
|
+
1. Checks for secrets (API keys, passwords) - blocks if found
|
|
128
|
+
2. Scrubs PII (emails, SSN, phone numbers) - redacts in place
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
data: Data to sanitize (dict, list, or str)
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Tuple of (sanitized_data, pii_count)
|
|
135
|
+
- sanitized_data: Data with PII redacted
|
|
136
|
+
- pii_count: Number of PII items found and scrubbed
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
SecurityError: If critical/high severity secrets are detected
|
|
140
|
+
|
|
141
|
+
Example:
|
|
142
|
+
>>> sanitizer = DataSanitizer(pii_scrub_enabled=True)
|
|
143
|
+
>>> data = {"contact": "user@example.com"}
|
|
144
|
+
>>> clean, count = sanitizer.sanitize(data)
|
|
145
|
+
>>> count
|
|
146
|
+
1
|
|
147
|
+
"""
|
|
148
|
+
pii_count = 0
|
|
149
|
+
|
|
150
|
+
if data is None:
|
|
151
|
+
return data, 0
|
|
152
|
+
|
|
153
|
+
# Convert data to string for scanning
|
|
154
|
+
if isinstance(data, dict):
|
|
155
|
+
data_str = json.dumps(data)
|
|
156
|
+
elif isinstance(data, list):
|
|
157
|
+
data_str = json.dumps(data)
|
|
158
|
+
elif isinstance(data, str):
|
|
159
|
+
data_str = data
|
|
160
|
+
else:
|
|
161
|
+
# For other types, convert to string
|
|
162
|
+
data_str = str(data)
|
|
163
|
+
|
|
164
|
+
# Check for secrets first (before modifying data)
|
|
165
|
+
if self._secrets_detector is not None:
|
|
166
|
+
detections = self._secrets_detector.detect(data_str)
|
|
167
|
+
# Block critical and high severity secrets
|
|
168
|
+
critical_secrets = [
|
|
169
|
+
d
|
|
170
|
+
for d in detections
|
|
171
|
+
if d.severity in (SecretSeverity.CRITICAL, SecretSeverity.HIGH)
|
|
172
|
+
]
|
|
173
|
+
if critical_secrets:
|
|
174
|
+
self._metrics.secrets_blocked_total += len(critical_secrets)
|
|
175
|
+
secret_types = [d.secret_type.value for d in critical_secrets]
|
|
176
|
+
logger.warning(
|
|
177
|
+
"secrets_detected_blocked",
|
|
178
|
+
secret_types=secret_types,
|
|
179
|
+
count=len(critical_secrets),
|
|
180
|
+
)
|
|
181
|
+
raise SecurityError(
|
|
182
|
+
f"Cannot store data containing secrets: {secret_types}. "
|
|
183
|
+
"Remove sensitive credentials before storing."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Scrub PII
|
|
187
|
+
if self._pii_scrubber is not None:
|
|
188
|
+
sanitized_str, pii_detections = self._pii_scrubber.scrub(data_str)
|
|
189
|
+
pii_count = len(pii_detections)
|
|
190
|
+
|
|
191
|
+
if pii_count > 0:
|
|
192
|
+
self._metrics.pii_scrubbed_total += pii_count
|
|
193
|
+
self._metrics.pii_scrub_operations += 1
|
|
194
|
+
logger.debug(
|
|
195
|
+
"pii_scrubbed",
|
|
196
|
+
pii_count=pii_count,
|
|
197
|
+
pii_types=[d.pii_type for d in pii_detections],
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Convert back to original type
|
|
201
|
+
if isinstance(data, dict):
|
|
202
|
+
try:
|
|
203
|
+
return json.loads(sanitized_str), pii_count
|
|
204
|
+
except json.JSONDecodeError:
|
|
205
|
+
# If PII scrubbing broke JSON structure, return original
|
|
206
|
+
# This can happen if regex matches part of JSON syntax
|
|
207
|
+
logger.warning("pii_scrubbing_broke_json_returning_original")
|
|
208
|
+
return data, 0
|
|
209
|
+
elif isinstance(data, list):
|
|
210
|
+
try:
|
|
211
|
+
return json.loads(sanitized_str), pii_count
|
|
212
|
+
except json.JSONDecodeError:
|
|
213
|
+
logger.warning("pii_scrubbing_broke_json_returning_original")
|
|
214
|
+
return data, 0
|
|
215
|
+
else:
|
|
216
|
+
return sanitized_str, pii_count
|
|
217
|
+
|
|
218
|
+
return data, pii_count
|
|
219
|
+
|
|
220
|
+
def check_secrets(self, data: Any) -> list[str]:
|
|
221
|
+
"""Check data for secrets without blocking.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
data: Data to check (dict, list, or str)
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
List of detected secret types (empty if none found)
|
|
228
|
+
|
|
229
|
+
Example:
|
|
230
|
+
>>> sanitizer = DataSanitizer(secrets_detection_enabled=True)
|
|
231
|
+
>>> secrets = sanitizer.check_secrets({"key": "sk-abc123..."})
|
|
232
|
+
>>> if secrets:
|
|
233
|
+
... print(f"Found secrets: {secrets}")
|
|
234
|
+
"""
|
|
235
|
+
if self._secrets_detector is None or data is None:
|
|
236
|
+
return []
|
|
237
|
+
|
|
238
|
+
# Convert data to string for scanning
|
|
239
|
+
if isinstance(data, dict):
|
|
240
|
+
data_str = json.dumps(data)
|
|
241
|
+
elif isinstance(data, list):
|
|
242
|
+
data_str = json.dumps(data)
|
|
243
|
+
elif isinstance(data, str):
|
|
244
|
+
data_str = data
|
|
245
|
+
else:
|
|
246
|
+
data_str = str(data)
|
|
247
|
+
|
|
248
|
+
detections = self._secrets_detector.detect(data_str)
|
|
249
|
+
return [d.secret_type.value for d in detections]
|
|
250
|
+
|
|
251
|
+
def scrub_pii_only(self, data: Any) -> tuple[Any, int]:
|
|
252
|
+
"""Scrub only PII from data (no secrets blocking).
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
data: Data to scrub (dict, list, or str)
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
Tuple of (scrubbed_data, pii_count)
|
|
259
|
+
|
|
260
|
+
Example:
|
|
261
|
+
>>> sanitizer = DataSanitizer(pii_scrub_enabled=True)
|
|
262
|
+
>>> clean, count = sanitizer.scrub_pii_only("Email: user@example.com")
|
|
263
|
+
>>> print(clean) # "Email: [EMAIL]"
|
|
264
|
+
"""
|
|
265
|
+
if self._pii_scrubber is None or data is None:
|
|
266
|
+
return data, 0
|
|
267
|
+
|
|
268
|
+
# Convert data to string
|
|
269
|
+
if isinstance(data, dict):
|
|
270
|
+
data_str = json.dumps(data)
|
|
271
|
+
elif isinstance(data, list):
|
|
272
|
+
data_str = json.dumps(data)
|
|
273
|
+
elif isinstance(data, str):
|
|
274
|
+
data_str = data
|
|
275
|
+
else:
|
|
276
|
+
data_str = str(data)
|
|
277
|
+
|
|
278
|
+
sanitized_str, pii_detections = self._pii_scrubber.scrub(data_str)
|
|
279
|
+
pii_count = len(pii_detections)
|
|
280
|
+
|
|
281
|
+
if pii_count == 0:
|
|
282
|
+
return data, 0
|
|
283
|
+
|
|
284
|
+
# Update metrics
|
|
285
|
+
self._metrics.pii_scrubbed_total += pii_count
|
|
286
|
+
self._metrics.pii_scrub_operations += 1
|
|
287
|
+
|
|
288
|
+
# Convert back to original type
|
|
289
|
+
if isinstance(data, dict):
|
|
290
|
+
try:
|
|
291
|
+
return json.loads(sanitized_str), pii_count
|
|
292
|
+
except json.JSONDecodeError:
|
|
293
|
+
return data, 0
|
|
294
|
+
elif isinstance(data, list):
|
|
295
|
+
try:
|
|
296
|
+
return json.loads(sanitized_str), pii_count
|
|
297
|
+
except json.JSONDecodeError:
|
|
298
|
+
return data, 0
|
|
299
|
+
else:
|
|
300
|
+
return sanitized_str, pii_count
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Collaboration session management.
|
|
2
|
+
|
|
3
|
+
This module manages multi-agent collaboration sessions:
|
|
4
|
+
- Create: Start a new collaboration session
|
|
5
|
+
- Join: Add agents to existing session
|
|
6
|
+
- Get: Retrieve session information
|
|
7
|
+
|
|
8
|
+
Key Prefix: PREFIX_SESSION = "empathy:session:"
|
|
9
|
+
|
|
10
|
+
Classes:
|
|
11
|
+
SessionManager: Collaboration session operations
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
>>> from attune.memory.short_term.sessions import SessionManager
|
|
15
|
+
>>> from attune.memory.types import AgentCredentials, AccessTier
|
|
16
|
+
>>> sessions = SessionManager(base_ops)
|
|
17
|
+
>>> creds = AgentCredentials("agent_1", AccessTier.CONTRIBUTOR)
|
|
18
|
+
>>> sessions.create_session("session_1", creds, {"topic": "refactoring"})
|
|
19
|
+
>>> sessions.join_session("session_1", other_agent_creds)
|
|
20
|
+
|
|
21
|
+
Copyright 2025 Smart-AI-Memory
|
|
22
|
+
Licensed under Fair Source License 0.9
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
from typing import TYPE_CHECKING, Any
|
|
30
|
+
|
|
31
|
+
import structlog
|
|
32
|
+
|
|
33
|
+
from attune.memory.types import (
|
|
34
|
+
AgentCredentials,
|
|
35
|
+
TTLStrategy,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from attune.memory.short_term.base import BaseOperations
|
|
40
|
+
|
|
41
|
+
logger = structlog.get_logger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SessionManager:
|
|
45
|
+
"""Collaboration session operations.
|
|
46
|
+
|
|
47
|
+
Manages multi-agent collaboration sessions including creation,
|
|
48
|
+
joining, and retrieval. Sessions track participants and metadata.
|
|
49
|
+
|
|
50
|
+
The class is designed to be composed with BaseOperations
|
|
51
|
+
for dependency injection.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
PREFIX_SESSION: Key prefix for session namespace
|
|
55
|
+
|
|
56
|
+
Example:
|
|
57
|
+
>>> sessions = SessionManager(base_ops)
|
|
58
|
+
>>> creds = AgentCredentials("agent_1", AccessTier.CONTRIBUTOR)
|
|
59
|
+
>>> sessions.create_session("session_1", creds)
|
|
60
|
+
True
|
|
61
|
+
>>> info = sessions.get_session("session_1", creds)
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
PREFIX_SESSION = "empathy:session:"
|
|
65
|
+
|
|
66
|
+
def __init__(self, base: BaseOperations) -> None:
|
|
67
|
+
"""Initialize session manager.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
base: BaseOperations instance for storage access
|
|
71
|
+
"""
|
|
72
|
+
self._base = base
|
|
73
|
+
|
|
74
|
+
def create_session(
|
|
75
|
+
self,
|
|
76
|
+
session_id: str,
|
|
77
|
+
credentials: AgentCredentials,
|
|
78
|
+
metadata: dict | None = None,
|
|
79
|
+
) -> bool:
|
|
80
|
+
"""Create a collaboration session.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
session_id: Unique session identifier
|
|
84
|
+
credentials: Session creator
|
|
85
|
+
metadata: Optional session metadata
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
True if created
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ValueError: If session_id is empty
|
|
92
|
+
TypeError: If metadata is not dict
|
|
93
|
+
|
|
94
|
+
Example:
|
|
95
|
+
>>> sessions.create_session(
|
|
96
|
+
... "session_1",
|
|
97
|
+
... creds,
|
|
98
|
+
... metadata={"topic": "code review"},
|
|
99
|
+
... )
|
|
100
|
+
True
|
|
101
|
+
"""
|
|
102
|
+
# Pattern 1: String ID validation
|
|
103
|
+
if not session_id or not session_id.strip():
|
|
104
|
+
raise ValueError(f"session_id cannot be empty. Got: {session_id!r}")
|
|
105
|
+
|
|
106
|
+
# Pattern 5: Type validation
|
|
107
|
+
if metadata is not None and not isinstance(metadata, dict):
|
|
108
|
+
raise TypeError(f"metadata must be dict, got {type(metadata).__name__}")
|
|
109
|
+
|
|
110
|
+
key = f"{self.PREFIX_SESSION}{session_id}"
|
|
111
|
+
payload = {
|
|
112
|
+
"session_id": session_id,
|
|
113
|
+
"created_by": credentials.agent_id,
|
|
114
|
+
"created_at": datetime.now().isoformat(),
|
|
115
|
+
"participants": [credentials.agent_id],
|
|
116
|
+
"metadata": metadata or {},
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
success = self._base._set(key, json.dumps(payload), TTLStrategy.SESSION.value)
|
|
120
|
+
|
|
121
|
+
if success:
|
|
122
|
+
logger.info(
|
|
123
|
+
"session_created",
|
|
124
|
+
session_id=session_id,
|
|
125
|
+
created_by=credentials.agent_id,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return success
|
|
129
|
+
|
|
130
|
+
def join_session(
|
|
131
|
+
self,
|
|
132
|
+
session_id: str,
|
|
133
|
+
credentials: AgentCredentials,
|
|
134
|
+
) -> bool:
|
|
135
|
+
"""Join an existing session.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
session_id: Session to join
|
|
139
|
+
credentials: Joining agent
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
True if joined
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
ValueError: If session_id is empty
|
|
146
|
+
|
|
147
|
+
Example:
|
|
148
|
+
>>> sessions.join_session("session_1", agent2_creds)
|
|
149
|
+
True
|
|
150
|
+
"""
|
|
151
|
+
# Pattern 1: String ID validation
|
|
152
|
+
if not session_id or not session_id.strip():
|
|
153
|
+
raise ValueError(f"session_id cannot be empty. Got: {session_id!r}")
|
|
154
|
+
|
|
155
|
+
key = f"{self.PREFIX_SESSION}{session_id}"
|
|
156
|
+
raw = self._base._get(key)
|
|
157
|
+
|
|
158
|
+
if raw is None:
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
payload = json.loads(raw)
|
|
162
|
+
if credentials.agent_id not in payload["participants"]:
|
|
163
|
+
payload["participants"].append(credentials.agent_id)
|
|
164
|
+
logger.info(
|
|
165
|
+
"session_joined",
|
|
166
|
+
session_id=session_id,
|
|
167
|
+
agent_id=credentials.agent_id,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return self._base._set(key, json.dumps(payload), TTLStrategy.SESSION.value)
|
|
171
|
+
|
|
172
|
+
def get_session(
|
|
173
|
+
self,
|
|
174
|
+
session_id: str,
|
|
175
|
+
credentials: AgentCredentials,
|
|
176
|
+
) -> dict[str, Any] | None:
|
|
177
|
+
"""Get session information.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
session_id: Session identifier
|
|
181
|
+
credentials: Any participant can read
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Session data or None if not found
|
|
185
|
+
|
|
186
|
+
Example:
|
|
187
|
+
>>> info = sessions.get_session("session_1", creds)
|
|
188
|
+
>>> if info:
|
|
189
|
+
... print(f"Participants: {info['participants']}")
|
|
190
|
+
"""
|
|
191
|
+
key = f"{self.PREFIX_SESSION}{session_id}"
|
|
192
|
+
raw = self._base._get(key)
|
|
193
|
+
|
|
194
|
+
if raw is None:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
result: dict[str, Any] = json.loads(raw)
|
|
198
|
+
return result
|
|
199
|
+
|
|
200
|
+
def leave_session(
|
|
201
|
+
self,
|
|
202
|
+
session_id: str,
|
|
203
|
+
credentials: AgentCredentials,
|
|
204
|
+
) -> bool:
|
|
205
|
+
"""Leave a session.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
session_id: Session to leave
|
|
209
|
+
credentials: Leaving agent
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
True if left successfully
|
|
213
|
+
"""
|
|
214
|
+
if not session_id or not session_id.strip():
|
|
215
|
+
raise ValueError(f"session_id cannot be empty. Got: {session_id!r}")
|
|
216
|
+
|
|
217
|
+
key = f"{self.PREFIX_SESSION}{session_id}"
|
|
218
|
+
raw = self._base._get(key)
|
|
219
|
+
|
|
220
|
+
if raw is None:
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
payload = json.loads(raw)
|
|
224
|
+
if credentials.agent_id in payload["participants"]:
|
|
225
|
+
payload["participants"].remove(credentials.agent_id)
|
|
226
|
+
logger.info(
|
|
227
|
+
"session_left",
|
|
228
|
+
session_id=session_id,
|
|
229
|
+
agent_id=credentials.agent_id,
|
|
230
|
+
)
|
|
231
|
+
return self._base._set(key, json.dumps(payload), TTLStrategy.SESSION.value)
|
|
232
|
+
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
def list_sessions(self) -> list[dict[str, Any]]:
|
|
236
|
+
"""List all active sessions.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
List of session data dicts
|
|
240
|
+
"""
|
|
241
|
+
pattern = f"{self.PREFIX_SESSION}*"
|
|
242
|
+
keys = self._base._keys(pattern)
|
|
243
|
+
sessions = []
|
|
244
|
+
|
|
245
|
+
for key in keys:
|
|
246
|
+
raw = self._base._get(key)
|
|
247
|
+
if raw:
|
|
248
|
+
sessions.append(json.loads(raw))
|
|
249
|
+
|
|
250
|
+
return sessions
|