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,227 @@
|
|
|
1
|
+
"""Local LRU cache layer for Redis operations.
|
|
2
|
+
|
|
3
|
+
This module provides a two-tier caching strategy:
|
|
4
|
+
1. Local in-memory LRU cache (fast, limited size)
|
|
5
|
+
2. Redis (persistent, shared across processes)
|
|
6
|
+
|
|
7
|
+
The local cache reduces Redis round-trips for frequently accessed data.
|
|
8
|
+
Benchmark: reduces latency from 37ms (Redis) to <0.001ms (local cache).
|
|
9
|
+
|
|
10
|
+
Classes:
|
|
11
|
+
CacheManager: LRU cache with TTL support
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
>>> from attune.memory.short_term.caching import CacheManager
|
|
15
|
+
>>> cache = CacheManager(enabled=True, max_size=1000)
|
|
16
|
+
>>> cache.add("key", "value")
|
|
17
|
+
>>> cache.get("key")
|
|
18
|
+
'value'
|
|
19
|
+
>>> cache.get_stats()
|
|
20
|
+
{'enabled': True, 'size': 1, 'max_size': 1000, 'hits': 1, ...}
|
|
21
|
+
|
|
22
|
+
Copyright 2025 Smart-AI-Memory
|
|
23
|
+
Licensed under Fair Source License 0.9
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import time
|
|
29
|
+
from typing import TYPE_CHECKING
|
|
30
|
+
|
|
31
|
+
import structlog
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
logger = structlog.get_logger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CacheManager:
|
|
40
|
+
"""Local LRU cache manager for two-tier caching.
|
|
41
|
+
|
|
42
|
+
Provides fast local caching with LRU eviction to reduce
|
|
43
|
+
Redis/mock storage round-trips for frequently accessed keys.
|
|
44
|
+
|
|
45
|
+
The cache stores tuples of (value, timestamp, last_access) for
|
|
46
|
+
each key, enabling both TTL expiration and LRU eviction.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
enabled: Whether caching is active
|
|
50
|
+
max_size: Maximum number of entries to cache
|
|
51
|
+
hits: Total cache hits
|
|
52
|
+
misses: Total cache misses
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
>>> cache = CacheManager(enabled=True, max_size=100)
|
|
56
|
+
>>> cache.add("user:123", '{"name": "Alice"}')
|
|
57
|
+
>>> value = cache.get("user:123")
|
|
58
|
+
>>> stats = cache.get_stats()
|
|
59
|
+
>>> print(f"Hit rate: {stats['hit_rate']:.1f}%")
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
enabled: bool = True,
|
|
65
|
+
max_size: int = 1000,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Initialize cache manager.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
enabled: Whether local caching is enabled
|
|
71
|
+
max_size: Maximum number of entries to cache (LRU eviction)
|
|
72
|
+
"""
|
|
73
|
+
self.enabled = enabled
|
|
74
|
+
self.max_size = max_size
|
|
75
|
+
|
|
76
|
+
# Cache storage: key -> (value, timestamp, last_access)
|
|
77
|
+
self._cache: dict[str, tuple[str, float, float]] = {}
|
|
78
|
+
self._hits = 0
|
|
79
|
+
self._misses = 0
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def hits(self) -> int:
|
|
83
|
+
"""Total cache hits."""
|
|
84
|
+
return self._hits
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def misses(self) -> int:
|
|
88
|
+
"""Total cache misses."""
|
|
89
|
+
return self._misses
|
|
90
|
+
|
|
91
|
+
def get(self, key: str) -> str | None:
|
|
92
|
+
"""Get value from local cache.
|
|
93
|
+
|
|
94
|
+
Updates last_access time for LRU tracking on hit.
|
|
95
|
+
Increments hit/miss counters for statistics.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
key: Cache key to retrieve
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Cached value or None if not found or disabled
|
|
102
|
+
"""
|
|
103
|
+
if not self.enabled:
|
|
104
|
+
self._misses += 1
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
if key in self._cache:
|
|
108
|
+
value, timestamp, _last_access = self._cache[key]
|
|
109
|
+
now = time.time()
|
|
110
|
+
# Update last access time for LRU
|
|
111
|
+
self._cache[key] = (value, timestamp, now)
|
|
112
|
+
self._hits += 1
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
self._misses += 1
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
def add(self, key: str, value: str) -> None:
|
|
119
|
+
"""Add entry to local cache with LRU eviction.
|
|
120
|
+
|
|
121
|
+
If cache is at max capacity, evicts the least recently
|
|
122
|
+
accessed entry before adding the new one.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
key: Cache key
|
|
126
|
+
value: Value to cache
|
|
127
|
+
"""
|
|
128
|
+
if not self.enabled:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
now = time.time()
|
|
132
|
+
|
|
133
|
+
# Evict oldest entry if cache is full
|
|
134
|
+
if len(self._cache) >= self.max_size:
|
|
135
|
+
# Find key with oldest last_access time (LRU)
|
|
136
|
+
oldest_key = min(self._cache, key=lambda k: self._cache[k][2])
|
|
137
|
+
del self._cache[oldest_key]
|
|
138
|
+
|
|
139
|
+
# Add new entry: (value, timestamp, last_access)
|
|
140
|
+
self._cache[key] = (value, now, now)
|
|
141
|
+
|
|
142
|
+
def remove(self, key: str) -> bool:
|
|
143
|
+
"""Remove entry from cache.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
key: Cache key to remove
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
True if key was present and removed
|
|
150
|
+
"""
|
|
151
|
+
if key in self._cache:
|
|
152
|
+
del self._cache[key]
|
|
153
|
+
return True
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
def invalidate(self, key: str) -> bool:
|
|
157
|
+
"""Invalidate (remove) entry from cache.
|
|
158
|
+
|
|
159
|
+
Alias for remove() to match common cache API terminology.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
key: Cache key to invalidate
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
True if key was present and invalidated
|
|
166
|
+
"""
|
|
167
|
+
return self.remove(key)
|
|
168
|
+
|
|
169
|
+
def contains(self, key: str) -> bool:
|
|
170
|
+
"""Check if key exists in cache (without updating access time).
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
key: Cache key to check
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
True if key exists in cache
|
|
177
|
+
"""
|
|
178
|
+
return self.enabled and key in self._cache
|
|
179
|
+
|
|
180
|
+
def clear(self) -> int:
|
|
181
|
+
"""Clear all entries from local cache.
|
|
182
|
+
|
|
183
|
+
Resets hit/miss counters to zero.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Number of entries cleared
|
|
187
|
+
"""
|
|
188
|
+
count = len(self._cache)
|
|
189
|
+
self._cache.clear()
|
|
190
|
+
self._hits = 0
|
|
191
|
+
self._misses = 0
|
|
192
|
+
logger.info("local_cache_cleared", entries_cleared=count)
|
|
193
|
+
return count
|
|
194
|
+
|
|
195
|
+
def get_stats(self) -> dict:
|
|
196
|
+
"""Get local cache performance statistics.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Dict with cache stats including:
|
|
200
|
+
- enabled: Whether caching is active
|
|
201
|
+
- size: Current number of cached entries
|
|
202
|
+
- max_size: Maximum cache capacity
|
|
203
|
+
- hits: Total cache hits
|
|
204
|
+
- misses: Total cache misses
|
|
205
|
+
- hit_rate: Percentage of requests served from cache
|
|
206
|
+
- total_requests: Total get requests
|
|
207
|
+
"""
|
|
208
|
+
total = self._hits + self._misses
|
|
209
|
+
hit_rate = (self._hits / total * 100) if total > 0 else 0.0
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
"enabled": self.enabled,
|
|
213
|
+
"size": len(self._cache),
|
|
214
|
+
"max_size": self.max_size,
|
|
215
|
+
"hits": self._hits,
|
|
216
|
+
"misses": self._misses,
|
|
217
|
+
"hit_rate": hit_rate,
|
|
218
|
+
"total_requests": total,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
def __len__(self) -> int:
|
|
222
|
+
"""Return number of cached entries."""
|
|
223
|
+
return len(self._cache)
|
|
224
|
+
|
|
225
|
+
def __contains__(self, key: str) -> bool:
|
|
226
|
+
"""Support 'in' operator."""
|
|
227
|
+
return self.contains(key)
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Conflict negotiation for multi-agent collaboration.
|
|
2
|
+
|
|
3
|
+
This module provides principled negotiation support per "Getting to Yes":
|
|
4
|
+
- Separate positions from interests
|
|
5
|
+
- Define BATNA before negotiating
|
|
6
|
+
- Track resolution outcomes
|
|
7
|
+
|
|
8
|
+
Key Prefix: PREFIX_CONFLICT = "empathy:conflict:"
|
|
9
|
+
|
|
10
|
+
Classes:
|
|
11
|
+
ConflictNegotiation: Conflict context and resolution operations
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
>>> from attune.memory.short_term.conflicts import ConflictNegotiation
|
|
15
|
+
>>> from attune.memory.types import AgentCredentials, AccessTier
|
|
16
|
+
>>> negotiation = ConflictNegotiation(base_ops)
|
|
17
|
+
>>> creds = AgentCredentials("agent_1", AccessTier.CONTRIBUTOR)
|
|
18
|
+
>>> context = negotiation.create_conflict_context(
|
|
19
|
+
... "conflict_1",
|
|
20
|
+
... positions={"agent_1": "Use Redis", "agent_2": "Use SQLite"},
|
|
21
|
+
... interests={"agent_1": ["speed", "scale"], "agent_2": ["simplicity"]},
|
|
22
|
+
... credentials=creds,
|
|
23
|
+
... )
|
|
24
|
+
|
|
25
|
+
Copyright 2025 Smart-AI-Memory
|
|
26
|
+
Licensed under Fair Source License 0.9
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
from typing import TYPE_CHECKING, Any
|
|
33
|
+
|
|
34
|
+
import structlog
|
|
35
|
+
|
|
36
|
+
from attune.memory.types import (
|
|
37
|
+
AgentCredentials,
|
|
38
|
+
ConflictContext,
|
|
39
|
+
TTLStrategy,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from attune.memory.short_term.base import BaseOperations
|
|
44
|
+
|
|
45
|
+
logger = structlog.get_logger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ConflictNegotiation:
|
|
49
|
+
"""Conflict context and resolution operations.
|
|
50
|
+
|
|
51
|
+
Implements principled negotiation per "Getting to Yes" framework:
|
|
52
|
+
- Separate positions from interests
|
|
53
|
+
- Define BATNA (Best Alternative to Negotiated Agreement)
|
|
54
|
+
- Track resolution outcomes for learning
|
|
55
|
+
|
|
56
|
+
The class is designed to be composed with BaseOperations
|
|
57
|
+
for dependency injection.
|
|
58
|
+
|
|
59
|
+
Attributes:
|
|
60
|
+
PREFIX_CONFLICT: Key prefix for conflict context namespace
|
|
61
|
+
|
|
62
|
+
Example:
|
|
63
|
+
>>> negotiation = ConflictNegotiation(base_ops)
|
|
64
|
+
>>> creds = AgentCredentials("agent_1", AccessTier.CONTRIBUTOR)
|
|
65
|
+
>>> context = negotiation.create_conflict_context(...)
|
|
66
|
+
>>> negotiation.resolve_conflict("conflict_1", "Chose Redis", validator_creds)
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
PREFIX_CONFLICT = "empathy:conflict:"
|
|
70
|
+
|
|
71
|
+
def __init__(self, base: BaseOperations) -> None:
|
|
72
|
+
"""Initialize conflict negotiation operations.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
base: BaseOperations instance for storage access
|
|
76
|
+
"""
|
|
77
|
+
self._base = base
|
|
78
|
+
|
|
79
|
+
def create_conflict_context(
|
|
80
|
+
self,
|
|
81
|
+
conflict_id: str,
|
|
82
|
+
positions: dict[str, Any],
|
|
83
|
+
interests: dict[str, list[str]],
|
|
84
|
+
credentials: AgentCredentials,
|
|
85
|
+
batna: str | None = None,
|
|
86
|
+
) -> ConflictContext:
|
|
87
|
+
"""Create context for principled negotiation.
|
|
88
|
+
|
|
89
|
+
Per Getting to Yes framework:
|
|
90
|
+
- Separate positions from interests
|
|
91
|
+
- Define BATNA before negotiating
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
conflict_id: Unique conflict identifier
|
|
95
|
+
positions: agent_id -> their stated position
|
|
96
|
+
interests: agent_id -> underlying interests
|
|
97
|
+
credentials: Must be CONTRIBUTOR or higher
|
|
98
|
+
batna: Best Alternative to Negotiated Agreement
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
ConflictContext for resolution
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
ValueError: If conflict_id is empty
|
|
105
|
+
TypeError: If positions or interests are not dicts
|
|
106
|
+
PermissionError: If credentials lack permission
|
|
107
|
+
|
|
108
|
+
Example:
|
|
109
|
+
>>> context = negotiation.create_conflict_context(
|
|
110
|
+
... "conflict_1",
|
|
111
|
+
... positions={"a1": "Redis", "a2": "SQLite"},
|
|
112
|
+
... interests={"a1": ["speed"], "a2": ["simplicity"]},
|
|
113
|
+
... credentials=creds,
|
|
114
|
+
... batna="Use file-based storage",
|
|
115
|
+
... )
|
|
116
|
+
"""
|
|
117
|
+
# Pattern 1: String ID validation
|
|
118
|
+
if not conflict_id or not conflict_id.strip():
|
|
119
|
+
raise ValueError(f"conflict_id cannot be empty. Got: {conflict_id!r}")
|
|
120
|
+
|
|
121
|
+
# Pattern 5: Type validation
|
|
122
|
+
if not isinstance(positions, dict):
|
|
123
|
+
raise TypeError(f"positions must be dict, got {type(positions).__name__}")
|
|
124
|
+
if not isinstance(interests, dict):
|
|
125
|
+
raise TypeError(f"interests must be dict, got {type(interests).__name__}")
|
|
126
|
+
|
|
127
|
+
if not credentials.can_stage():
|
|
128
|
+
raise PermissionError(
|
|
129
|
+
f"Agent {credentials.agent_id} cannot create conflict context. "
|
|
130
|
+
"Requires CONTRIBUTOR tier or higher.",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
context = ConflictContext(
|
|
134
|
+
conflict_id=conflict_id,
|
|
135
|
+
positions=positions,
|
|
136
|
+
interests=interests,
|
|
137
|
+
batna=batna,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
key = f"{self.PREFIX_CONFLICT}{conflict_id}"
|
|
141
|
+
self._base._set(
|
|
142
|
+
key,
|
|
143
|
+
json.dumps(context.to_dict()),
|
|
144
|
+
TTLStrategy.CONFLICT_CONTEXT.value,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
logger.info(
|
|
148
|
+
"conflict_context_created",
|
|
149
|
+
conflict_id=conflict_id,
|
|
150
|
+
agent_count=len(positions),
|
|
151
|
+
has_batna=batna is not None,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return context
|
|
155
|
+
|
|
156
|
+
def get_conflict_context(
|
|
157
|
+
self,
|
|
158
|
+
conflict_id: str,
|
|
159
|
+
credentials: AgentCredentials,
|
|
160
|
+
) -> ConflictContext | None:
|
|
161
|
+
"""Retrieve conflict context.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
conflict_id: Conflict identifier
|
|
165
|
+
credentials: Any tier can read
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
ConflictContext or None if not found
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
ValueError: If conflict_id is empty
|
|
172
|
+
|
|
173
|
+
Example:
|
|
174
|
+
>>> context = negotiation.get_conflict_context("conflict_1", creds)
|
|
175
|
+
>>> if context:
|
|
176
|
+
... print(f"BATNA: {context.batna}")
|
|
177
|
+
"""
|
|
178
|
+
# Pattern 1: String ID validation
|
|
179
|
+
if not conflict_id or not conflict_id.strip():
|
|
180
|
+
raise ValueError(f"conflict_id cannot be empty. Got: {conflict_id!r}")
|
|
181
|
+
|
|
182
|
+
key = f"{self.PREFIX_CONFLICT}{conflict_id}"
|
|
183
|
+
raw = self._base._get(key)
|
|
184
|
+
|
|
185
|
+
if raw is None:
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
return ConflictContext.from_dict(json.loads(raw))
|
|
189
|
+
|
|
190
|
+
def resolve_conflict(
|
|
191
|
+
self,
|
|
192
|
+
conflict_id: str,
|
|
193
|
+
resolution: str,
|
|
194
|
+
credentials: AgentCredentials,
|
|
195
|
+
) -> bool:
|
|
196
|
+
"""Mark conflict as resolved.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
conflict_id: Conflict to resolve
|
|
200
|
+
resolution: How it was resolved
|
|
201
|
+
credentials: Must be VALIDATOR or higher
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
True if resolved
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
PermissionError: If credentials lack validation access
|
|
208
|
+
|
|
209
|
+
Example:
|
|
210
|
+
>>> negotiation.resolve_conflict(
|
|
211
|
+
... "conflict_1",
|
|
212
|
+
... "Chose Redis for better scaling",
|
|
213
|
+
... validator_creds,
|
|
214
|
+
... )
|
|
215
|
+
True
|
|
216
|
+
"""
|
|
217
|
+
if not credentials.can_validate():
|
|
218
|
+
raise PermissionError(
|
|
219
|
+
f"Agent {credentials.agent_id} cannot resolve conflicts. "
|
|
220
|
+
"Requires VALIDATOR tier or higher.",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
context = self.get_conflict_context(conflict_id, credentials)
|
|
224
|
+
if context is None:
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
context.resolved = True
|
|
228
|
+
context.resolution = resolution
|
|
229
|
+
|
|
230
|
+
key = f"{self.PREFIX_CONFLICT}{conflict_id}"
|
|
231
|
+
# Keep resolved conflicts longer for audit
|
|
232
|
+
self._base._set(key, json.dumps(context.to_dict()), TTLStrategy.CONFLICT_CONTEXT.value)
|
|
233
|
+
|
|
234
|
+
logger.info(
|
|
235
|
+
"conflict_resolved",
|
|
236
|
+
conflict_id=conflict_id,
|
|
237
|
+
agent_id=credentials.agent_id,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
def list_active_conflicts(
|
|
243
|
+
self,
|
|
244
|
+
credentials: AgentCredentials,
|
|
245
|
+
) -> list[ConflictContext]:
|
|
246
|
+
"""List all active (unresolved) conflicts.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
credentials: Any tier can read
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
List of unresolved conflict contexts
|
|
253
|
+
"""
|
|
254
|
+
pattern = f"{self.PREFIX_CONFLICT}*"
|
|
255
|
+
keys = self._base._keys(pattern)
|
|
256
|
+
conflicts = []
|
|
257
|
+
|
|
258
|
+
for key in keys:
|
|
259
|
+
raw = self._base._get(key)
|
|
260
|
+
if raw:
|
|
261
|
+
context = ConflictContext.from_dict(json.loads(raw))
|
|
262
|
+
if not context.resolved:
|
|
263
|
+
conflicts.append(context)
|
|
264
|
+
|
|
265
|
+
return conflicts
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Cross-session coordination for distributed agents.
|
|
2
|
+
|
|
3
|
+
This module enables coordination across multiple sessions:
|
|
4
|
+
- Enable cross-session communication
|
|
5
|
+
- Check cross-session availability
|
|
6
|
+
- Coordinate distributed agent activities
|
|
7
|
+
|
|
8
|
+
Use Cases:
|
|
9
|
+
- Multi-process agent coordination
|
|
10
|
+
- Distributed workflow execution
|
|
11
|
+
- Shared state across sessions
|
|
12
|
+
|
|
13
|
+
Classes:
|
|
14
|
+
CrossSessionManager: Cross-session coordination operations
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
>>> from attune.memory.short_term.cross_session import CrossSessionManager
|
|
18
|
+
>>> from attune.memory.types import AccessTier
|
|
19
|
+
>>> cross_session = CrossSessionManager(base_ops)
|
|
20
|
+
>>> if cross_session.available():
|
|
21
|
+
... coordinator = cross_session.enable(AccessTier.CONTRIBUTOR)
|
|
22
|
+
... print(f"Session ID: {coordinator.agent_id}")
|
|
23
|
+
|
|
24
|
+
Copyright 2025 Smart-AI-Memory
|
|
25
|
+
Licensed under Fair Source License 0.9
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import TYPE_CHECKING, Any
|
|
31
|
+
|
|
32
|
+
import structlog
|
|
33
|
+
|
|
34
|
+
from attune.memory.types import AccessTier
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from attune.memory.short_term.base import BaseOperations
|
|
38
|
+
|
|
39
|
+
logger = structlog.get_logger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CrossSessionManager:
|
|
43
|
+
"""Cross-session coordination operations.
|
|
44
|
+
|
|
45
|
+
Provides coordination capabilities for agents running in
|
|
46
|
+
different Claude Code sessions using Redis as the coordination
|
|
47
|
+
backbone.
|
|
48
|
+
|
|
49
|
+
The class is designed to be composed with BaseOperations
|
|
50
|
+
for dependency injection.
|
|
51
|
+
|
|
52
|
+
Example:
|
|
53
|
+
>>> cross_session = CrossSessionManager(base_ops)
|
|
54
|
+
>>> if cross_session.available():
|
|
55
|
+
... coordinator = cross_session.enable(AccessTier.CONTRIBUTOR)
|
|
56
|
+
... sessions = coordinator.get_active_sessions()
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, base: BaseOperations) -> None:
|
|
60
|
+
"""Initialize cross-session manager.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
base: BaseOperations instance for Redis client access
|
|
64
|
+
"""
|
|
65
|
+
self._base = base
|
|
66
|
+
|
|
67
|
+
def enable(
|
|
68
|
+
self,
|
|
69
|
+
access_tier: AccessTier = AccessTier.CONTRIBUTOR,
|
|
70
|
+
auto_announce: bool = True,
|
|
71
|
+
) -> Any:
|
|
72
|
+
"""Enable cross-session communication for this memory instance.
|
|
73
|
+
|
|
74
|
+
This allows agents in different Claude Code sessions to communicate
|
|
75
|
+
and coordinate via Redis.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
access_tier: Access tier for this session
|
|
79
|
+
auto_announce: Whether to announce presence automatically
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
CrossSessionCoordinator instance
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
ValueError: If in mock mode (Redis required for cross-session)
|
|
86
|
+
|
|
87
|
+
Example:
|
|
88
|
+
>>> coordinator = cross_session.enable(AccessTier.CONTRIBUTOR)
|
|
89
|
+
>>> print(f"Session ID: {coordinator.agent_id}")
|
|
90
|
+
>>> sessions = coordinator.get_active_sessions()
|
|
91
|
+
"""
|
|
92
|
+
if self._base.use_mock:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
"Cross-session communication requires Redis. "
|
|
95
|
+
"Set REDIS_HOST/REDIS_PORT or disable mock mode."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Import lazily to avoid circular imports
|
|
99
|
+
from attune.memory.cross_session import CrossSessionCoordinator, SessionType
|
|
100
|
+
|
|
101
|
+
# The coordinator expects a memory instance with full API
|
|
102
|
+
# For now, pass the base which should be extended by facade
|
|
103
|
+
coordinator = CrossSessionCoordinator(
|
|
104
|
+
memory=self._base, # type: ignore[arg-type]
|
|
105
|
+
session_type=SessionType.CLAUDE,
|
|
106
|
+
access_tier=access_tier,
|
|
107
|
+
auto_announce=auto_announce,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return coordinator
|
|
111
|
+
|
|
112
|
+
def available(self) -> bool:
|
|
113
|
+
"""Check if cross-session communication is available.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
True if Redis is connected (not mock mode)
|
|
117
|
+
|
|
118
|
+
Example:
|
|
119
|
+
>>> if cross_session.available():
|
|
120
|
+
... coordinator = cross_session.enable()
|
|
121
|
+
"""
|
|
122
|
+
return not self._base.use_mock and self._base._client is not None
|