agentdatabase 0.1.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.
- agentdatabase-0.1.0.dist-info/METADATA +847 -0
- agentdatabase-0.1.0.dist-info/RECORD +35 -0
- agentdatabase-0.1.0.dist-info/WHEEL +5 -0
- agentdatabase-0.1.0.dist-info/entry_points.txt +2 -0
- agentdatabase-0.1.0.dist-info/licenses/LICENSE +651 -0
- agentdatabase-0.1.0.dist-info/top_level.txt +1 -0
- agentdb/__init__.py +5 -0
- agentdb/adapters/claude_agent_sdk.py +831 -0
- agentdb/adapters/hermes.py +247 -0
- agentdb/backend.py +75 -0
- agentdb/core/__init__.py +19 -0
- agentdb/core/directory_tracking.py +59 -0
- agentdb/core/file_integrity.py +79 -0
- agentdb/core/models.py +90 -0
- agentdb/core/profiles.py +116 -0
- agentdb/core/store.py +373 -0
- agentdb/core/system.py +86 -0
- agentdb/embeddings/__init__.py +7 -0
- agentdb/embeddings/provider.py +99 -0
- agentdb/embeddings/store.py +199 -0
- agentdb/embeddings/text.py +20 -0
- agentdb/gateway/__init__.py +189 -0
- agentdb/gateway/adapter.py +58 -0
- agentdb/governance/__init__.py +3 -0
- agentdb/governance/conflict_detector.py +103 -0
- agentdb/governance/lifecycle_manager.py +226 -0
- agentdb/governance/permission_router.py +131 -0
- agentdb/interface/__init__.py +25 -0
- agentdb/interface/client.py +1108 -0
- agentdb/interface/mcp_server.py +96 -0
- agentdb/retrieval/__init__.py +3 -0
- agentdb/retrieval/algorithm.py +207 -0
- agentdb/skills/__init__.py +3 -0
- agentdb/skills/skill_store.py +351 -0
- agentdb/testing.py +68 -0
|
@@ -0,0 +1,1108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
import warnings
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Callable, Optional
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from ..core.models import ConflictRecord, MemoryRecord, Skill
|
|
17
|
+
from ..core.profiles import BUILTIN_PROFILES
|
|
18
|
+
from ..core.store import MemoryStore
|
|
19
|
+
from ..core.system import SystemStats
|
|
20
|
+
from ..gateway import Gateway
|
|
21
|
+
from ..governance.conflict_detector import ConflictDetector
|
|
22
|
+
from ..governance.lifecycle_manager import LifecycleManager
|
|
23
|
+
from ..governance.permission_router import PermissionDenied, PermissionRouter
|
|
24
|
+
from ..retrieval.algorithm import MultiSignalRanker
|
|
25
|
+
from ..skills.skill_store import SkillStore
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Event constants
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
EVENT_MEMORY_WRITE = "memory_write"
|
|
33
|
+
EVENT_MEMORY_DELETE = "memory_delete"
|
|
34
|
+
EVENT_MEMORY_RETRIEVE = "memory_retrieve"
|
|
35
|
+
EVENT_MEMORY_OUTCOME = "memory_outcome"
|
|
36
|
+
EVENT_EXTERNAL_CONFLICT_DETECTED = "external_conflict_detected"
|
|
37
|
+
EVENT_PERMISSION_DENIED = "permission_denied"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Data containers
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class AuditEntry:
|
|
46
|
+
event_type: str
|
|
47
|
+
payload: dict
|
|
48
|
+
sequence: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class RetrievalResult:
|
|
53
|
+
records: list
|
|
54
|
+
conflicts: list
|
|
55
|
+
override_instructions: list
|
|
56
|
+
skills: list
|
|
57
|
+
skill_warnings: list
|
|
58
|
+
token_estimate: int = 0
|
|
59
|
+
truncated: bool = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# SQLite schema
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
_CREATE_AUDIT_LOG = """
|
|
67
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
68
|
+
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
69
|
+
event_type TEXT NOT NULL,
|
|
70
|
+
payload TEXT NOT NULL,
|
|
71
|
+
created_at TEXT NOT NULL
|
|
72
|
+
)
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
_CREATE_OUTCOMES = """
|
|
76
|
+
CREATE TABLE IF NOT EXISTS outcomes (
|
|
77
|
+
id TEXT PRIMARY KEY,
|
|
78
|
+
session_id TEXT NOT NULL,
|
|
79
|
+
agent_id TEXT NOT NULL,
|
|
80
|
+
outcome_type TEXT NOT NULL,
|
|
81
|
+
outcome_value REAL NOT NULL,
|
|
82
|
+
linked_retrieval_sequence INTEGER,
|
|
83
|
+
created_at TEXT NOT NULL
|
|
84
|
+
)
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
_CREATE_EXTERNAL_RETRIEVES = """
|
|
88
|
+
CREATE TABLE IF NOT EXISTS external_retrieves (
|
|
89
|
+
session_id TEXT NOT NULL,
|
|
90
|
+
had_external_context INTEGER NOT NULL,
|
|
91
|
+
had_conflict INTEGER NOT NULL DEFAULT 0
|
|
92
|
+
)
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def _utcnow() -> datetime:
|
|
96
|
+
return datetime.now(timezone.utc)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _estimate_tokens(record: MemoryRecord) -> int:
|
|
100
|
+
value_str = json.dumps(record.value) if isinstance(record.value, (dict, list)) else str(record.value)
|
|
101
|
+
return max(1, len(value_str) // 4)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Audit log
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
class AuditLog:
|
|
109
|
+
def __init__(self, db_path: Path) -> None:
|
|
110
|
+
self._db_path = db_path
|
|
111
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
with self._connect() as conn:
|
|
113
|
+
conn.execute(_CREATE_AUDIT_LOG)
|
|
114
|
+
|
|
115
|
+
@contextmanager
|
|
116
|
+
def _connect(self):
|
|
117
|
+
conn = sqlite3.connect(str(self._db_path))
|
|
118
|
+
conn.row_factory = sqlite3.Row
|
|
119
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
120
|
+
try:
|
|
121
|
+
yield conn
|
|
122
|
+
conn.commit()
|
|
123
|
+
except Exception:
|
|
124
|
+
conn.rollback()
|
|
125
|
+
raise
|
|
126
|
+
finally:
|
|
127
|
+
conn.close()
|
|
128
|
+
|
|
129
|
+
def append(self, event_type: str, payload: dict) -> int:
|
|
130
|
+
now = _utcnow().isoformat()
|
|
131
|
+
with self._connect() as conn:
|
|
132
|
+
cursor = conn.execute(
|
|
133
|
+
"INSERT INTO audit_log (event_type, payload, created_at) VALUES (?, ?, ?)",
|
|
134
|
+
(event_type, json.dumps(payload), now),
|
|
135
|
+
)
|
|
136
|
+
return cursor.lastrowid
|
|
137
|
+
|
|
138
|
+
def replay(self, from_sequence: int = 0) -> list[AuditEntry]:
|
|
139
|
+
with self._connect() as conn:
|
|
140
|
+
rows = conn.execute(
|
|
141
|
+
"SELECT sequence, event_type, payload FROM audit_log WHERE sequence >= ? ORDER BY sequence",
|
|
142
|
+
(from_sequence,),
|
|
143
|
+
).fetchall()
|
|
144
|
+
return [
|
|
145
|
+
AuditEntry(
|
|
146
|
+
event_type=row["event_type"],
|
|
147
|
+
payload=json.loads(row["payload"]),
|
|
148
|
+
sequence=row["sequence"],
|
|
149
|
+
)
|
|
150
|
+
for row in rows
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Skills API (high-level facade)
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
class SkillsAPI:
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
skill_store: SkillStore,
|
|
162
|
+
embedding_provider=None,
|
|
163
|
+
embedding_store=None,
|
|
164
|
+
) -> None:
|
|
165
|
+
self._store = skill_store
|
|
166
|
+
self._embedding_provider = embedding_provider
|
|
167
|
+
self._embedding_store = embedding_store
|
|
168
|
+
|
|
169
|
+
def _maybe_embed_skill(self, skill: Skill) -> None:
|
|
170
|
+
if self._embedding_provider is None or self._embedding_store is None:
|
|
171
|
+
return
|
|
172
|
+
try:
|
|
173
|
+
from ..embeddings.text import _embed_text_for_skill
|
|
174
|
+
vec = self._embedding_provider.embed(_embed_text_for_skill(skill))
|
|
175
|
+
self._embedding_store.put(
|
|
176
|
+
"skills", f"{skill.id}:{skill.version}", vec, working_dirs=[]
|
|
177
|
+
)
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
def create_skill(
|
|
182
|
+
self,
|
|
183
|
+
skill_id: str,
|
|
184
|
+
name: str,
|
|
185
|
+
content: str,
|
|
186
|
+
scope: list[str],
|
|
187
|
+
) -> Skill:
|
|
188
|
+
skill = Skill(
|
|
189
|
+
id=skill_id,
|
|
190
|
+
name=name,
|
|
191
|
+
content=content,
|
|
192
|
+
authored_by="human",
|
|
193
|
+
scope=scope,
|
|
194
|
+
)
|
|
195
|
+
created = self._store.create(skill)
|
|
196
|
+
self._maybe_embed_skill(created)
|
|
197
|
+
return created
|
|
198
|
+
|
|
199
|
+
def update_skill_version(self, skill_id: str, content: str, new_version: str) -> Skill:
|
|
200
|
+
updated = self._store.update(skill_id, content, new_version)
|
|
201
|
+
self._maybe_embed_skill(updated)
|
|
202
|
+
return updated
|
|
203
|
+
|
|
204
|
+
def get_skill(self, skill_id: str) -> Optional[Skill]:
|
|
205
|
+
return self._store.get(skill_id)
|
|
206
|
+
|
|
207
|
+
def archive_skill(self, skill_id: str, version: str) -> None:
|
|
208
|
+
self._store.archive(skill_id, version)
|
|
209
|
+
if self._embedding_store is not None:
|
|
210
|
+
try:
|
|
211
|
+
self._embedding_store.delete("skills", f"{skill_id}:{version}")
|
|
212
|
+
except Exception:
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
def list_stale_skills_for_agent(self, agent_id: str) -> list[Skill]:
|
|
216
|
+
return self._store.list_stale(agent_id)
|
|
217
|
+
|
|
218
|
+
def list_skill_warnings_for_agent(self, agent_id: str) -> list[str]:
|
|
219
|
+
stale = self._store.list_stale(agent_id)
|
|
220
|
+
return [
|
|
221
|
+
f"Skill '{skill.id}' is stale (health_score={skill.health_score:.2f})"
|
|
222
|
+
for skill in stale
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# Profile-based defaults and config resolution
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
class ConfigurationError(Exception):
|
|
231
|
+
"""Raised for any invalid memory.yaml profile configuration. Never
|
|
232
|
+
silently substituted for a default -- see Design Principle in
|
|
233
|
+
docs/superpowers/specs/2026-07-27-unified-profile-configuration-design.md."""
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
_OVERRIDE_SECTIONS = ("retrieval", "injection", "lifecycle", "capture")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _builtin_profile_dict(name: str) -> dict:
|
|
240
|
+
if name not in BUILTIN_PROFILES:
|
|
241
|
+
raise ConfigurationError(
|
|
242
|
+
f"profile.name '{name}' is not a recognized built-in profile "
|
|
243
|
+
f"(expected one of {sorted(BUILTIN_PROFILES.keys())})"
|
|
244
|
+
)
|
|
245
|
+
return dataclasses.asdict(BUILTIN_PROFILES[name])
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
249
|
+
result = dict(base)
|
|
250
|
+
for key, val in override.items():
|
|
251
|
+
if key in result and isinstance(result[key], dict) and isinstance(val, dict):
|
|
252
|
+
result[key] = _deep_merge(result[key], val)
|
|
253
|
+
else:
|
|
254
|
+
result[key] = val
|
|
255
|
+
return result
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _validate_and_merge_overrides(base: dict, profile_block: dict, where: str) -> dict:
|
|
259
|
+
if not isinstance(profile_block, dict):
|
|
260
|
+
raise ConfigurationError(
|
|
261
|
+
f"{where}: expected a mapping (e.g. '{where}:\n name: ...'), got "
|
|
262
|
+
f"{type(profile_block).__name__} ({profile_block!r})"
|
|
263
|
+
)
|
|
264
|
+
result = dict(base)
|
|
265
|
+
for key in profile_block:
|
|
266
|
+
if key in ("name", "agents"):
|
|
267
|
+
continue
|
|
268
|
+
if key not in _OVERRIDE_SECTIONS:
|
|
269
|
+
raise ConfigurationError(
|
|
270
|
+
f"{where}: unknown key '{key}' (expected one of {_OVERRIDE_SECTIONS}, 'name', or 'agents')"
|
|
271
|
+
)
|
|
272
|
+
section_override = profile_block[key]
|
|
273
|
+
if not isinstance(section_override, dict):
|
|
274
|
+
raise ConfigurationError(
|
|
275
|
+
f"{where}.{key}: expected a mapping, got {type(section_override).__name__} ({section_override!r})"
|
|
276
|
+
)
|
|
277
|
+
base_section = result.get(key, {})
|
|
278
|
+
for field, value in section_override.items():
|
|
279
|
+
if field not in base_section:
|
|
280
|
+
raise ConfigurationError(f"{where}.{key}: unknown field '{field}'")
|
|
281
|
+
base_value = base_section[field]
|
|
282
|
+
if isinstance(base_value, dict):
|
|
283
|
+
if not isinstance(value, dict):
|
|
284
|
+
raise ConfigurationError(
|
|
285
|
+
f"{where}.{key}.{field}: expected dict, got {type(value).__name__} ({value!r})"
|
|
286
|
+
)
|
|
287
|
+
unknown = set(value) - set(base_value)
|
|
288
|
+
if unknown:
|
|
289
|
+
raise ConfigurationError(f"{where}.{key}.{field}: unknown field(s) {sorted(unknown)}")
|
|
290
|
+
for sub_field, sub_value in value.items():
|
|
291
|
+
expected_sub_type = type(base_value[sub_field])
|
|
292
|
+
if not isinstance(sub_value, expected_sub_type):
|
|
293
|
+
raise ConfigurationError(
|
|
294
|
+
f"{where}.{key}.{field}.{sub_field}: expected {expected_sub_type.__name__}, "
|
|
295
|
+
f"got {type(sub_value).__name__} ({sub_value!r})"
|
|
296
|
+
)
|
|
297
|
+
else:
|
|
298
|
+
expected_type = type(base_value)
|
|
299
|
+
if not isinstance(value, expected_type):
|
|
300
|
+
raise ConfigurationError(
|
|
301
|
+
f"{where}.{key}.{field}: expected {expected_type.__name__}, got {type(value).__name__} ({value!r})"
|
|
302
|
+
)
|
|
303
|
+
result[key] = _deep_merge(base_section, section_override)
|
|
304
|
+
return result
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
_LEGACY_ROOT_KEYS = ("mode", "profiles", "retrieval", "injection", "lifecycle", "capture")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _resolve_config(yaml_dict: dict) -> dict:
|
|
311
|
+
"""
|
|
312
|
+
Resolve the final config dict from the new single-root `profile:` schema.
|
|
313
|
+
Priority (low -> high): built-in profile defaults, then override sections
|
|
314
|
+
(retrieval/injection/lifecycle/capture) declared directly under profile:.
|
|
315
|
+
Raises ConfigurationError on any unrecognized name, key, or wrong type --
|
|
316
|
+
never silently substitutes a default.
|
|
317
|
+
"""
|
|
318
|
+
if yaml_dict:
|
|
319
|
+
stale_keys = [k for k in _LEGACY_ROOT_KEYS if k in yaml_dict]
|
|
320
|
+
if stale_keys:
|
|
321
|
+
raise ConfigurationError(
|
|
322
|
+
f"memory.yaml has old-schema root key(s) {stale_keys} -- these must nest under "
|
|
323
|
+
f"a single top-level 'profile:' key now (e.g. 'capture:' becomes "
|
|
324
|
+
f"'profile.capture:'), see docs/superpowers/specs/"
|
|
325
|
+
f"2026-07-27-unified-profile-configuration-design.md"
|
|
326
|
+
)
|
|
327
|
+
profile_block = yaml_dict.get("profile", {}) if yaml_dict else {}
|
|
328
|
+
if not isinstance(profile_block, dict):
|
|
329
|
+
raise ConfigurationError(
|
|
330
|
+
f"profile: expected a mapping (e.g. 'profile:\n name: ...'), got "
|
|
331
|
+
f"{type(profile_block).__name__} ({profile_block!r})"
|
|
332
|
+
)
|
|
333
|
+
name = profile_block.get("name")
|
|
334
|
+
if not name:
|
|
335
|
+
raise ConfigurationError("profile.name is required")
|
|
336
|
+
base = _builtin_profile_dict(name)
|
|
337
|
+
return _validate_and_merge_overrides(base, profile_block, where="profile")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
# Default memory.yaml content
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
_DEFAULT_MEMORY_YAML = """\
|
|
345
|
+
# profile.name: budget_optimized | full_storage | research
|
|
346
|
+
# Any of retrieval:/injection:/lifecycle:/capture: declared alongside name
|
|
347
|
+
# override just those fields on top of the named profile.
|
|
348
|
+
profile:
|
|
349
|
+
name: budget_optimized
|
|
350
|
+
|
|
351
|
+
reconciliation:
|
|
352
|
+
triggers:
|
|
353
|
+
- conversation_started
|
|
354
|
+
database:
|
|
355
|
+
mode: embedded
|
|
356
|
+
path: database/agentdb.sqlite
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
# Main AgentDB class
|
|
362
|
+
# ---------------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
class AgentDB:
|
|
365
|
+
def __init__(
|
|
366
|
+
self,
|
|
367
|
+
path: Optional[Any] = None,
|
|
368
|
+
memory_dir: Optional[Any] = None,
|
|
369
|
+
permissions_path: Optional[Any] = None,
|
|
370
|
+
tracking_directories: Optional[list[str]] = None,
|
|
371
|
+
tracking_root: Any = ".",
|
|
372
|
+
tracking_max_files_per_directory: Optional[int] = None,
|
|
373
|
+
db_path: Optional[Any] = None,
|
|
374
|
+
summarizer: Optional[Callable] = None,
|
|
375
|
+
infer_fn: Optional[Callable] = None,
|
|
376
|
+
embed_fn: Optional[Callable] = None,
|
|
377
|
+
local_embed: bool = False,
|
|
378
|
+
) -> None:
|
|
379
|
+
# db_path is an alias for path (used by adapter tests)
|
|
380
|
+
if db_path is not None and path is None:
|
|
381
|
+
path = db_path
|
|
382
|
+
self._summarizer = summarizer
|
|
383
|
+
self._infer_fn = infer_fn
|
|
384
|
+
# Resolve memory_dir from args or env vars
|
|
385
|
+
resolved_memory_dir: Optional[Path] = None
|
|
386
|
+
if memory_dir is not None:
|
|
387
|
+
resolved_memory_dir = Path(memory_dir).resolve()
|
|
388
|
+
else:
|
|
389
|
+
env_memory_dir = os.environ.get("AGENTDB_MEMORY_DIR")
|
|
390
|
+
env_home = os.environ.get("AGENTDB_HOME")
|
|
391
|
+
if env_memory_dir:
|
|
392
|
+
resolved_memory_dir = Path(env_memory_dir).resolve()
|
|
393
|
+
elif env_home:
|
|
394
|
+
resolved_memory_dir = Path(env_home).resolve()
|
|
395
|
+
|
|
396
|
+
self.memory_dir: Optional[Path] = resolved_memory_dir
|
|
397
|
+
|
|
398
|
+
# Set up workspace directories if memory_dir is provided
|
|
399
|
+
self.memory_config_path: Optional[Path] = None
|
|
400
|
+
self.memory_config: dict = {}
|
|
401
|
+
|
|
402
|
+
if resolved_memory_dir is not None:
|
|
403
|
+
# Create workspace layout
|
|
404
|
+
for subdir in ("config", "database", "conversations", "embeddings", "reconciliation", "logs"):
|
|
405
|
+
(resolved_memory_dir / subdir).mkdir(parents=True, exist_ok=True)
|
|
406
|
+
|
|
407
|
+
config_path = resolved_memory_dir / "config" / "memory.yaml"
|
|
408
|
+
self.memory_config_path = config_path.resolve()
|
|
409
|
+
|
|
410
|
+
if not config_path.exists():
|
|
411
|
+
config_path.write_text(_DEFAULT_MEMORY_YAML, encoding="utf-8")
|
|
412
|
+
|
|
413
|
+
with config_path.open("r", encoding="utf-8") as f:
|
|
414
|
+
self.memory_config = yaml.safe_load(f) or {}
|
|
415
|
+
|
|
416
|
+
# Validate full_storage profile. profile may be a non-dict (e.g. a
|
|
417
|
+
# bare scalar like `profile: research`) here -- that's a config
|
|
418
|
+
# error, but it's _resolve_config's job (below) to raise it with a
|
|
419
|
+
# proper ConfigurationError; this check just needs to not crash on
|
|
420
|
+
# its way there.
|
|
421
|
+
_profile_block_for_check = self.memory_config.get("profile", {})
|
|
422
|
+
if not isinstance(_profile_block_for_check, dict):
|
|
423
|
+
_profile_block_for_check = {}
|
|
424
|
+
if _profile_block_for_check.get("name") == "full_storage":
|
|
425
|
+
td = self.memory_config.get("tracked_directories", [])
|
|
426
|
+
if not td:
|
|
427
|
+
raise ValueError(
|
|
428
|
+
"tracked_directories must be non-empty when profile.name is 'full_storage'"
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# Determine DB path
|
|
432
|
+
if path is not None:
|
|
433
|
+
db_path = Path(path).resolve()
|
|
434
|
+
elif resolved_memory_dir is not None:
|
|
435
|
+
db_path = (resolved_memory_dir / "database" / "agentdb.sqlite").resolve()
|
|
436
|
+
else:
|
|
437
|
+
db_path = Path(":memory:")
|
|
438
|
+
|
|
439
|
+
self._path = db_path
|
|
440
|
+
|
|
441
|
+
# Set up permissions router
|
|
442
|
+
self._permissions: Optional[PermissionRouter] = None
|
|
443
|
+
if permissions_path is not None:
|
|
444
|
+
self._permissions = PermissionRouter(permissions_path)
|
|
445
|
+
|
|
446
|
+
# Initialize stores using shared DB path
|
|
447
|
+
self._store = MemoryStore(db_path)
|
|
448
|
+
self.audit_log = AuditLog(db_path)
|
|
449
|
+
self._system = SystemStats(db_path)
|
|
450
|
+
|
|
451
|
+
# Skills store - use separate DB or same
|
|
452
|
+
skills_db_path = db_path.parent / "skills.sqlite" if db_path != Path(":memory:") else db_path
|
|
453
|
+
|
|
454
|
+
def _on_skill_stale(skill_id: str) -> None:
|
|
455
|
+
self.gateway.dispatch("skill_stale", skill_id=skill_id)
|
|
456
|
+
|
|
457
|
+
self.skills = SkillStore(db_path=skills_db_path, on_stale_crossing=_on_skill_stale)
|
|
458
|
+
|
|
459
|
+
# Initialize outcomes table
|
|
460
|
+
self._init_outcomes_table()
|
|
461
|
+
|
|
462
|
+
# Resolve profile-based config. When no memory_dir was provided at
|
|
463
|
+
# all, there is no config file to speak of and we fall back to the
|
|
464
|
+
# budget_optimized built-in directly -- this is "no config given"
|
|
465
|
+
# rather than "invalid config". But if memory_dir WAS provided, any
|
|
466
|
+
# memory_config content (including a blank/empty file that parses to
|
|
467
|
+
# None/{}) goes through _resolve_config's strict profile.name
|
|
468
|
+
# validation -- never a silent fallback.
|
|
469
|
+
if self.memory_dir is not None:
|
|
470
|
+
self._resolved_config = _resolve_config(self.memory_config)
|
|
471
|
+
else:
|
|
472
|
+
self._resolved_config = _builtin_profile_dict("budget_optimized")
|
|
473
|
+
|
|
474
|
+
# Eagerly validate every profile.agents.* block now, so a typo in any
|
|
475
|
+
# bot's config is a startup failure, not a failure the first time
|
|
476
|
+
# that specific bot happens to retrieve.
|
|
477
|
+
agents = self.memory_config.get("profile", {}).get("agents", {}) if self.memory_config else {}
|
|
478
|
+
for agent_id in agents:
|
|
479
|
+
self.config_for(agent_id)
|
|
480
|
+
|
|
481
|
+
# Tracking config
|
|
482
|
+
self.tracking_directories: list[str] = tracking_directories or []
|
|
483
|
+
self.tracking_root: Path = Path(tracking_root).resolve()
|
|
484
|
+
self.tracking_max_files_per_directory: int = (
|
|
485
|
+
tracking_max_files_per_directory
|
|
486
|
+
if tracking_max_files_per_directory is not None
|
|
487
|
+
else self._resolved_config["capture"]["tracking_max_files_per_directory"]
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
# Session tracking: session_id -> list of record ids retrieved
|
|
491
|
+
self._session_record_ids: dict[str, list[str]] = {}
|
|
492
|
+
|
|
493
|
+
# Embedding layer (optional) — must be initialized before ranker
|
|
494
|
+
self._raw_embeddings_cfg: dict = (self.memory_config or {}).get("embeddings", {})
|
|
495
|
+
self._embedding_provider = None
|
|
496
|
+
self._embedding_store = None
|
|
497
|
+
if embed_fn is not None:
|
|
498
|
+
from ..embeddings import FunctionEmbeddingProvider, EmbeddingStore
|
|
499
|
+
self._embedding_provider = FunctionEmbeddingProvider(embed_fn)
|
|
500
|
+
elif local_embed:
|
|
501
|
+
from ..embeddings import LocalEmbeddingProvider, EmbeddingStore
|
|
502
|
+
self._embedding_provider = LocalEmbeddingProvider()
|
|
503
|
+
if self._embedding_provider is not None and str(self._path) != ":memory:":
|
|
504
|
+
from ..embeddings import EmbeddingStore
|
|
505
|
+
embed_db_path = self._path.parent / "embeddings.db"
|
|
506
|
+
self._embedding_store = EmbeddingStore(
|
|
507
|
+
db_path=embed_db_path,
|
|
508
|
+
model_name=self._embedding_provider.model_name,
|
|
509
|
+
dimensions=self._embedding_provider.dimensions,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
# Skills API — created after embedding init so it receives embedding deps
|
|
513
|
+
self.skills_api = SkillsAPI(
|
|
514
|
+
self.skills,
|
|
515
|
+
embedding_provider=self._embedding_provider,
|
|
516
|
+
embedding_store=self._embedding_store,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
# Ranker with weights from resolved config; semantic weight activated when provider present
|
|
520
|
+
self._ranker = self._build_ranker()
|
|
521
|
+
|
|
522
|
+
# Gateway surface (initialized after all dependencies are ready)
|
|
523
|
+
self._gateway: Optional[Gateway] = None
|
|
524
|
+
|
|
525
|
+
# Register session topic handler when embedding is active
|
|
526
|
+
if self._embedding_provider is not None:
|
|
527
|
+
self.gateway.subscribe("conversation_started", self._handle_conversation_started_embedding)
|
|
528
|
+
|
|
529
|
+
# Lifecycle manager — initialized after _session_record_ids is created
|
|
530
|
+
lc = self._resolved_config["lifecycle"]
|
|
531
|
+
self._lifecycle = LifecycleManager(
|
|
532
|
+
store=self._store,
|
|
533
|
+
system=self._system,
|
|
534
|
+
session_record_ids=self._session_record_ids,
|
|
535
|
+
embedding_store=self._embedding_store,
|
|
536
|
+
skill_store=self.skills,
|
|
537
|
+
confidence_decay_rate=lc["confidence_decay_rate"],
|
|
538
|
+
archival_threshold=lc["archival_threshold"],
|
|
539
|
+
staleness_days=lc["staleness_days"],
|
|
540
|
+
reinforce_rate=lc["reinforce_rate"],
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
def _build_ranker(self) -> "MultiSignalRanker":
|
|
544
|
+
rw = self._resolved_config["retrieval"]["ranker_weights"]
|
|
545
|
+
lc = self._resolved_config["lifecycle"]
|
|
546
|
+
staleness = lc["staleness_days"]
|
|
547
|
+
if self._embedding_provider is not None:
|
|
548
|
+
w = float(self._raw_embeddings_cfg.get("semantic_ranker_weight", 0.20))
|
|
549
|
+
scale = 1.0 - w
|
|
550
|
+
return MultiSignalRanker(
|
|
551
|
+
weight_provenance=rw["provenance"] * scale,
|
|
552
|
+
weight_recency=rw["recency"] * scale,
|
|
553
|
+
weight_confidence=rw["confidence"] * scale,
|
|
554
|
+
weight_causal=rw["causal"] * scale,
|
|
555
|
+
weight_semantic=w,
|
|
556
|
+
weight_workspace=rw.get("workspace", 0.0) * scale,
|
|
557
|
+
staleness_days=staleness,
|
|
558
|
+
)
|
|
559
|
+
return MultiSignalRanker(
|
|
560
|
+
weight_provenance=rw["provenance"],
|
|
561
|
+
weight_recency=rw["recency"],
|
|
562
|
+
weight_confidence=rw["confidence"],
|
|
563
|
+
weight_causal=rw["causal"],
|
|
564
|
+
weight_semantic=rw["semantic"],
|
|
565
|
+
weight_workspace=rw.get("workspace", 0.0),
|
|
566
|
+
staleness_days=staleness,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
def _handle_conversation_started_embedding(self, **kwargs) -> None:
|
|
570
|
+
session_id = kwargs.get("session_id")
|
|
571
|
+
opening_message = kwargs.get("opening_message", "")
|
|
572
|
+
if not session_id or not opening_message:
|
|
573
|
+
return
|
|
574
|
+
try:
|
|
575
|
+
vec = self._embedding_provider.embed(opening_message)
|
|
576
|
+
self._embedding_store.put_session_topic(session_id, vec)
|
|
577
|
+
except Exception:
|
|
578
|
+
pass
|
|
579
|
+
|
|
580
|
+
@property
|
|
581
|
+
def _reconciliation_handlers(self) -> dict[str, list[Callable]]: # type: ignore[override]
|
|
582
|
+
"""Deprecated — handlers now live in db.gateway._handlers."""
|
|
583
|
+
warnings.warn(
|
|
584
|
+
"AgentDB._reconciliation_handlers is deprecated and will be removed in a future version. "
|
|
585
|
+
"Use db.gateway._handlers instead.",
|
|
586
|
+
DeprecationWarning,
|
|
587
|
+
stacklevel=2,
|
|
588
|
+
)
|
|
589
|
+
return self.gateway._handlers
|
|
590
|
+
|
|
591
|
+
@property
|
|
592
|
+
def gateway(self) -> Gateway:
|
|
593
|
+
"""Unified governance surface: reconciliation, conflict queue, proposals."""
|
|
594
|
+
if self._gateway is None:
|
|
595
|
+
self._gateway = Gateway(self)
|
|
596
|
+
return self._gateway
|
|
597
|
+
|
|
598
|
+
@property
|
|
599
|
+
def system(self) -> SystemStats:
|
|
600
|
+
"""System statistics surface."""
|
|
601
|
+
return self._system
|
|
602
|
+
|
|
603
|
+
@property
|
|
604
|
+
def lifecycle(self) -> LifecycleManager:
|
|
605
|
+
"""Lifecycle manager: sweep, reinforce, record_outcome."""
|
|
606
|
+
return self._lifecycle
|
|
607
|
+
|
|
608
|
+
@property
|
|
609
|
+
def config(self) -> dict:
|
|
610
|
+
"""Fully resolved config: profile defaults merged with memory.yaml overrides."""
|
|
611
|
+
return self._resolved_config
|
|
612
|
+
|
|
613
|
+
def config_for(self, agent_id: str) -> dict:
|
|
614
|
+
"""Resolved config for a specific agent: the global profile resolution,
|
|
615
|
+
with this agent's own overrides (if listed under profile.agents) merged
|
|
616
|
+
on top. isolate_by_agent is derived here -- True iff agent_id is listed
|
|
617
|
+
-- and is never accepted as an input anywhere in this codebase."""
|
|
618
|
+
agents = self.memory_config.get("profile", {}).get("agents", {}) if self.memory_config else {}
|
|
619
|
+
if agent_id not in agents:
|
|
620
|
+
return {**self.config, "isolate_by_agent": False}
|
|
621
|
+
|
|
622
|
+
agent_block = agents[agent_id] or {}
|
|
623
|
+
base = _builtin_profile_dict(agent_block["name"]) if "name" in agent_block else self.config
|
|
624
|
+
resolved = _validate_and_merge_overrides(base, agent_block, where=f"profile.agents.{agent_id}")
|
|
625
|
+
return {**resolved, "isolate_by_agent": True}
|
|
626
|
+
|
|
627
|
+
def _init_outcomes_table(self) -> None:
|
|
628
|
+
with self._store._connect() as conn:
|
|
629
|
+
conn.execute(_CREATE_OUTCOMES)
|
|
630
|
+
conn.execute(_CREATE_EXTERNAL_RETRIEVES)
|
|
631
|
+
|
|
632
|
+
def _derive_trust_level(self, origin: str) -> str:
|
|
633
|
+
if origin == "human_approved":
|
|
634
|
+
return "human"
|
|
635
|
+
elif origin == "provider_ingested":
|
|
636
|
+
return "provider"
|
|
637
|
+
else:
|
|
638
|
+
return "agent"
|
|
639
|
+
|
|
640
|
+
def _derive_trust_zone(self, origin: str) -> str:
|
|
641
|
+
"""Derive the trust zone from origin for permission checks."""
|
|
642
|
+
if origin == "human_approved":
|
|
643
|
+
return "human_approved"
|
|
644
|
+
return "agent_inferred"
|
|
645
|
+
|
|
646
|
+
def write(
|
|
647
|
+
self,
|
|
648
|
+
key: str,
|
|
649
|
+
value: Any,
|
|
650
|
+
origin: str,
|
|
651
|
+
agent_id: str,
|
|
652
|
+
session_id: Optional[str] = None,
|
|
653
|
+
entities: Optional[list] = None,
|
|
654
|
+
confidence: Optional[float] = None,
|
|
655
|
+
infer: bool = False,
|
|
656
|
+
expiry_time: Optional["datetime"] = None,
|
|
657
|
+
working_dirs: Optional[list[str]] = None,
|
|
658
|
+
) -> MemoryRecord:
|
|
659
|
+
if infer:
|
|
660
|
+
if self._infer_fn is None:
|
|
661
|
+
raise ValueError(
|
|
662
|
+
"infer=True requires an infer_fn — pass infer_fn=<callable> to AgentDB()"
|
|
663
|
+
)
|
|
664
|
+
value = self._infer_fn(value)
|
|
665
|
+
|
|
666
|
+
trust_zone = self._derive_trust_zone(origin)
|
|
667
|
+
|
|
668
|
+
# Permission check
|
|
669
|
+
if self._permissions is not None:
|
|
670
|
+
try:
|
|
671
|
+
self._permissions.check_write(key, agent_id, trust_zone)
|
|
672
|
+
except PermissionDenied as e:
|
|
673
|
+
self.audit_log.append(
|
|
674
|
+
EVENT_PERMISSION_DENIED,
|
|
675
|
+
{
|
|
676
|
+
"key": key,
|
|
677
|
+
"agent_id": agent_id,
|
|
678
|
+
"trust_zone": trust_zone,
|
|
679
|
+
"reason": str(e),
|
|
680
|
+
},
|
|
681
|
+
)
|
|
682
|
+
raise
|
|
683
|
+
|
|
684
|
+
trust_level = self._derive_trust_level(origin)
|
|
685
|
+
record = MemoryRecord(
|
|
686
|
+
key=key,
|
|
687
|
+
value=value,
|
|
688
|
+
origin=origin,
|
|
689
|
+
trust_level=trust_level,
|
|
690
|
+
agent_id=agent_id,
|
|
691
|
+
confidence=confidence if confidence is not None else 1.0,
|
|
692
|
+
entities=list(entities) if entities else [],
|
|
693
|
+
expiry_time=expiry_time,
|
|
694
|
+
inferred=infer,
|
|
695
|
+
working_dirs=[os.path.realpath(d) for d in (working_dirs or [])],
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
# Conflict detection: check before persisting
|
|
699
|
+
existing = self._store.get_by_key(key)
|
|
700
|
+
if existing is not None and not existing.archived:
|
|
701
|
+
conflict = ConflictDetector().check(new_record=record, existing=existing)
|
|
702
|
+
if conflict is not None:
|
|
703
|
+
record.conflict_id = conflict.id
|
|
704
|
+
existing.conflict_id = conflict.id
|
|
705
|
+
self._store.update_conflict_id(existing.id, conflict.id)
|
|
706
|
+
self._store.store_conflict(conflict)
|
|
707
|
+
self.gateway.dispatch(
|
|
708
|
+
"conflict_detected",
|
|
709
|
+
conflict_id=conflict.id,
|
|
710
|
+
conflict_type=conflict.conflict_type,
|
|
711
|
+
key=key,
|
|
712
|
+
agent_id=agent_id,
|
|
713
|
+
session_id=session_id,
|
|
714
|
+
)
|
|
715
|
+
self.audit_log.append(
|
|
716
|
+
"conflict_detected",
|
|
717
|
+
{
|
|
718
|
+
"conflict_id": conflict.id,
|
|
719
|
+
"conflict_type": conflict.conflict_type,
|
|
720
|
+
"key": key,
|
|
721
|
+
"record_a_id": existing.id,
|
|
722
|
+
"record_b_id": record.id,
|
|
723
|
+
"agent_id": agent_id,
|
|
724
|
+
},
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
self._store.write(record)
|
|
728
|
+
|
|
729
|
+
# Store embedding (non-blocking — failures never propagate to caller)
|
|
730
|
+
if self._embedding_provider is not None and self._embedding_store is not None:
|
|
731
|
+
try:
|
|
732
|
+
from ..embeddings.text import _embed_text_for_record
|
|
733
|
+
vec = self._embedding_provider.embed(_embed_text_for_record(record))
|
|
734
|
+
self._embedding_store.put(
|
|
735
|
+
"memories", record.id, vec, working_dirs=record.working_dirs
|
|
736
|
+
)
|
|
737
|
+
except Exception:
|
|
738
|
+
pass
|
|
739
|
+
|
|
740
|
+
# Semantic conflict detection (requires embedding to be stored first)
|
|
741
|
+
try:
|
|
742
|
+
threshold = float(
|
|
743
|
+
self._raw_embeddings_cfg.get("semantic_conflict_threshold", 0.92)
|
|
744
|
+
)
|
|
745
|
+
recent = [r for r in self._store.list_all()
|
|
746
|
+
if r.agent_id == agent_id and r.id != record.id and not r.archived]
|
|
747
|
+
recent.sort(key=lambda r: r.created_at, reverse=True)
|
|
748
|
+
sem_conflicts = ConflictDetector.check_semantic_batch(
|
|
749
|
+
new_record=record,
|
|
750
|
+
candidates=recent,
|
|
751
|
+
embedding_store=self._embedding_store,
|
|
752
|
+
threshold=threshold,
|
|
753
|
+
)
|
|
754
|
+
for sc in sem_conflicts:
|
|
755
|
+
self._store.store_conflict(sc)
|
|
756
|
+
except Exception:
|
|
757
|
+
pass
|
|
758
|
+
|
|
759
|
+
self.audit_log.append(
|
|
760
|
+
EVENT_MEMORY_WRITE,
|
|
761
|
+
{
|
|
762
|
+
"key": key,
|
|
763
|
+
"agent_id": agent_id,
|
|
764
|
+
"session_id": session_id,
|
|
765
|
+
"record_id": record.id,
|
|
766
|
+
},
|
|
767
|
+
)
|
|
768
|
+
return record
|
|
769
|
+
|
|
770
|
+
def read(self, key: str, agent_id: Optional[str] = None) -> Optional[MemoryRecord]:
|
|
771
|
+
"""Raw read by key, no permission check."""
|
|
772
|
+
return self._store.get_by_key(key)
|
|
773
|
+
|
|
774
|
+
def delete(self, key: str, agent_id: str, trust_zone: str) -> None:
|
|
775
|
+
"""Archive a record by key. Raises PermissionDenied if not allowed."""
|
|
776
|
+
if self._permissions is not None:
|
|
777
|
+
try:
|
|
778
|
+
self._permissions.check_write(key, agent_id, trust_zone)
|
|
779
|
+
except PermissionDenied as e:
|
|
780
|
+
self.audit_log.append(
|
|
781
|
+
EVENT_PERMISSION_DENIED,
|
|
782
|
+
{
|
|
783
|
+
"key": key,
|
|
784
|
+
"agent_id": agent_id,
|
|
785
|
+
"trust_zone": trust_zone,
|
|
786
|
+
"reason": str(e),
|
|
787
|
+
},
|
|
788
|
+
)
|
|
789
|
+
raise
|
|
790
|
+
|
|
791
|
+
self._store.delete_by_key(key)
|
|
792
|
+
self.audit_log.append(
|
|
793
|
+
EVENT_MEMORY_DELETE,
|
|
794
|
+
{"key": key, "agent_id": agent_id},
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
def retrieve(
|
|
798
|
+
self,
|
|
799
|
+
query: str,
|
|
800
|
+
agent_id: str,
|
|
801
|
+
limit: Optional[int] = None,
|
|
802
|
+
session_id: Optional[str] = None,
|
|
803
|
+
external_context: Optional[list[dict]] = None,
|
|
804
|
+
budget_tokens: Optional[int] = None,
|
|
805
|
+
working_dirs: Optional[list[str]] = None,
|
|
806
|
+
) -> RetrievalResult:
|
|
807
|
+
agent_config = self.config_for(agent_id)
|
|
808
|
+
isolate_by_agent = agent_config["isolate_by_agent"]
|
|
809
|
+
resolved_limit = limit if limit is not None else agent_config["retrieval"]["retrieve_limit"]
|
|
810
|
+
resolved_budget_tokens = budget_tokens if budget_tokens is not None else agent_config["injection"]["context_token_limit"]
|
|
811
|
+
|
|
812
|
+
# Query gate — skip embedding work for generic/short queries
|
|
813
|
+
_stop_phrases = frozenset(
|
|
814
|
+
self._raw_embeddings_cfg.get("generic_query_stop_phrases", [
|
|
815
|
+
"ok", "go ahead", "sounds good", "yes", "no", "sure",
|
|
816
|
+
"proceed", "continue", "let's review", "sounds great",
|
|
817
|
+
])
|
|
818
|
+
)
|
|
819
|
+
_min_tokens = int(self._raw_embeddings_cfg.get("min_query_tokens", 3))
|
|
820
|
+
_query_passes_gate = (
|
|
821
|
+
self._embedding_provider is not None
|
|
822
|
+
and len(query.split()) >= _min_tokens
|
|
823
|
+
and query.strip().lower() not in _stop_phrases
|
|
824
|
+
)
|
|
825
|
+
query_embedding: Optional[list[float]] = None
|
|
826
|
+
if _query_passes_gate:
|
|
827
|
+
try:
|
|
828
|
+
query_embedding = self._embedding_provider.embed(query)
|
|
829
|
+
except Exception:
|
|
830
|
+
query_embedding = None
|
|
831
|
+
|
|
832
|
+
candidates = self._store.search(
|
|
833
|
+
query, limit=max(resolved_limit, 50), agent_id=agent_id if isolate_by_agent else None,
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
# Scope episodic records to the current session. Episodic records are
|
|
837
|
+
# session-local by design; cross-session insights should be promoted to
|
|
838
|
+
# structured non-episodic keys. Non-episodic records pass through unfiltered.
|
|
839
|
+
_EPISODIC_PREFIX = "memory/episodic/"
|
|
840
|
+
if session_id is not None:
|
|
841
|
+
candidates = [
|
|
842
|
+
r for r in candidates
|
|
843
|
+
if _EPISODIC_PREFIX not in r.key or f"/{session_id}/" in r.key
|
|
844
|
+
]
|
|
845
|
+
|
|
846
|
+
# Embedding hydration + session topic filter
|
|
847
|
+
if _query_passes_gate and self._embedding_store is not None:
|
|
848
|
+
from ..embeddings.provider import _cosine
|
|
849
|
+
from ..embeddings.text import _embed_text_for_record
|
|
850
|
+
|
|
851
|
+
record_ids = [r.id for r in candidates]
|
|
852
|
+
stored_embeddings = self._embedding_store.get_batch("memories", record_ids)
|
|
853
|
+
|
|
854
|
+
# Lazy regen: embed records that have no stored vector
|
|
855
|
+
missing = [r for r in candidates if r.id not in stored_embeddings]
|
|
856
|
+
if missing:
|
|
857
|
+
try:
|
|
858
|
+
vecs = self._embedding_provider.embed_batch(
|
|
859
|
+
[_embed_text_for_record(r) for r in missing]
|
|
860
|
+
)
|
|
861
|
+
for rec, vec in zip(missing, vecs):
|
|
862
|
+
self._embedding_store.put(
|
|
863
|
+
"memories", rec.id, vec, working_dirs=rec.working_dirs
|
|
864
|
+
)
|
|
865
|
+
stored_embeddings[rec.id] = vec
|
|
866
|
+
except Exception:
|
|
867
|
+
pass
|
|
868
|
+
|
|
869
|
+
# Session topic filter
|
|
870
|
+
if session_id:
|
|
871
|
+
session_topic = self._embedding_store.get_session_topic(session_id)
|
|
872
|
+
if session_topic is not None:
|
|
873
|
+
_threshold = float(
|
|
874
|
+
self._raw_embeddings_cfg.get("session_topic_threshold", 0.35)
|
|
875
|
+
)
|
|
876
|
+
candidates = [
|
|
877
|
+
r for r in candidates
|
|
878
|
+
if r.id not in stored_embeddings # no embedding → always pass
|
|
879
|
+
or _cosine(stored_embeddings[r.id], session_topic) >= _threshold
|
|
880
|
+
]
|
|
881
|
+
|
|
882
|
+
# Attach embeddings as transient field before ranking
|
|
883
|
+
for r in candidates:
|
|
884
|
+
if r.id in stored_embeddings:
|
|
885
|
+
r.embedding = stored_embeddings[r.id]
|
|
886
|
+
|
|
887
|
+
ranked_all = self._ranker.rank(
|
|
888
|
+
query, candidates, agent_id=agent_id,
|
|
889
|
+
query_embedding=query_embedding,
|
|
890
|
+
session_working_dirs=working_dirs,
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
# Drop records that don't meet the minimum relevance threshold.
|
|
894
|
+
min_score = agent_config["retrieval"]["min_composite_score"]
|
|
895
|
+
if min_score > 0.0:
|
|
896
|
+
ranked_all = [rr for rr in ranked_all if rr.composite_score >= min_score]
|
|
897
|
+
|
|
898
|
+
ranked = ranked_all[:resolved_limit]
|
|
899
|
+
|
|
900
|
+
# Budget cap
|
|
901
|
+
selected: list[MemoryRecord] = []
|
|
902
|
+
token_total = 0
|
|
903
|
+
truncated = False
|
|
904
|
+
for rr in ranked:
|
|
905
|
+
record = rr.record
|
|
906
|
+
est = _estimate_tokens(record)
|
|
907
|
+
if resolved_budget_tokens is not None and token_total + est > resolved_budget_tokens:
|
|
908
|
+
truncated = True
|
|
909
|
+
break
|
|
910
|
+
selected.append(record)
|
|
911
|
+
token_total += est
|
|
912
|
+
records = selected
|
|
913
|
+
|
|
914
|
+
# Increment retrieval counts
|
|
915
|
+
for record in records:
|
|
916
|
+
self._store.increment_retrieval_count(record.id)
|
|
917
|
+
|
|
918
|
+
record_ids = [r.id for r in records]
|
|
919
|
+
signal_scores = {rr.record.id: rr.composite_score for rr in ranked}
|
|
920
|
+
|
|
921
|
+
# Track for outcome recording
|
|
922
|
+
if session_id is not None:
|
|
923
|
+
if session_id not in self._session_record_ids:
|
|
924
|
+
self._session_record_ids[session_id] = []
|
|
925
|
+
self._session_record_ids[session_id].extend(record_ids)
|
|
926
|
+
|
|
927
|
+
seq = self.audit_log.append(
|
|
928
|
+
EVENT_MEMORY_RETRIEVE,
|
|
929
|
+
{
|
|
930
|
+
"query": query,
|
|
931
|
+
"record_ids": record_ids,
|
|
932
|
+
"signal_scores": signal_scores,
|
|
933
|
+
"session_id": session_id,
|
|
934
|
+
"trigger_source": "query",
|
|
935
|
+
"reconciliation_run_id": None,
|
|
936
|
+
"isolate_by_agent": isolate_by_agent,
|
|
937
|
+
},
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
# Handle external context conflict detection
|
|
941
|
+
had_conflict = False
|
|
942
|
+
if external_context is not None:
|
|
943
|
+
# Check for overlap: any content word matches a record value
|
|
944
|
+
ext_words = set()
|
|
945
|
+
for ctx in external_context:
|
|
946
|
+
content = ctx.get("content", "")
|
|
947
|
+
ext_words.update(w.lower() for w in content.split())
|
|
948
|
+
|
|
949
|
+
overlap_records = []
|
|
950
|
+
for record in records:
|
|
951
|
+
value_str = (
|
|
952
|
+
json.dumps(record.value)
|
|
953
|
+
if isinstance(record.value, (dict, list))
|
|
954
|
+
else str(record.value)
|
|
955
|
+
).lower()
|
|
956
|
+
for word in ext_words:
|
|
957
|
+
if word in value_str:
|
|
958
|
+
overlap_records.append(record)
|
|
959
|
+
break
|
|
960
|
+
|
|
961
|
+
if overlap_records:
|
|
962
|
+
had_conflict = True
|
|
963
|
+
self.audit_log.append(
|
|
964
|
+
EVENT_EXTERNAL_CONFLICT_DETECTED,
|
|
965
|
+
{
|
|
966
|
+
"query": query,
|
|
967
|
+
"count": len(overlap_records),
|
|
968
|
+
"sources": [ctx.get("source", "") for ctx in external_context],
|
|
969
|
+
"session_id": session_id,
|
|
970
|
+
"trigger_source": "query",
|
|
971
|
+
"reconciliation_run_id": None,
|
|
972
|
+
},
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
# Record external retrieve stats
|
|
976
|
+
with self._store._connect() as conn:
|
|
977
|
+
conn.execute(
|
|
978
|
+
"INSERT INTO external_retrieves (session_id, had_external_context, had_conflict) VALUES (?, ?, ?)",
|
|
979
|
+
(session_id or "", 1, int(had_conflict)),
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
# Retrieve matching skills for this agent
|
|
983
|
+
all_skills = self.skills.get_for_agent(agent_id)
|
|
984
|
+
skill_warnings: list[str] = []
|
|
985
|
+
for skill in all_skills:
|
|
986
|
+
self.skills.mark_retrieved(skill.id)
|
|
987
|
+
if skill.health_score < 0.6 or "staleness" in skill.health_score_reasons:
|
|
988
|
+
skill_warnings.append(
|
|
989
|
+
f"Skill '{skill.id}' is stale (health_score={skill.health_score:.2f})"
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
# Fetch conflicts for returned records
|
|
993
|
+
conflicts = self._store.get_conflicts_for_records([r.id for r in records])
|
|
994
|
+
|
|
995
|
+
# Build override instructions for authority conflicts
|
|
996
|
+
override_instructions = []
|
|
997
|
+
for c in conflicts:
|
|
998
|
+
if c.conflict_type == "authority":
|
|
999
|
+
override_instructions.append(
|
|
1000
|
+
f"Authority conflict on a retrieved memory (conflict_id={c.id}): "
|
|
1001
|
+
"the human-approved version takes precedence over any agent-inferred value for this key."
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
return RetrievalResult(
|
|
1005
|
+
records=records,
|
|
1006
|
+
conflicts=conflicts,
|
|
1007
|
+
override_instructions=override_instructions,
|
|
1008
|
+
skills=all_skills,
|
|
1009
|
+
skill_warnings=skill_warnings,
|
|
1010
|
+
token_estimate=token_total,
|
|
1011
|
+
truncated=truncated,
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
def retrieve_by_entities(
|
|
1015
|
+
self,
|
|
1016
|
+
entities: list[str],
|
|
1017
|
+
agent_id: str,
|
|
1018
|
+
session_id: Optional[str] = None,
|
|
1019
|
+
) -> RetrievalResult:
|
|
1020
|
+
agent_config = self.config_for(agent_id)
|
|
1021
|
+
isolate_by_agent = agent_config["isolate_by_agent"]
|
|
1022
|
+
records = self._store.search_by_entities(
|
|
1023
|
+
entities, agent_id=agent_id if isolate_by_agent else None,
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
record_ids = [r.id for r in records]
|
|
1027
|
+
|
|
1028
|
+
# Track for outcome recording
|
|
1029
|
+
if session_id is not None:
|
|
1030
|
+
if session_id not in self._session_record_ids:
|
|
1031
|
+
self._session_record_ids[session_id] = []
|
|
1032
|
+
self._session_record_ids[session_id].extend(record_ids)
|
|
1033
|
+
|
|
1034
|
+
self.audit_log.append(
|
|
1035
|
+
EVENT_MEMORY_RETRIEVE,
|
|
1036
|
+
{
|
|
1037
|
+
"query": "",
|
|
1038
|
+
"record_ids": record_ids,
|
|
1039
|
+
"signal_scores": {r.id: 1.0 for r in records},
|
|
1040
|
+
"session_id": session_id,
|
|
1041
|
+
"trigger_source": "entity",
|
|
1042
|
+
"entities": entities,
|
|
1043
|
+
"reconciliation_run_id": None,
|
|
1044
|
+
"isolate_by_agent": isolate_by_agent,
|
|
1045
|
+
},
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
all_skills = self.skills.get_for_agent(agent_id)
|
|
1049
|
+
skill_warnings: list[str] = []
|
|
1050
|
+
for skill in all_skills:
|
|
1051
|
+
self.skills.mark_retrieved(skill.id)
|
|
1052
|
+
if skill.health_score < 0.6 or "staleness" in skill.health_score_reasons:
|
|
1053
|
+
skill_warnings.append(
|
|
1054
|
+
f"Skill '{skill.id}' is stale (health_score={skill.health_score:.2f})"
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
# Fetch conflicts for returned records
|
|
1058
|
+
conflicts = self._store.get_conflicts_for_records([r.id for r in records])
|
|
1059
|
+
|
|
1060
|
+
override_instructions = []
|
|
1061
|
+
for c in conflicts:
|
|
1062
|
+
if c.conflict_type == "authority":
|
|
1063
|
+
override_instructions.append(
|
|
1064
|
+
f"Authority conflict on a retrieved memory (conflict_id={c.id}): "
|
|
1065
|
+
"the human-approved version takes precedence over any agent-inferred value for this key."
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
return RetrievalResult(
|
|
1069
|
+
records=records,
|
|
1070
|
+
conflicts=conflicts,
|
|
1071
|
+
override_instructions=override_instructions,
|
|
1072
|
+
skills=all_skills,
|
|
1073
|
+
skill_warnings=skill_warnings,
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
def record_outcome(
|
|
1077
|
+
self,
|
|
1078
|
+
session_id: str,
|
|
1079
|
+
agent_id: str,
|
|
1080
|
+
outcome_type: str,
|
|
1081
|
+
outcome_value: float,
|
|
1082
|
+
) -> int:
|
|
1083
|
+
"""Backward-compat alias — delegates to db.lifecycle.record_outcome()."""
|
|
1084
|
+
adjusted_ids = self.lifecycle.record_outcome(
|
|
1085
|
+
session_id=session_id,
|
|
1086
|
+
agent_id=agent_id,
|
|
1087
|
+
outcome_type=outcome_type,
|
|
1088
|
+
outcome_value=outcome_value,
|
|
1089
|
+
)
|
|
1090
|
+
self.audit_log.append(
|
|
1091
|
+
EVENT_MEMORY_OUTCOME,
|
|
1092
|
+
{
|
|
1093
|
+
"session_id": session_id,
|
|
1094
|
+
"outcome_type": outcome_type,
|
|
1095
|
+
"outcome_value": float(outcome_value),
|
|
1096
|
+
"linked_retrieval_sequence": None,
|
|
1097
|
+
"adjusted_record_ids": adjusted_ids,
|
|
1098
|
+
},
|
|
1099
|
+
)
|
|
1100
|
+
return len(adjusted_ids)
|
|
1101
|
+
|
|
1102
|
+
def register_reconciliation_handler(self, trigger: str, handler: Callable) -> None:
|
|
1103
|
+
"""Backward-compat alias → db.gateway.subscribe(trigger, handler)."""
|
|
1104
|
+
self.gateway.subscribe(trigger, handler)
|
|
1105
|
+
|
|
1106
|
+
def dispatch_reconciliation_trigger(self, trigger: str, **kwargs) -> int:
|
|
1107
|
+
"""Backward-compat alias → db.gateway.dispatch(trigger, **kwargs)."""
|
|
1108
|
+
return self.gateway.dispatch(trigger, **kwargs)
|