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.
@@ -0,0 +1,226 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from datetime import datetime, timezone # noqa: F401 — timezone used in run_sweep
5
+
6
+ from agentdb.core.models import Origin
7
+ from agentdb.core.store import MemoryStore
8
+ from agentdb.core.system import SystemStats
9
+
10
+
11
+ def _utcnow() -> datetime:
12
+ return datetime.now(timezone.utc)
13
+
14
+
15
+ def _origin_value(origin) -> str:
16
+ """Return the string value of an origin, whether it's an Origin enum or a raw string."""
17
+ if isinstance(origin, Origin):
18
+ return origin.value
19
+ return str(origin)
20
+
21
+
22
+ class LifecycleManager:
23
+ CONFIDENCE_DECAY_RATE: float = 0.0 # off by default; set > 0 to enable
24
+ ARCHIVAL_THRESHOLD: float = 0.10 # archive if confidence falls below this
25
+ STALENESS_DAYS: int = 90 # archive if not retrieved for this many days
26
+ REINFORCE_RATE: float = 0.10 # confidence boost per reinforce call
27
+
28
+ def __init__(
29
+ self,
30
+ store: MemoryStore,
31
+ system: SystemStats,
32
+ session_record_ids: dict | None = None,
33
+ embedding_store=None,
34
+ skill_store=None,
35
+ confidence_decay_rate: float | None = None,
36
+ archival_threshold: float | None = None,
37
+ staleness_days: int | None = None,
38
+ reinforce_rate: float | None = None,
39
+ ) -> None:
40
+ self._store = store
41
+ self._system = system
42
+ self._session_record_ids: dict[str, list[str]] = (
43
+ session_record_ids if session_record_ids is not None else {}
44
+ )
45
+ self._embedding_store = embedding_store
46
+ self._skill_store = skill_store
47
+ cls = type(self)
48
+ self.CONFIDENCE_DECAY_RATE = confidence_decay_rate if confidence_decay_rate is not None else cls.CONFIDENCE_DECAY_RATE
49
+ self.ARCHIVAL_THRESHOLD = archival_threshold if archival_threshold is not None else cls.ARCHIVAL_THRESHOLD
50
+ self.STALENESS_DAYS = staleness_days if staleness_days is not None else cls.STALENESS_DAYS
51
+ self.REINFORCE_RATE = reinforce_rate if reinforce_rate is not None else cls.REINFORCE_RATE
52
+
53
+ # ------------------------------------------------------------------
54
+ # Sweep
55
+ # ------------------------------------------------------------------
56
+
57
+ def run_sweep(self) -> dict:
58
+ """
59
+ On-demand lifecycle sweep.
60
+
61
+ 1. If CONFIDENCE_DECAY_RATE > 0: apply decay to agent_inferred +
62
+ provider_ingested records (human_approved is immune).
63
+ 2. Archive records whose confidence is below ARCHIVAL_THRESHOLD.
64
+ 3. Archive records not retrieved for more than STALENESS_DAYS.
65
+
66
+ Returns {"decayed": N, "archived": N}.
67
+ human_approved records are immune to all lifecycle operations.
68
+ """
69
+ now = _utcnow()
70
+ decayed = 0
71
+ archived_ids: set[str] = set()
72
+
73
+ records = self._store.list_all()
74
+
75
+ # --- Step 0: archive expired records (expiry_time has passed) ---
76
+ for record in records:
77
+ if _origin_value(record.origin) == Origin.HUMAN_APPROVED.value:
78
+ continue # human_approved records never expire
79
+ if record.expiry_time is not None:
80
+ expiry = record.expiry_time
81
+ if expiry.tzinfo is None:
82
+ expiry = expiry.replace(tzinfo=timezone.utc)
83
+ if expiry <= now:
84
+ self._store.archive_record(record.id)
85
+ archived_ids.add(record.id)
86
+
87
+ records = self._store.list_all()
88
+
89
+ # --- Step 1: optional decay ---
90
+ if self.CONFIDENCE_DECAY_RATE > 0:
91
+ for record in records:
92
+ origin = _origin_value(record.origin)
93
+ if origin == Origin.HUMAN_APPROVED.value:
94
+ continue
95
+ new_conf = max(0.0, record.confidence - self.CONFIDENCE_DECAY_RATE)
96
+ if new_conf != record.confidence:
97
+ self._store.update_confidence(record.id, new_conf)
98
+ decayed += 1
99
+
100
+ # Re-read after potential mutations so archival thresholds are current
101
+ records = self._store.list_all()
102
+
103
+ # --- Step 2: archive below threshold ---
104
+ for record in records:
105
+ if record.id in archived_ids:
106
+ continue
107
+ origin = _origin_value(record.origin)
108
+ if origin == Origin.HUMAN_APPROVED.value:
109
+ continue
110
+ if record.confidence < self.ARCHIVAL_THRESHOLD:
111
+ self._store.archive_record(record.id)
112
+ archived_ids.add(record.id)
113
+
114
+ # --- Step 3: archive stale records ---
115
+ records = self._store.list_all()
116
+ for record in records:
117
+ if record.id in archived_ids:
118
+ continue
119
+ origin = _origin_value(record.origin)
120
+ if origin == Origin.HUMAN_APPROVED.value:
121
+ continue
122
+
123
+ # Use last_retrieved if available, else created_at
124
+ reference_time = record.last_retrieved or record.created_at
125
+ # Ensure timezone-aware for comparison
126
+ if reference_time.tzinfo is None:
127
+ reference_time = reference_time.replace(tzinfo=timezone.utc)
128
+ days_since = (now - reference_time).days
129
+ if days_since >= self.STALENESS_DAYS:
130
+ self._store.archive_record(record.id)
131
+ archived_ids.add(record.id)
132
+
133
+ # Embedding orphan cleanup
134
+ if self._embedding_store is not None:
135
+ active_memory_ids = [r.id for r in self._store.list_all()]
136
+ self._embedding_store.prune_orphans("memories", active_memory_ids)
137
+
138
+ if self._skill_store is not None:
139
+ active_skill_ids = self._skill_store.list_all_active_ids()
140
+ self._embedding_store.prune_orphans("skills", active_skill_ids)
141
+
142
+ return {"decayed": decayed, "archived": len(archived_ids)}
143
+
144
+ # ------------------------------------------------------------------
145
+ # Reinforce
146
+ # ------------------------------------------------------------------
147
+
148
+ def reinforce(self, record_id: str) -> None:
149
+ """Boost confidence toward 1.0 and reset last_retrieved."""
150
+ record = self._store.get_by_id(record_id)
151
+ if record is None or record.archived:
152
+ return
153
+ if _origin_value(record.origin) == Origin.HUMAN_APPROVED.value:
154
+ return # immune
155
+
156
+ new_conf = min(1.0, record.confidence + self.REINFORCE_RATE)
157
+ self._store.update_confidence(record_id, new_conf)
158
+ self._store.increment_retrieval_count(record_id)
159
+
160
+ # ------------------------------------------------------------------
161
+ # Record outcome (core logic, moved from AgentDB)
162
+ # ------------------------------------------------------------------
163
+
164
+ def record_outcome(
165
+ self,
166
+ session_id: str,
167
+ agent_id: str,
168
+ outcome_type: str,
169
+ outcome_value: float,
170
+ ) -> list[str]:
171
+ """
172
+ Adjust confidence for records retrieved in this session.
173
+
174
+ Rules by origin:
175
+ - agent_inferred: positive (+0.05 * outcome_value, cap +0.1),
176
+ negative (-0.02 * (1 - outcome_value), cap -0.1)
177
+ - provider_ingested: positive (no change), negative (same as above)
178
+ - human_approved: never adjusted
179
+
180
+ Clears the session record IDs after adjusting.
181
+ Returns list of adjusted record IDs (for audit log use by the caller).
182
+ """
183
+ seen_ids = list(dict.fromkeys(self._session_record_ids.get(session_id, [])))
184
+ is_positive = outcome_value >= 0.5
185
+ adjusted_ids: list[str] = []
186
+
187
+ for record_id in seen_ids:
188
+ record = self._store.get_by_id(record_id)
189
+ if record is None:
190
+ continue
191
+
192
+ origin = _origin_value(record.origin)
193
+ if origin == Origin.HUMAN_APPROVED.value:
194
+ continue
195
+
196
+ if origin == Origin.AGENT_INFERRED.value:
197
+ if is_positive:
198
+ delta = min(0.05 * outcome_value, 0.1)
199
+ new_conf = min(1.0, record.confidence + delta)
200
+ else:
201
+ delta = min(0.02 * (1 - outcome_value), 0.1)
202
+ new_conf = max(0.0, record.confidence - delta)
203
+ self._store.update_confidence(record_id, new_conf)
204
+ adjusted_ids.append(record_id)
205
+ elif origin == Origin.PROVIDER_INGESTED.value:
206
+ if not is_positive:
207
+ delta = min(0.02 * (1 - outcome_value), 0.1)
208
+ new_conf = max(0.0, record.confidence - delta)
209
+ self._store.update_confidence(record_id, new_conf)
210
+ adjusted_ids.append(record_id)
211
+
212
+ # Clear session record IDs so the next retrieve starts fresh
213
+ if session_id in self._session_record_ids:
214
+ self._session_record_ids[session_id] = []
215
+
216
+ # Persist outcome row
217
+ now = _utcnow().isoformat()
218
+ with self._store._connect() as conn:
219
+ conn.execute(
220
+ "INSERT INTO outcomes "
221
+ "(id, session_id, agent_id, outcome_type, outcome_value, linked_retrieval_sequence, created_at) "
222
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
223
+ (str(uuid.uuid4()), session_id, agent_id, outcome_type, outcome_value, None, now),
224
+ )
225
+
226
+ return adjusted_ids
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+
10
+ class PermissionDenied(Exception):
11
+ pass
12
+
13
+
14
+ # Prefixes owned by AgentDB's own framework machinery (not agent business
15
+ # data) — writable by default regardless of default_policy/can_write,
16
+ # because gating them requires every integrating deployment to remember a
17
+ # permissions.yaml edit before the feature works at all. An explicit
18
+ # cannot_write entry still overrides this (see check_write below) — this is
19
+ # a default, not an unconditional bypass.
20
+ FRAMEWORK_MANAGED_WRITE_PREFIXES: tuple[str, ...] = ("proposal/",)
21
+
22
+
23
+ class PermissionRouter:
24
+ """
25
+ Routes permission checks based on a YAML policy file.
26
+
27
+ YAML format:
28
+ default_policy: deny
29
+ human_zone_prefixes:
30
+ - "skills/"
31
+ agents:
32
+ agent_id:
33
+ can_write:
34
+ - "memory/working/"
35
+ cannot_write:
36
+ - "skills/"
37
+ can_read:
38
+ - "memory/working/"
39
+ """
40
+
41
+ def __init__(self, permissions_path: Any) -> None:
42
+ path = Path(permissions_path)
43
+ with path.open("r", encoding="utf-8") as f:
44
+ self._config: dict = yaml.safe_load(f) or {}
45
+
46
+ self._default_policy: str = self._config.get("default_policy", "deny")
47
+ self._human_zone_prefixes: list[str] = self._config.get("human_zone_prefixes", [])
48
+ self._agents: dict = self._config.get("agents", {})
49
+
50
+ def _matches_any(self, key: str, patterns: list[str]) -> bool:
51
+ for pattern in patterns:
52
+ # Treat patterns ending with '/' as prefix matches,
53
+ # also support glob patterns
54
+ if pattern.endswith("/"):
55
+ if key.startswith(pattern):
56
+ return True
57
+ elif fnmatch.fnmatch(key, pattern + "*") or fnmatch.fnmatch(key, pattern):
58
+ return True
59
+ return False
60
+
61
+ def is_human_zone(self, key: str) -> bool:
62
+ for prefix in self._human_zone_prefixes:
63
+ if key.startswith(prefix):
64
+ return True
65
+ return False
66
+
67
+ def check_write(self, key: str, agent_id: str, trust_zone: str) -> None:
68
+ """
69
+ Raises PermissionDenied if the agent is not allowed to write the key.
70
+
71
+ Rules:
72
+ - If trust_zone == "agent_inferred" and key is in a human_zone_prefix: deny
73
+ - Check cannot_write before can_write
74
+ - default_policy: deny means deny unless explicitly allowed
75
+ """
76
+ # Agent-inferred writes to human zones are always denied
77
+ if trust_zone == "agent_inferred" and self.is_human_zone(key):
78
+ raise PermissionDenied(
79
+ f"Agent '{agent_id}' cannot write to human zone key '{key}' "
80
+ f"with trust_zone '{trust_zone}'"
81
+ )
82
+
83
+ agent_config = self._agents.get(agent_id, {})
84
+
85
+ # Check cannot_write first
86
+ cannot_write = agent_config.get("cannot_write", [])
87
+ if self._matches_any(key, cannot_write):
88
+ raise PermissionDenied(
89
+ f"Agent '{agent_id}' is explicitly denied write access to '{key}'"
90
+ )
91
+
92
+ # Check can_write
93
+ can_write = agent_config.get("can_write", [])
94
+ if self._matches_any(key, can_write):
95
+ return # allowed
96
+
97
+ # Framework-managed prefixes are writable by default (cannot_write,
98
+ # checked above, still overrides this).
99
+ if self._matches_any(key, FRAMEWORK_MANAGED_WRITE_PREFIXES):
100
+ return # allowed
101
+
102
+ # Fall through to default policy
103
+ if self._default_policy == "deny":
104
+ raise PermissionDenied(
105
+ f"Agent '{agent_id}' has no explicit write permission for '{key}' "
106
+ f"and default_policy is 'deny'"
107
+ )
108
+
109
+ def unconfigured_agents(self, agent_ids: list[str]) -> list[str]:
110
+ """Return the subset of agent_ids that have no entry in this config.
111
+
112
+ Useful for pre-flight checks at startup: call with all known bot
113
+ identities and warn/fail if any are returned.
114
+ """
115
+ return [a for a in agent_ids if a not in self._agents]
116
+
117
+ def check_read(self, key: str, agent_id: str) -> None:
118
+ """
119
+ Raises PermissionDenied if the agent is not allowed to read the key.
120
+ """
121
+ agent_config = self._agents.get(agent_id, {})
122
+
123
+ can_read = agent_config.get("can_read", [])
124
+ if self._matches_any(key, can_read):
125
+ return # allowed
126
+
127
+ if self._default_policy == "deny":
128
+ raise PermissionDenied(
129
+ f"Agent '{agent_id}' has no explicit read permission for '{key}' "
130
+ f"and default_policy is 'deny'"
131
+ )
@@ -0,0 +1,25 @@
1
+ from .client import (
2
+ AgentDB,
3
+ AuditLog,
4
+ AuditEntry,
5
+ RetrievalResult,
6
+ SystemStats,
7
+ SkillsAPI,
8
+ EVENT_MEMORY_WRITE,
9
+ EVENT_MEMORY_RETRIEVE,
10
+ EVENT_MEMORY_OUTCOME,
11
+ EVENT_EXTERNAL_CONFLICT_DETECTED,
12
+ )
13
+
14
+ __all__ = [
15
+ "AgentDB",
16
+ "AuditLog",
17
+ "AuditEntry",
18
+ "RetrievalResult",
19
+ "SystemStats",
20
+ "SkillsAPI",
21
+ "EVENT_MEMORY_WRITE",
22
+ "EVENT_MEMORY_RETRIEVE",
23
+ "EVENT_MEMORY_OUTCOME",
24
+ "EVENT_EXTERNAL_CONFLICT_DETECTED",
25
+ ]