alma-memory 0.5.1__py3-none-any.whl → 0.7.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.
- alma/__init__.py +296 -226
- alma/compression/__init__.py +33 -0
- alma/compression/pipeline.py +980 -0
- alma/confidence/__init__.py +47 -47
- alma/confidence/engine.py +540 -540
- alma/confidence/types.py +351 -351
- alma/config/loader.py +157 -157
- alma/consolidation/__init__.py +23 -23
- alma/consolidation/engine.py +678 -678
- alma/consolidation/prompts.py +84 -84
- alma/core.py +1189 -430
- alma/domains/__init__.py +30 -30
- alma/domains/factory.py +359 -359
- alma/domains/schemas.py +448 -448
- alma/domains/types.py +272 -272
- alma/events/__init__.py +75 -75
- alma/events/emitter.py +285 -284
- alma/events/storage_mixin.py +246 -246
- alma/events/types.py +126 -126
- alma/events/webhook.py +425 -425
- alma/exceptions.py +49 -49
- alma/extraction/__init__.py +31 -31
- alma/extraction/auto_learner.py +265 -265
- alma/extraction/extractor.py +420 -420
- alma/graph/__init__.py +106 -106
- alma/graph/backends/__init__.py +32 -32
- alma/graph/backends/kuzu.py +624 -624
- alma/graph/backends/memgraph.py +432 -432
- alma/graph/backends/memory.py +236 -236
- alma/graph/backends/neo4j.py +417 -417
- alma/graph/base.py +159 -159
- alma/graph/extraction.py +198 -198
- alma/graph/store.py +860 -860
- alma/harness/__init__.py +35 -35
- alma/harness/base.py +386 -386
- alma/harness/domains.py +705 -705
- alma/initializer/__init__.py +37 -37
- alma/initializer/initializer.py +418 -418
- alma/initializer/types.py +250 -250
- alma/integration/__init__.py +62 -62
- alma/integration/claude_agents.py +444 -444
- alma/integration/helena.py +423 -423
- alma/integration/victor.py +471 -471
- alma/learning/__init__.py +101 -86
- alma/learning/decay.py +878 -0
- alma/learning/forgetting.py +1446 -1446
- alma/learning/heuristic_extractor.py +390 -390
- alma/learning/protocols.py +374 -374
- alma/learning/validation.py +346 -346
- alma/mcp/__init__.py +123 -45
- alma/mcp/__main__.py +156 -156
- alma/mcp/resources.py +122 -122
- alma/mcp/server.py +955 -591
- alma/mcp/tools.py +3254 -509
- alma/observability/__init__.py +91 -84
- alma/observability/config.py +302 -302
- alma/observability/guidelines.py +170 -0
- alma/observability/logging.py +424 -424
- alma/observability/metrics.py +583 -583
- alma/observability/tracing.py +440 -440
- alma/progress/__init__.py +21 -21
- alma/progress/tracker.py +607 -607
- alma/progress/types.py +250 -250
- alma/retrieval/__init__.py +134 -53
- alma/retrieval/budget.py +525 -0
- alma/retrieval/cache.py +1304 -1061
- alma/retrieval/embeddings.py +202 -202
- alma/retrieval/engine.py +850 -427
- alma/retrieval/modes.py +365 -0
- alma/retrieval/progressive.py +560 -0
- alma/retrieval/scoring.py +344 -344
- alma/retrieval/trust_scoring.py +637 -0
- alma/retrieval/verification.py +797 -0
- alma/session/__init__.py +19 -19
- alma/session/manager.py +442 -399
- alma/session/types.py +288 -288
- alma/storage/__init__.py +101 -90
- alma/storage/archive.py +233 -0
- alma/storage/azure_cosmos.py +1259 -1259
- alma/storage/base.py +1083 -583
- alma/storage/chroma.py +1443 -1443
- alma/storage/constants.py +103 -103
- alma/storage/file_based.py +614 -614
- alma/storage/migrations/__init__.py +21 -21
- alma/storage/migrations/base.py +321 -321
- alma/storage/migrations/runner.py +323 -323
- alma/storage/migrations/version_stores.py +337 -337
- alma/storage/migrations/versions/__init__.py +11 -11
- alma/storage/migrations/versions/v1_0_0.py +373 -373
- alma/storage/migrations/versions/v1_1_0_workflow_context.py +551 -0
- alma/storage/pinecone.py +1080 -1080
- alma/storage/postgresql.py +1948 -1559
- alma/storage/qdrant.py +1306 -1306
- alma/storage/sqlite_local.py +3041 -1457
- alma/testing/__init__.py +46 -46
- alma/testing/factories.py +301 -301
- alma/testing/mocks.py +389 -389
- alma/types.py +292 -264
- alma/utils/__init__.py +19 -0
- alma/utils/tokenizer.py +521 -0
- alma/workflow/__init__.py +83 -0
- alma/workflow/artifacts.py +170 -0
- alma/workflow/checkpoint.py +311 -0
- alma/workflow/context.py +228 -0
- alma/workflow/outcomes.py +189 -0
- alma/workflow/reducers.py +393 -0
- {alma_memory-0.5.1.dist-info → alma_memory-0.7.0.dist-info}/METADATA +210 -72
- alma_memory-0.7.0.dist-info/RECORD +112 -0
- alma_memory-0.5.1.dist-info/RECORD +0 -93
- {alma_memory-0.5.1.dist-info → alma_memory-0.7.0.dist-info}/WHEEL +0 -0
- {alma_memory-0.5.1.dist-info → alma_memory-0.7.0.dist-info}/top_level.txt +0 -0
alma/learning/protocols.py
CHANGED
|
@@ -1,374 +1,374 @@
|
|
|
1
|
-
"""
|
|
2
|
-
ALMA Learning Protocols.
|
|
3
|
-
|
|
4
|
-
Defines how agents learn from outcomes while respecting scope constraints.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import logging
|
|
8
|
-
import uuid
|
|
9
|
-
from datetime import datetime, timedelta, timezone
|
|
10
|
-
from typing import TYPE_CHECKING, Dict, Optional
|
|
11
|
-
|
|
12
|
-
from alma.storage.base import StorageBackend
|
|
13
|
-
from alma.types import (
|
|
14
|
-
AntiPattern,
|
|
15
|
-
DomainKnowledge,
|
|
16
|
-
Heuristic,
|
|
17
|
-
MemoryScope,
|
|
18
|
-
Outcome,
|
|
19
|
-
UserPreference,
|
|
20
|
-
)
|
|
21
|
-
|
|
22
|
-
if TYPE_CHECKING:
|
|
23
|
-
from alma.retrieval.embeddings import EmbeddingProvider
|
|
24
|
-
|
|
25
|
-
logger = logging.getLogger(__name__)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class LearningProtocol:
|
|
29
|
-
"""
|
|
30
|
-
Manages how agents learn from task outcomes.
|
|
31
|
-
|
|
32
|
-
Key principles:
|
|
33
|
-
- Validate scope before any learning
|
|
34
|
-
- Require minimum occurrences before creating heuristics
|
|
35
|
-
- Support forgetting to prevent memory bloat
|
|
36
|
-
"""
|
|
37
|
-
|
|
38
|
-
def __init__(
|
|
39
|
-
self,
|
|
40
|
-
storage: StorageBackend,
|
|
41
|
-
scopes: Dict[str, MemoryScope],
|
|
42
|
-
embedder: Optional["EmbeddingProvider"] = None,
|
|
43
|
-
similarity_threshold: float = 0.75,
|
|
44
|
-
):
|
|
45
|
-
"""
|
|
46
|
-
Initialize learning protocol.
|
|
47
|
-
|
|
48
|
-
Args:
|
|
49
|
-
storage: Storage backend for persistence
|
|
50
|
-
scopes: Dict of agent_name -> MemoryScope
|
|
51
|
-
embedder: Optional embedding provider for semantic similarity
|
|
52
|
-
similarity_threshold: Cosine similarity threshold for strategy matching (default 0.75)
|
|
53
|
-
"""
|
|
54
|
-
self.storage = storage
|
|
55
|
-
self.scopes = scopes
|
|
56
|
-
self.embedder = embedder
|
|
57
|
-
self.similarity_threshold = similarity_threshold
|
|
58
|
-
|
|
59
|
-
def learn(
|
|
60
|
-
self,
|
|
61
|
-
agent: str,
|
|
62
|
-
project_id: str,
|
|
63
|
-
task: str,
|
|
64
|
-
outcome: bool,
|
|
65
|
-
strategy_used: str,
|
|
66
|
-
task_type: Optional[str] = None,
|
|
67
|
-
duration_ms: Optional[int] = None,
|
|
68
|
-
error_message: Optional[str] = None,
|
|
69
|
-
feedback: Optional[str] = None,
|
|
70
|
-
) -> Outcome:
|
|
71
|
-
"""
|
|
72
|
-
Learn from a task outcome.
|
|
73
|
-
|
|
74
|
-
Creates an Outcome record and potentially updates/creates heuristics.
|
|
75
|
-
|
|
76
|
-
Args:
|
|
77
|
-
agent: Agent that executed the task
|
|
78
|
-
project_id: Project context
|
|
79
|
-
task: Task description
|
|
80
|
-
outcome: True if successful, False if failed
|
|
81
|
-
strategy_used: The approach taken
|
|
82
|
-
task_type: Category for grouping
|
|
83
|
-
duration_ms: Execution time
|
|
84
|
-
error_message: Error details if failed
|
|
85
|
-
feedback: User feedback
|
|
86
|
-
|
|
87
|
-
Returns:
|
|
88
|
-
The created Outcome record
|
|
89
|
-
"""
|
|
90
|
-
# Validate agent has a scope (warn but don't block)
|
|
91
|
-
scope = self.scopes.get(agent)
|
|
92
|
-
if scope is None:
|
|
93
|
-
logger.warning(f"Agent '{agent}' has no defined scope")
|
|
94
|
-
|
|
95
|
-
# Create outcome record
|
|
96
|
-
outcome_record = Outcome(
|
|
97
|
-
id=f"out_{uuid.uuid4().hex[:12]}",
|
|
98
|
-
agent=agent,
|
|
99
|
-
project_id=project_id,
|
|
100
|
-
task_type=task_type or self._infer_task_type(task),
|
|
101
|
-
task_description=task,
|
|
102
|
-
success=outcome,
|
|
103
|
-
strategy_used=strategy_used,
|
|
104
|
-
duration_ms=duration_ms,
|
|
105
|
-
error_message=error_message,
|
|
106
|
-
user_feedback=feedback,
|
|
107
|
-
timestamp=datetime.now(timezone.utc),
|
|
108
|
-
)
|
|
109
|
-
|
|
110
|
-
# Save outcome
|
|
111
|
-
self.storage.save_outcome(outcome_record)
|
|
112
|
-
logger.info(
|
|
113
|
-
f"Recorded outcome for {agent}: {'success' if outcome else 'failure'}"
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
# Check if we should create/update a heuristic
|
|
117
|
-
self._maybe_create_heuristic(
|
|
118
|
-
agent=agent,
|
|
119
|
-
project_id=project_id,
|
|
120
|
-
task_type=outcome_record.task_type,
|
|
121
|
-
strategy=strategy_used,
|
|
122
|
-
success=outcome,
|
|
123
|
-
scope=scope,
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
# If failure with clear pattern, consider anti-pattern
|
|
127
|
-
if not outcome and error_message:
|
|
128
|
-
self._maybe_create_anti_pattern(
|
|
129
|
-
agent=agent,
|
|
130
|
-
project_id=project_id,
|
|
131
|
-
task=task,
|
|
132
|
-
strategy=strategy_used,
|
|
133
|
-
error=error_message,
|
|
134
|
-
)
|
|
135
|
-
|
|
136
|
-
return outcome_record
|
|
137
|
-
|
|
138
|
-
def _maybe_create_heuristic(
|
|
139
|
-
self,
|
|
140
|
-
agent: str,
|
|
141
|
-
project_id: str,
|
|
142
|
-
task_type: str,
|
|
143
|
-
strategy: str,
|
|
144
|
-
success: bool,
|
|
145
|
-
scope: Optional[MemoryScope],
|
|
146
|
-
):
|
|
147
|
-
"""
|
|
148
|
-
Create or update a heuristic if we have enough occurrences.
|
|
149
|
-
|
|
150
|
-
Only creates heuristic after min_occurrences similar outcomes.
|
|
151
|
-
"""
|
|
152
|
-
min_occurrences = 3
|
|
153
|
-
if scope:
|
|
154
|
-
min_occurrences = scope.min_occurrences_for_heuristic
|
|
155
|
-
|
|
156
|
-
# Get similar outcomes to check occurrence count
|
|
157
|
-
similar_outcomes = self.storage.get_outcomes(
|
|
158
|
-
project_id=project_id,
|
|
159
|
-
agent=agent,
|
|
160
|
-
task_type=task_type,
|
|
161
|
-
top_k=min_occurrences + 1,
|
|
162
|
-
success_only=False,
|
|
163
|
-
)
|
|
164
|
-
|
|
165
|
-
# Filter to same strategy
|
|
166
|
-
same_strategy = [
|
|
167
|
-
o
|
|
168
|
-
for o in similar_outcomes
|
|
169
|
-
if self._strategies_similar(o.strategy_used, strategy)
|
|
170
|
-
]
|
|
171
|
-
|
|
172
|
-
if len(same_strategy) >= min_occurrences:
|
|
173
|
-
success_count = sum(1 for o in same_strategy if o.success)
|
|
174
|
-
confidence = success_count / len(same_strategy)
|
|
175
|
-
|
|
176
|
-
# Only create heuristic if confidence is meaningful
|
|
177
|
-
if confidence > 0.5:
|
|
178
|
-
heuristic = Heuristic(
|
|
179
|
-
id=f"heur_{uuid.uuid4().hex[:12]}",
|
|
180
|
-
agent=agent,
|
|
181
|
-
project_id=project_id,
|
|
182
|
-
condition=f"task type: {task_type}",
|
|
183
|
-
strategy=strategy,
|
|
184
|
-
confidence=confidence,
|
|
185
|
-
occurrence_count=len(same_strategy),
|
|
186
|
-
success_count=success_count,
|
|
187
|
-
last_validated=datetime.now(timezone.utc),
|
|
188
|
-
created_at=datetime.now(timezone.utc),
|
|
189
|
-
)
|
|
190
|
-
self.storage.save_heuristic(heuristic)
|
|
191
|
-
logger.info(
|
|
192
|
-
f"Created heuristic for {agent}: {strategy[:50]}... "
|
|
193
|
-
f"(confidence: {confidence:.0%})"
|
|
194
|
-
)
|
|
195
|
-
|
|
196
|
-
def _maybe_create_anti_pattern(
|
|
197
|
-
self,
|
|
198
|
-
agent: str,
|
|
199
|
-
project_id: str,
|
|
200
|
-
task: str,
|
|
201
|
-
strategy: str,
|
|
202
|
-
error: str,
|
|
203
|
-
):
|
|
204
|
-
"""Create anti-pattern if we see repeated failures with same pattern."""
|
|
205
|
-
# Check for similar failures
|
|
206
|
-
similar_failures = self.storage.get_outcomes(
|
|
207
|
-
project_id=project_id,
|
|
208
|
-
agent=agent,
|
|
209
|
-
success_only=False,
|
|
210
|
-
top_k=10,
|
|
211
|
-
)
|
|
212
|
-
|
|
213
|
-
# Filter to failures with similar error
|
|
214
|
-
similar = [
|
|
215
|
-
o
|
|
216
|
-
for o in similar_failures
|
|
217
|
-
if not o.success
|
|
218
|
-
and o.error_message
|
|
219
|
-
and self._errors_similar(o.error_message, error)
|
|
220
|
-
]
|
|
221
|
-
|
|
222
|
-
if len(similar) >= 2: # At least 2 similar failures
|
|
223
|
-
anti_pattern = AntiPattern(
|
|
224
|
-
id=f"anti_{uuid.uuid4().hex[:12]}",
|
|
225
|
-
agent=agent,
|
|
226
|
-
project_id=project_id,
|
|
227
|
-
pattern=strategy,
|
|
228
|
-
why_bad=error,
|
|
229
|
-
better_alternative="[To be determined from successful outcomes]",
|
|
230
|
-
occurrence_count=len(similar),
|
|
231
|
-
last_seen=datetime.now(timezone.utc),
|
|
232
|
-
)
|
|
233
|
-
self.storage.save_anti_pattern(anti_pattern)
|
|
234
|
-
logger.info(f"Created anti-pattern for {agent}: {strategy[:50]}...")
|
|
235
|
-
|
|
236
|
-
def add_preference(
|
|
237
|
-
self,
|
|
238
|
-
user_id: str,
|
|
239
|
-
category: str,
|
|
240
|
-
preference: str,
|
|
241
|
-
source: str,
|
|
242
|
-
) -> UserPreference:
|
|
243
|
-
"""Add a user preference."""
|
|
244
|
-
pref = UserPreference(
|
|
245
|
-
id=f"pref_{uuid.uuid4().hex[:12]}",
|
|
246
|
-
user_id=user_id,
|
|
247
|
-
category=category,
|
|
248
|
-
preference=preference,
|
|
249
|
-
source=source,
|
|
250
|
-
confidence=1.0 if source == "explicit_instruction" else 0.7,
|
|
251
|
-
timestamp=datetime.now(timezone.utc),
|
|
252
|
-
)
|
|
253
|
-
self.storage.save_user_preference(pref)
|
|
254
|
-
return pref
|
|
255
|
-
|
|
256
|
-
def add_domain_knowledge(
|
|
257
|
-
self,
|
|
258
|
-
agent: str,
|
|
259
|
-
project_id: str,
|
|
260
|
-
domain: str,
|
|
261
|
-
fact: str,
|
|
262
|
-
source: str,
|
|
263
|
-
) -> DomainKnowledge:
|
|
264
|
-
"""Add domain knowledge."""
|
|
265
|
-
knowledge = DomainKnowledge(
|
|
266
|
-
id=f"dk_{uuid.uuid4().hex[:12]}",
|
|
267
|
-
agent=agent,
|
|
268
|
-
project_id=project_id,
|
|
269
|
-
domain=domain,
|
|
270
|
-
fact=fact,
|
|
271
|
-
source=source,
|
|
272
|
-
confidence=1.0 if source == "user_stated" else 0.8,
|
|
273
|
-
last_verified=datetime.now(timezone.utc),
|
|
274
|
-
)
|
|
275
|
-
self.storage.save_domain_knowledge(knowledge)
|
|
276
|
-
return knowledge
|
|
277
|
-
|
|
278
|
-
def forget(
|
|
279
|
-
self,
|
|
280
|
-
project_id: str,
|
|
281
|
-
agent: Optional[str] = None,
|
|
282
|
-
older_than_days: int = 90,
|
|
283
|
-
below_confidence: float = 0.3,
|
|
284
|
-
) -> int:
|
|
285
|
-
"""
|
|
286
|
-
Prune stale and low-confidence memories.
|
|
287
|
-
|
|
288
|
-
Returns:
|
|
289
|
-
Total number of items pruned
|
|
290
|
-
"""
|
|
291
|
-
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
|
|
292
|
-
|
|
293
|
-
# Delete old outcomes
|
|
294
|
-
outcomes_deleted = self.storage.delete_outcomes_older_than(
|
|
295
|
-
project_id=project_id,
|
|
296
|
-
older_than=cutoff,
|
|
297
|
-
agent=agent,
|
|
298
|
-
)
|
|
299
|
-
|
|
300
|
-
# Delete low-confidence heuristics
|
|
301
|
-
heuristics_deleted = self.storage.delete_low_confidence_heuristics(
|
|
302
|
-
project_id=project_id,
|
|
303
|
-
below_confidence=below_confidence,
|
|
304
|
-
agent=agent,
|
|
305
|
-
)
|
|
306
|
-
|
|
307
|
-
total = outcomes_deleted + heuristics_deleted
|
|
308
|
-
logger.info(
|
|
309
|
-
f"Forgot {total} items: {outcomes_deleted} outcomes, "
|
|
310
|
-
f"{heuristics_deleted} heuristics"
|
|
311
|
-
)
|
|
312
|
-
return total
|
|
313
|
-
|
|
314
|
-
def _infer_task_type(self, task: str) -> str:
|
|
315
|
-
"""Infer task type from description."""
|
|
316
|
-
task_lower = task.lower()
|
|
317
|
-
if "test" in task_lower or "validate" in task_lower:
|
|
318
|
-
return "testing"
|
|
319
|
-
elif "api" in task_lower or "endpoint" in task_lower:
|
|
320
|
-
return "api_testing"
|
|
321
|
-
elif "form" in task_lower or "input" in task_lower:
|
|
322
|
-
return "form_testing"
|
|
323
|
-
elif "database" in task_lower or "query" in task_lower:
|
|
324
|
-
return "database_validation"
|
|
325
|
-
return "general"
|
|
326
|
-
|
|
327
|
-
def _strategies_similar(self, s1: str, s2: str) -> bool:
|
|
328
|
-
"""
|
|
329
|
-
Check if two strategies are similar enough to count together.
|
|
330
|
-
|
|
331
|
-
Uses embedding-based cosine similarity when an embedder is available,
|
|
332
|
-
otherwise falls back to simple word overlap.
|
|
333
|
-
"""
|
|
334
|
-
if self.embedder is not None:
|
|
335
|
-
return self._strategies_similar_embedding(s1, s2)
|
|
336
|
-
return self._strategies_similar_word_overlap(s1, s2)
|
|
337
|
-
|
|
338
|
-
def _strategies_similar_embedding(self, s1: str, s2: str) -> bool:
|
|
339
|
-
"""Check strategy similarity using embedding cosine similarity."""
|
|
340
|
-
try:
|
|
341
|
-
emb1 = self.embedder.encode(s1)
|
|
342
|
-
emb2 = self.embedder.encode(s2)
|
|
343
|
-
similarity = self._cosine_similarity(emb1, emb2)
|
|
344
|
-
return similarity >= self.similarity_threshold
|
|
345
|
-
except Exception as e:
|
|
346
|
-
logger.warning(
|
|
347
|
-
f"Embedding similarity failed, falling back to word overlap: {e}"
|
|
348
|
-
)
|
|
349
|
-
return self._strategies_similar_word_overlap(s1, s2)
|
|
350
|
-
|
|
351
|
-
def _strategies_similar_word_overlap(self, s1: str, s2: str) -> bool:
|
|
352
|
-
"""Check strategy similarity using simple word overlap."""
|
|
353
|
-
words1 = set(s1.lower().split())
|
|
354
|
-
words2 = set(s2.lower().split())
|
|
355
|
-
overlap = len(words1 & words2)
|
|
356
|
-
return overlap >= min(3, len(words1) // 2)
|
|
357
|
-
|
|
358
|
-
def _cosine_similarity(self, v1: list, v2: list) -> float:
|
|
359
|
-
"""Compute cosine similarity between two vectors."""
|
|
360
|
-
import math
|
|
361
|
-
|
|
362
|
-
dot_product = sum(a * b for a, b in zip(v1, v2, strict=False))
|
|
363
|
-
norm1 = math.sqrt(sum(a * a for a in v1))
|
|
364
|
-
norm2 = math.sqrt(sum(b * b for b in v2))
|
|
365
|
-
if norm1 == 0 or norm2 == 0:
|
|
366
|
-
return 0.0
|
|
367
|
-
return dot_product / (norm1 * norm2)
|
|
368
|
-
|
|
369
|
-
def _errors_similar(self, e1: str, e2: str) -> bool:
|
|
370
|
-
"""Check if two errors are similar."""
|
|
371
|
-
# Simple substring check
|
|
372
|
-
e1_lower = e1.lower()
|
|
373
|
-
e2_lower = e2.lower()
|
|
374
|
-
return e1_lower in e2_lower or e2_lower in e1_lower
|
|
1
|
+
"""
|
|
2
|
+
ALMA Learning Protocols.
|
|
3
|
+
|
|
4
|
+
Defines how agents learn from outcomes while respecting scope constraints.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
from typing import TYPE_CHECKING, Dict, Optional
|
|
11
|
+
|
|
12
|
+
from alma.storage.base import StorageBackend
|
|
13
|
+
from alma.types import (
|
|
14
|
+
AntiPattern,
|
|
15
|
+
DomainKnowledge,
|
|
16
|
+
Heuristic,
|
|
17
|
+
MemoryScope,
|
|
18
|
+
Outcome,
|
|
19
|
+
UserPreference,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from alma.retrieval.embeddings import EmbeddingProvider
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LearningProtocol:
|
|
29
|
+
"""
|
|
30
|
+
Manages how agents learn from task outcomes.
|
|
31
|
+
|
|
32
|
+
Key principles:
|
|
33
|
+
- Validate scope before any learning
|
|
34
|
+
- Require minimum occurrences before creating heuristics
|
|
35
|
+
- Support forgetting to prevent memory bloat
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
storage: StorageBackend,
|
|
41
|
+
scopes: Dict[str, MemoryScope],
|
|
42
|
+
embedder: Optional["EmbeddingProvider"] = None,
|
|
43
|
+
similarity_threshold: float = 0.75,
|
|
44
|
+
):
|
|
45
|
+
"""
|
|
46
|
+
Initialize learning protocol.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
storage: Storage backend for persistence
|
|
50
|
+
scopes: Dict of agent_name -> MemoryScope
|
|
51
|
+
embedder: Optional embedding provider for semantic similarity
|
|
52
|
+
similarity_threshold: Cosine similarity threshold for strategy matching (default 0.75)
|
|
53
|
+
"""
|
|
54
|
+
self.storage = storage
|
|
55
|
+
self.scopes = scopes
|
|
56
|
+
self.embedder = embedder
|
|
57
|
+
self.similarity_threshold = similarity_threshold
|
|
58
|
+
|
|
59
|
+
def learn(
|
|
60
|
+
self,
|
|
61
|
+
agent: str,
|
|
62
|
+
project_id: str,
|
|
63
|
+
task: str,
|
|
64
|
+
outcome: bool,
|
|
65
|
+
strategy_used: str,
|
|
66
|
+
task_type: Optional[str] = None,
|
|
67
|
+
duration_ms: Optional[int] = None,
|
|
68
|
+
error_message: Optional[str] = None,
|
|
69
|
+
feedback: Optional[str] = None,
|
|
70
|
+
) -> Outcome:
|
|
71
|
+
"""
|
|
72
|
+
Learn from a task outcome.
|
|
73
|
+
|
|
74
|
+
Creates an Outcome record and potentially updates/creates heuristics.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
agent: Agent that executed the task
|
|
78
|
+
project_id: Project context
|
|
79
|
+
task: Task description
|
|
80
|
+
outcome: True if successful, False if failed
|
|
81
|
+
strategy_used: The approach taken
|
|
82
|
+
task_type: Category for grouping
|
|
83
|
+
duration_ms: Execution time
|
|
84
|
+
error_message: Error details if failed
|
|
85
|
+
feedback: User feedback
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
The created Outcome record
|
|
89
|
+
"""
|
|
90
|
+
# Validate agent has a scope (warn but don't block)
|
|
91
|
+
scope = self.scopes.get(agent)
|
|
92
|
+
if scope is None:
|
|
93
|
+
logger.warning(f"Agent '{agent}' has no defined scope")
|
|
94
|
+
|
|
95
|
+
# Create outcome record
|
|
96
|
+
outcome_record = Outcome(
|
|
97
|
+
id=f"out_{uuid.uuid4().hex[:12]}",
|
|
98
|
+
agent=agent,
|
|
99
|
+
project_id=project_id,
|
|
100
|
+
task_type=task_type or self._infer_task_type(task),
|
|
101
|
+
task_description=task,
|
|
102
|
+
success=outcome,
|
|
103
|
+
strategy_used=strategy_used,
|
|
104
|
+
duration_ms=duration_ms,
|
|
105
|
+
error_message=error_message,
|
|
106
|
+
user_feedback=feedback,
|
|
107
|
+
timestamp=datetime.now(timezone.utc),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Save outcome
|
|
111
|
+
self.storage.save_outcome(outcome_record)
|
|
112
|
+
logger.info(
|
|
113
|
+
f"Recorded outcome for {agent}: {'success' if outcome else 'failure'}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Check if we should create/update a heuristic
|
|
117
|
+
self._maybe_create_heuristic(
|
|
118
|
+
agent=agent,
|
|
119
|
+
project_id=project_id,
|
|
120
|
+
task_type=outcome_record.task_type,
|
|
121
|
+
strategy=strategy_used,
|
|
122
|
+
success=outcome,
|
|
123
|
+
scope=scope,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# If failure with clear pattern, consider anti-pattern
|
|
127
|
+
if not outcome and error_message:
|
|
128
|
+
self._maybe_create_anti_pattern(
|
|
129
|
+
agent=agent,
|
|
130
|
+
project_id=project_id,
|
|
131
|
+
task=task,
|
|
132
|
+
strategy=strategy_used,
|
|
133
|
+
error=error_message,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return outcome_record
|
|
137
|
+
|
|
138
|
+
def _maybe_create_heuristic(
|
|
139
|
+
self,
|
|
140
|
+
agent: str,
|
|
141
|
+
project_id: str,
|
|
142
|
+
task_type: str,
|
|
143
|
+
strategy: str,
|
|
144
|
+
success: bool,
|
|
145
|
+
scope: Optional[MemoryScope],
|
|
146
|
+
):
|
|
147
|
+
"""
|
|
148
|
+
Create or update a heuristic if we have enough occurrences.
|
|
149
|
+
|
|
150
|
+
Only creates heuristic after min_occurrences similar outcomes.
|
|
151
|
+
"""
|
|
152
|
+
min_occurrences = 3
|
|
153
|
+
if scope:
|
|
154
|
+
min_occurrences = scope.min_occurrences_for_heuristic
|
|
155
|
+
|
|
156
|
+
# Get similar outcomes to check occurrence count
|
|
157
|
+
similar_outcomes = self.storage.get_outcomes(
|
|
158
|
+
project_id=project_id,
|
|
159
|
+
agent=agent,
|
|
160
|
+
task_type=task_type,
|
|
161
|
+
top_k=min_occurrences + 1,
|
|
162
|
+
success_only=False,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Filter to same strategy
|
|
166
|
+
same_strategy = [
|
|
167
|
+
o
|
|
168
|
+
for o in similar_outcomes
|
|
169
|
+
if self._strategies_similar(o.strategy_used, strategy)
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
if len(same_strategy) >= min_occurrences:
|
|
173
|
+
success_count = sum(1 for o in same_strategy if o.success)
|
|
174
|
+
confidence = success_count / len(same_strategy)
|
|
175
|
+
|
|
176
|
+
# Only create heuristic if confidence is meaningful
|
|
177
|
+
if confidence > 0.5:
|
|
178
|
+
heuristic = Heuristic(
|
|
179
|
+
id=f"heur_{uuid.uuid4().hex[:12]}",
|
|
180
|
+
agent=agent,
|
|
181
|
+
project_id=project_id,
|
|
182
|
+
condition=f"task type: {task_type}",
|
|
183
|
+
strategy=strategy,
|
|
184
|
+
confidence=confidence,
|
|
185
|
+
occurrence_count=len(same_strategy),
|
|
186
|
+
success_count=success_count,
|
|
187
|
+
last_validated=datetime.now(timezone.utc),
|
|
188
|
+
created_at=datetime.now(timezone.utc),
|
|
189
|
+
)
|
|
190
|
+
self.storage.save_heuristic(heuristic)
|
|
191
|
+
logger.info(
|
|
192
|
+
f"Created heuristic for {agent}: {strategy[:50]}... "
|
|
193
|
+
f"(confidence: {confidence:.0%})"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def _maybe_create_anti_pattern(
|
|
197
|
+
self,
|
|
198
|
+
agent: str,
|
|
199
|
+
project_id: str,
|
|
200
|
+
task: str,
|
|
201
|
+
strategy: str,
|
|
202
|
+
error: str,
|
|
203
|
+
):
|
|
204
|
+
"""Create anti-pattern if we see repeated failures with same pattern."""
|
|
205
|
+
# Check for similar failures
|
|
206
|
+
similar_failures = self.storage.get_outcomes(
|
|
207
|
+
project_id=project_id,
|
|
208
|
+
agent=agent,
|
|
209
|
+
success_only=False,
|
|
210
|
+
top_k=10,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Filter to failures with similar error
|
|
214
|
+
similar = [
|
|
215
|
+
o
|
|
216
|
+
for o in similar_failures
|
|
217
|
+
if not o.success
|
|
218
|
+
and o.error_message
|
|
219
|
+
and self._errors_similar(o.error_message, error)
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
if len(similar) >= 2: # At least 2 similar failures
|
|
223
|
+
anti_pattern = AntiPattern(
|
|
224
|
+
id=f"anti_{uuid.uuid4().hex[:12]}",
|
|
225
|
+
agent=agent,
|
|
226
|
+
project_id=project_id,
|
|
227
|
+
pattern=strategy,
|
|
228
|
+
why_bad=error,
|
|
229
|
+
better_alternative="[To be determined from successful outcomes]",
|
|
230
|
+
occurrence_count=len(similar),
|
|
231
|
+
last_seen=datetime.now(timezone.utc),
|
|
232
|
+
)
|
|
233
|
+
self.storage.save_anti_pattern(anti_pattern)
|
|
234
|
+
logger.info(f"Created anti-pattern for {agent}: {strategy[:50]}...")
|
|
235
|
+
|
|
236
|
+
def add_preference(
|
|
237
|
+
self,
|
|
238
|
+
user_id: str,
|
|
239
|
+
category: str,
|
|
240
|
+
preference: str,
|
|
241
|
+
source: str,
|
|
242
|
+
) -> UserPreference:
|
|
243
|
+
"""Add a user preference."""
|
|
244
|
+
pref = UserPreference(
|
|
245
|
+
id=f"pref_{uuid.uuid4().hex[:12]}",
|
|
246
|
+
user_id=user_id,
|
|
247
|
+
category=category,
|
|
248
|
+
preference=preference,
|
|
249
|
+
source=source,
|
|
250
|
+
confidence=1.0 if source == "explicit_instruction" else 0.7,
|
|
251
|
+
timestamp=datetime.now(timezone.utc),
|
|
252
|
+
)
|
|
253
|
+
self.storage.save_user_preference(pref)
|
|
254
|
+
return pref
|
|
255
|
+
|
|
256
|
+
def add_domain_knowledge(
|
|
257
|
+
self,
|
|
258
|
+
agent: str,
|
|
259
|
+
project_id: str,
|
|
260
|
+
domain: str,
|
|
261
|
+
fact: str,
|
|
262
|
+
source: str,
|
|
263
|
+
) -> DomainKnowledge:
|
|
264
|
+
"""Add domain knowledge."""
|
|
265
|
+
knowledge = DomainKnowledge(
|
|
266
|
+
id=f"dk_{uuid.uuid4().hex[:12]}",
|
|
267
|
+
agent=agent,
|
|
268
|
+
project_id=project_id,
|
|
269
|
+
domain=domain,
|
|
270
|
+
fact=fact,
|
|
271
|
+
source=source,
|
|
272
|
+
confidence=1.0 if source == "user_stated" else 0.8,
|
|
273
|
+
last_verified=datetime.now(timezone.utc),
|
|
274
|
+
)
|
|
275
|
+
self.storage.save_domain_knowledge(knowledge)
|
|
276
|
+
return knowledge
|
|
277
|
+
|
|
278
|
+
def forget(
|
|
279
|
+
self,
|
|
280
|
+
project_id: str,
|
|
281
|
+
agent: Optional[str] = None,
|
|
282
|
+
older_than_days: int = 90,
|
|
283
|
+
below_confidence: float = 0.3,
|
|
284
|
+
) -> int:
|
|
285
|
+
"""
|
|
286
|
+
Prune stale and low-confidence memories.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
Total number of items pruned
|
|
290
|
+
"""
|
|
291
|
+
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
|
|
292
|
+
|
|
293
|
+
# Delete old outcomes
|
|
294
|
+
outcomes_deleted = self.storage.delete_outcomes_older_than(
|
|
295
|
+
project_id=project_id,
|
|
296
|
+
older_than=cutoff,
|
|
297
|
+
agent=agent,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# Delete low-confidence heuristics
|
|
301
|
+
heuristics_deleted = self.storage.delete_low_confidence_heuristics(
|
|
302
|
+
project_id=project_id,
|
|
303
|
+
below_confidence=below_confidence,
|
|
304
|
+
agent=agent,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
total = outcomes_deleted + heuristics_deleted
|
|
308
|
+
logger.info(
|
|
309
|
+
f"Forgot {total} items: {outcomes_deleted} outcomes, "
|
|
310
|
+
f"{heuristics_deleted} heuristics"
|
|
311
|
+
)
|
|
312
|
+
return total
|
|
313
|
+
|
|
314
|
+
def _infer_task_type(self, task: str) -> str:
|
|
315
|
+
"""Infer task type from description."""
|
|
316
|
+
task_lower = task.lower()
|
|
317
|
+
if "test" in task_lower or "validate" in task_lower:
|
|
318
|
+
return "testing"
|
|
319
|
+
elif "api" in task_lower or "endpoint" in task_lower:
|
|
320
|
+
return "api_testing"
|
|
321
|
+
elif "form" in task_lower or "input" in task_lower:
|
|
322
|
+
return "form_testing"
|
|
323
|
+
elif "database" in task_lower or "query" in task_lower:
|
|
324
|
+
return "database_validation"
|
|
325
|
+
return "general"
|
|
326
|
+
|
|
327
|
+
def _strategies_similar(self, s1: str, s2: str) -> bool:
|
|
328
|
+
"""
|
|
329
|
+
Check if two strategies are similar enough to count together.
|
|
330
|
+
|
|
331
|
+
Uses embedding-based cosine similarity when an embedder is available,
|
|
332
|
+
otherwise falls back to simple word overlap.
|
|
333
|
+
"""
|
|
334
|
+
if self.embedder is not None:
|
|
335
|
+
return self._strategies_similar_embedding(s1, s2)
|
|
336
|
+
return self._strategies_similar_word_overlap(s1, s2)
|
|
337
|
+
|
|
338
|
+
def _strategies_similar_embedding(self, s1: str, s2: str) -> bool:
|
|
339
|
+
"""Check strategy similarity using embedding cosine similarity."""
|
|
340
|
+
try:
|
|
341
|
+
emb1 = self.embedder.encode(s1)
|
|
342
|
+
emb2 = self.embedder.encode(s2)
|
|
343
|
+
similarity = self._cosine_similarity(emb1, emb2)
|
|
344
|
+
return similarity >= self.similarity_threshold
|
|
345
|
+
except Exception as e:
|
|
346
|
+
logger.warning(
|
|
347
|
+
f"Embedding similarity failed, falling back to word overlap: {e}"
|
|
348
|
+
)
|
|
349
|
+
return self._strategies_similar_word_overlap(s1, s2)
|
|
350
|
+
|
|
351
|
+
def _strategies_similar_word_overlap(self, s1: str, s2: str) -> bool:
|
|
352
|
+
"""Check strategy similarity using simple word overlap."""
|
|
353
|
+
words1 = set(s1.lower().split())
|
|
354
|
+
words2 = set(s2.lower().split())
|
|
355
|
+
overlap = len(words1 & words2)
|
|
356
|
+
return overlap >= min(3, len(words1) // 2)
|
|
357
|
+
|
|
358
|
+
def _cosine_similarity(self, v1: list, v2: list) -> float:
|
|
359
|
+
"""Compute cosine similarity between two vectors."""
|
|
360
|
+
import math
|
|
361
|
+
|
|
362
|
+
dot_product = sum(a * b for a, b in zip(v1, v2, strict=False))
|
|
363
|
+
norm1 = math.sqrt(sum(a * a for a in v1))
|
|
364
|
+
norm2 = math.sqrt(sum(b * b for b in v2))
|
|
365
|
+
if norm1 == 0 or norm2 == 0:
|
|
366
|
+
return 0.0
|
|
367
|
+
return dot_product / (norm1 * norm2)
|
|
368
|
+
|
|
369
|
+
def _errors_similar(self, e1: str, e2: str) -> bool:
|
|
370
|
+
"""Check if two errors are similar."""
|
|
371
|
+
# Simple substring check
|
|
372
|
+
e1_lower = e1.lower()
|
|
373
|
+
e2_lower = e2.lower()
|
|
374
|
+
return e1_lower in e2_lower or e2_lower in e1_lower
|