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,199 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import sqlite3
6
+ import struct
7
+ from contextlib import contextmanager
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ logger = logging.getLogger("agentdb")
12
+
13
+ _CREATE_METADATA = """
14
+ CREATE TABLE IF NOT EXISTS metadata (
15
+ key TEXT PRIMARY KEY,
16
+ value TEXT NOT NULL
17
+ )
18
+ """
19
+
20
+ _CREATE_EMBEDDINGS = """
21
+ CREATE TABLE IF NOT EXISTS embeddings (
22
+ table_name TEXT NOT NULL,
23
+ record_id TEXT NOT NULL,
24
+ embedding BLOB NOT NULL,
25
+ working_dirs TEXT NOT NULL DEFAULT '[]',
26
+ created_at TEXT NOT NULL,
27
+ PRIMARY KEY (table_name, record_id)
28
+ )
29
+ """
30
+
31
+ _CREATE_SESSION_TOPICS = """
32
+ CREATE TABLE IF NOT EXISTS session_topics (
33
+ session_id TEXT PRIMARY KEY,
34
+ embedding BLOB NOT NULL,
35
+ created_at TEXT NOT NULL
36
+ )
37
+ """
38
+
39
+
40
+ def _pack(vec: list[float]) -> bytes:
41
+ return struct.pack(f"{len(vec)}f", *vec)
42
+
43
+
44
+ def _unpack(blob: bytes) -> list[float]:
45
+ n = len(blob) // 4
46
+ return list(struct.unpack(f"{n}f", blob))
47
+
48
+
49
+ def _utcnow() -> str:
50
+ return datetime.now(timezone.utc).isoformat()
51
+
52
+
53
+ class EmbeddingStore:
54
+ def __init__(self, db_path, model_name: str, dimensions: int) -> None:
55
+ self._db_path = Path(db_path)
56
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
57
+ self._model_name = model_name
58
+ self._dimensions = dimensions
59
+ with self._connect() as conn:
60
+ conn.execute(_CREATE_METADATA)
61
+ conn.execute(_CREATE_EMBEDDINGS)
62
+ conn.execute(_CREATE_SESSION_TOPICS)
63
+ self._validate_or_reset()
64
+
65
+ @contextmanager
66
+ def _connect(self):
67
+ conn = sqlite3.connect(str(self._db_path))
68
+ conn.row_factory = sqlite3.Row
69
+ conn.execute("PRAGMA journal_mode=WAL")
70
+ try:
71
+ yield conn
72
+ conn.commit()
73
+ except Exception:
74
+ conn.rollback()
75
+ raise
76
+ finally:
77
+ conn.close()
78
+
79
+ def _validate_or_reset(self) -> None:
80
+ with self._connect() as conn:
81
+ rows = conn.execute(
82
+ "SELECT key, value FROM metadata WHERE key IN ('model_name', 'dimensions')"
83
+ ).fetchall()
84
+ meta = {r["key"]: r["value"] for r in rows}
85
+
86
+ if not meta:
87
+ conn.execute(
88
+ "INSERT OR REPLACE INTO metadata (key, value) VALUES ('model_name', ?)",
89
+ (self._model_name,),
90
+ )
91
+ conn.execute(
92
+ "INSERT OR REPLACE INTO metadata (key, value) VALUES ('dimensions', ?)",
93
+ (str(self._dimensions),),
94
+ )
95
+ return
96
+
97
+ stored_name = meta.get("model_name")
98
+ stored_dims = int(meta.get("dimensions", "0"))
99
+ name_changed = stored_name != self._model_name
100
+ # Only treat dimensions as changed when both sides are known (non-zero)
101
+ dims_changed = (
102
+ self._dimensions != 0
103
+ and stored_dims != 0
104
+ and stored_dims != self._dimensions
105
+ )
106
+
107
+ if name_changed or dims_changed:
108
+ logger.warning(
109
+ "agentdb: embedding config changed (model '%s'→'%s', dims %d→%d) — "
110
+ "wiping all stored embeddings; they will regenerate lazily.",
111
+ stored_name,
112
+ self._model_name,
113
+ stored_dims,
114
+ self._dimensions,
115
+ )
116
+ conn.execute("DELETE FROM embeddings")
117
+ conn.execute("DELETE FROM session_topics")
118
+ conn.execute(
119
+ "INSERT OR REPLACE INTO metadata (key, value) VALUES ('model_name', ?)",
120
+ (self._model_name,),
121
+ )
122
+ conn.execute(
123
+ "INSERT OR REPLACE INTO metadata (key, value) VALUES ('dimensions', ?)",
124
+ (str(self._dimensions),),
125
+ )
126
+
127
+ def put(
128
+ self,
129
+ table: str,
130
+ record_id: str,
131
+ embedding: list[float],
132
+ working_dirs: list[str],
133
+ ) -> None:
134
+ with self._connect() as conn:
135
+ conn.execute(
136
+ """
137
+ INSERT OR REPLACE INTO embeddings
138
+ (table_name, record_id, embedding, working_dirs, created_at)
139
+ VALUES (?, ?, ?, ?, ?)
140
+ """,
141
+ (table, record_id, _pack(embedding), json.dumps(working_dirs), _utcnow()),
142
+ )
143
+
144
+ def get(self, table: str, record_id: str) -> list[float] | None:
145
+ with self._connect() as conn:
146
+ row = conn.execute(
147
+ "SELECT embedding FROM embeddings WHERE table_name = ? AND record_id = ?",
148
+ (table, record_id),
149
+ ).fetchone()
150
+ return _unpack(row["embedding"]) if row else None
151
+
152
+ def get_batch(self, table: str, record_ids: list[str]) -> dict[str, list[float]]:
153
+ if not record_ids:
154
+ return {}
155
+ placeholders = ",".join("?" * len(record_ids))
156
+ with self._connect() as conn:
157
+ rows = conn.execute(
158
+ f"SELECT record_id, embedding FROM embeddings "
159
+ f"WHERE table_name = ? AND record_id IN ({placeholders})",
160
+ [table] + list(record_ids),
161
+ ).fetchall()
162
+ return {row["record_id"]: _unpack(row["embedding"]) for row in rows}
163
+
164
+ def delete(self, table: str, record_id: str) -> None:
165
+ with self._connect() as conn:
166
+ conn.execute(
167
+ "DELETE FROM embeddings WHERE table_name = ? AND record_id = ?",
168
+ (table, record_id),
169
+ )
170
+
171
+ def prune_orphans(self, table: str, valid_ids: list[str]) -> int:
172
+ if not valid_ids:
173
+ return 0
174
+ placeholders = ",".join("?" * len(valid_ids))
175
+ with self._connect() as conn:
176
+ cursor = conn.execute(
177
+ f"DELETE FROM embeddings WHERE table_name = ? "
178
+ f"AND record_id NOT IN ({placeholders})",
179
+ [table] + list(valid_ids),
180
+ )
181
+ return cursor.rowcount
182
+
183
+ def put_session_topic(self, session_id: str, embedding: list[float]) -> None:
184
+ with self._connect() as conn:
185
+ conn.execute(
186
+ """
187
+ INSERT OR REPLACE INTO session_topics (session_id, embedding, created_at)
188
+ VALUES (?, ?, ?)
189
+ """,
190
+ (session_id, _pack(embedding), _utcnow()),
191
+ )
192
+
193
+ def get_session_topic(self, session_id: str) -> list[float] | None:
194
+ with self._connect() as conn:
195
+ row = conn.execute(
196
+ "SELECT embedding FROM session_topics WHERE session_id = ?",
197
+ (session_id,),
198
+ ).fetchone()
199
+ return _unpack(row["embedding"]) if row else None
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from ..core.models import MemoryRecord, Skill
6
+
7
+
8
+ def _embed_text_for_record(r: MemoryRecord) -> str:
9
+ """Build the text representation of a memory record for embedding."""
10
+ key_readable = r.key.replace("/", " ").strip()
11
+ if isinstance(r.value, (dict, list)):
12
+ value_str = json.dumps(r.value)[:2000]
13
+ else:
14
+ value_str = str(r.value)[:2000]
15
+ return f"{key_readable}: {value_str}"
16
+
17
+
18
+ def _embed_text_for_skill(skill: Skill) -> str:
19
+ """Build the text representation of a skill for embedding."""
20
+ return f"{skill.name}: {skill.content}"[:2000]
@@ -0,0 +1,189 @@
1
+ """
2
+ db.gateway — governed event dispatch, conflict queue, and proposal workflow.
3
+
4
+ Gateway is the single surface for:
5
+ - Reconciliation event subscribe/dispatch (previously scattered on AgentDB)
6
+ - Conflict queue (read-only view into conflict/ memory records)
7
+ - Proposal workflow: propose → approve/reject → list_proposals
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import uuid
12
+ from typing import TYPE_CHECKING, Callable, Optional
13
+
14
+ if TYPE_CHECKING:
15
+ from ..interface.client import AgentDB
16
+
17
+
18
+ class Gateway:
19
+ """
20
+ Unified governance surface attached to an AgentDB instance as `db.gateway`.
21
+
22
+ No new tables are created. Proposals are stored as agent_inferred memory
23
+ records with key prefix ``proposal/<proposal_id>``. Conflicts are memory
24
+ records with key prefix ``conflict/`` (written by a future conflict-detection
25
+ task; this surface exposes the read-only view).
26
+ """
27
+
28
+ def __init__(self, db: "AgentDB") -> None:
29
+ self._db = db
30
+ # Reconciliation handlers: trigger -> [callables]
31
+ self._handlers: dict[str, list[Callable]] = {}
32
+
33
+ # ------------------------------------------------------------------
34
+ # Reconciliation
35
+ # ------------------------------------------------------------------
36
+
37
+ def subscribe(self, trigger: str, handler: Callable) -> None:
38
+ """Register a handler for a named reconciliation trigger."""
39
+ self._handlers.setdefault(trigger, []).append(handler)
40
+
41
+ def dispatch(self, trigger: str, **kwargs) -> int:
42
+ """Fire all handlers for *trigger*. Returns the handler count."""
43
+ handlers = self._handlers.get(trigger, [])
44
+ for handler in handlers:
45
+ handler(**kwargs)
46
+ return len(handlers)
47
+
48
+ # ------------------------------------------------------------------
49
+ # Conflict queue
50
+ # ------------------------------------------------------------------
51
+
52
+ def conflict_queue(self, resolved: bool = False) -> list:
53
+ """
54
+ Return conflict records from the conflicts table.
55
+
56
+ Args:
57
+ resolved: if True, return resolved conflicts; defaults to False (pending only).
58
+ """
59
+ return self._db._store.list_conflicts(resolved=resolved)
60
+
61
+ # ------------------------------------------------------------------
62
+ # Proposal workflow
63
+ # ------------------------------------------------------------------
64
+
65
+ def propose(
66
+ self,
67
+ name: str,
68
+ content: str,
69
+ scope: list[str],
70
+ agent_id: str,
71
+ entities: Optional[list[str]] = None,
72
+ session_id: Optional[str] = None,
73
+ ) -> str:
74
+ """
75
+ Draft a skill proposal.
76
+
77
+ Writes the draft as an agent_inferred memory record under
78
+ ``proposal/<proposal_id>`` and returns the proposal_id.
79
+ """
80
+ proposal_id = str(uuid.uuid4())
81
+ self._db.write(
82
+ key=f"proposal/{proposal_id}",
83
+ value={
84
+ "proposal_id": proposal_id,
85
+ "name": name,
86
+ "content": content,
87
+ "scope": scope,
88
+ "agent_id": agent_id,
89
+ "status": "pending",
90
+ },
91
+ origin="agent_inferred",
92
+ agent_id=agent_id,
93
+ session_id=session_id,
94
+ entities=entities or [],
95
+ )
96
+ self._db.audit_log.append(
97
+ "proposal_created",
98
+ {"proposal_id": proposal_id, "name": name, "agent_id": agent_id},
99
+ )
100
+ return proposal_id
101
+
102
+ def approve(self, proposal_id: str, resolution: Optional[str] = None) -> None:
103
+ """
104
+ Approve a pending proposal.
105
+
106
+ Promotes the draft to a skill via skills_api.create_skill and archives
107
+ the proposal memory record.
108
+ """
109
+ record = self._db.read(f"proposal/{proposal_id}")
110
+ if record is None:
111
+ raise KeyError(f"Proposal not found: {proposal_id}")
112
+
113
+ payload = record.value
114
+ self._db.skills_api.create_skill(
115
+ skill_id=proposal_id,
116
+ name=payload["name"],
117
+ content=payload["content"],
118
+ scope=payload["scope"],
119
+ )
120
+
121
+ # Archive the proposal record now that the skill has been created
122
+ self._db._store.delete_by_key(f"proposal/{proposal_id}")
123
+
124
+ self._db.audit_log.append(
125
+ "proposal_approved",
126
+ {
127
+ "proposal_id": proposal_id,
128
+ "resolution": resolution,
129
+ "agent_id": payload.get("agent_id"),
130
+ },
131
+ )
132
+
133
+ def reject(self, proposal_id: str, reason: Optional[str] = None) -> None:
134
+ """
135
+ Reject a pending proposal.
136
+
137
+ Archives the proposal memory record without touching skills_api.
138
+ """
139
+ record = self._db.read(f"proposal/{proposal_id}")
140
+ if record is None:
141
+ raise KeyError(f"Proposal not found: {proposal_id}")
142
+
143
+ payload = record.value
144
+ self._db._store.delete_by_key(f"proposal/{proposal_id}")
145
+
146
+ self._db.audit_log.append(
147
+ "proposal_rejected",
148
+ {
149
+ "proposal_id": proposal_id,
150
+ "reason": reason,
151
+ "agent_id": payload.get("agent_id"),
152
+ },
153
+ )
154
+
155
+ def list_proposals(self, status: str = "pending") -> list[dict]:
156
+ """
157
+ Return proposals filtered by status.
158
+
159
+ Status is encoded in the record value. Approved and rejected proposals
160
+ have their memory records archived; we use the audit log for those.
161
+
162
+ For simplicity:
163
+ - ``pending`` — active (non-archived) memory records with key prefix proposal/
164
+ - ``approved`` — read from audit_log events of type proposal_approved
165
+ - ``rejected`` — read from audit_log events of type proposal_rejected
166
+ """
167
+ if status == "pending":
168
+ results: list[dict] = []
169
+ for record in self._db._store._by_id.values():
170
+ if record.key.startswith("proposal/") and not record.archived:
171
+ results.append(dict(record.value))
172
+ return results
173
+
174
+ event_type = f"proposal_{status}" # proposal_approved / proposal_rejected
175
+ entries = self._db.audit_log.replay()
176
+ return [
177
+ {
178
+ "proposal_id": e.payload["proposal_id"],
179
+ "agent_id": e.payload.get("agent_id"),
180
+ "status": status,
181
+ }
182
+ for e in entries
183
+ if e.event_type == event_type
184
+ ]
185
+
186
+
187
+ from .adapter import GatewayAdapter # noqa: E402 — keep at bottom to avoid circular import
188
+
189
+ __all__ = ["Gateway", "GatewayAdapter"]
@@ -0,0 +1,58 @@
1
+ """
2
+ GatewayAdapter — base class for all AgentDB runtime adapters.
3
+
4
+ Subclasses (e.g. AgentDBClaudeMiddleware) inherit a direct reference to the
5
+ db.gateway surface so they can subscribe to triggers, dispatch events, and
6
+ interact with the proposal workflow without importing Gateway directly.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from ..interface.client import AgentDB
14
+ from . import Gateway
15
+
16
+
17
+ class GatewayAdapter:
18
+ """
19
+ Base class that wires an adapter to db.gateway.
20
+
21
+ Subclasses should call super().__init__(db, agent_id, profile) and may
22
+ override prepare_turn, pre_tool_use, and finalize_turn.
23
+
24
+ ``self.profile_name`` holds the raw profile string passed at construction.
25
+ Subclasses that need the fully resolved per-agent config should call
26
+ ``db.config_for(agent_id)`` themselves and store it (e.g. AgentDBClaudeMiddleware's
27
+ ``self._resolved``) rather than reconstructing profile data by hand.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ db: "AgentDB",
33
+ agent_id: str,
34
+ profile: str = "budget_optimized",
35
+ ) -> None:
36
+ self._db = db
37
+ self._agent_id = agent_id
38
+ self.profile_name: str = profile
39
+ self.gateway: "Gateway" = db.gateway
40
+
41
+ @property
42
+ def agent_id(self) -> str:
43
+ """Public accessor for the agent_id this adapter was constructed with.
44
+
45
+ Downstream apps need this to scope their own gateway calls (e.g. the
46
+ scope= argument on gateway.propose()) without reaching into the
47
+ underscore-prefixed private attribute.
48
+ """
49
+ return self._agent_id
50
+
51
+ def prepare_turn(self, user_query: str, session_id: str) -> None: # pragma: no cover
52
+ pass
53
+
54
+ def pre_tool_use(self, tool_name: str, tool_input: dict, session_id: str) -> None: # pragma: no cover
55
+ pass
56
+
57
+ def finalize_turn(self, session_id: str, **kwargs) -> None: # pragma: no cover
58
+ pass
@@ -0,0 +1,3 @@
1
+ from .permission_router import PermissionRouter, PermissionDenied
2
+
3
+ __all__ = ["PermissionRouter", "PermissionDenied"]
@@ -0,0 +1,103 @@
1
+ """
2
+ Phase 1 conflict detector — temporal and authority conflicts only.
3
+
4
+ Temporal: same key + same origin → two records with equal authority, neither
5
+ supersedes the other.
6
+ Authority: agent_inferred / provider_ingested tries to overwrite human_approved
7
+ → the agent has less authority than the existing human-approved value.
8
+
9
+ Human-approved overwriting anything lower is NOT a conflict — it supersedes cleanly.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from agentdb.core.models import ConflictRecord, MemoryRecord
14
+
15
+ # Origins that are considered "agent-tier" (lower authority than human_approved)
16
+ _AGENT_TIER_ORIGINS = {"agent_inferred", "provider_ingested"}
17
+
18
+
19
+ class ConflictDetector:
20
+ """Checks whether a new write conflicts with an existing record."""
21
+
22
+ def check(
23
+ self,
24
+ new_record: MemoryRecord,
25
+ existing: MemoryRecord,
26
+ ) -> ConflictRecord | None:
27
+ """
28
+ Returns a ConflictRecord if new_record conflicts with existing, else None.
29
+
30
+ Rules:
31
+ - Different keys → no conflict (caller should not invoke in this case).
32
+ - Same key + same origin → temporal conflict.
33
+ - New is agent_inferred/provider_ingested AND existing is human_approved
34
+ → authority conflict.
35
+ - New is human_approved, existing is anything lower → no conflict (supersedes).
36
+ """
37
+ if new_record.key != existing.key:
38
+ return None
39
+
40
+ new_origin = str(new_record.origin)
41
+ existing_origin = str(existing.origin)
42
+
43
+ # Authority conflict: agent/provider overwriting human_approved
44
+ if existing_origin == "human_approved" and new_origin in _AGENT_TIER_ORIGINS:
45
+ return ConflictRecord(
46
+ record_a_id=existing.id,
47
+ record_b_id=new_record.id,
48
+ conflict_type="authority",
49
+ )
50
+
51
+ # Human supersedes lower-trust: no conflict (only when existing is NOT also human_approved)
52
+ if new_origin == "human_approved" and existing_origin in _AGENT_TIER_ORIGINS:
53
+ return None
54
+
55
+ # Same origin → temporal conflict (covers human_approved vs human_approved too)
56
+ if new_origin == existing_origin:
57
+ return ConflictRecord(
58
+ record_a_id=existing.id,
59
+ record_b_id=new_record.id,
60
+ conflict_type="temporal",
61
+ )
62
+
63
+ return None
64
+
65
+ @staticmethod
66
+ def check_semantic_batch(
67
+ new_record: "MemoryRecord",
68
+ candidates: list,
69
+ embedding_store,
70
+ threshold: float = 0.92,
71
+ ) -> list:
72
+ """
73
+ Returns ConflictRecords for any candidate with cosine(new, candidate) >= threshold.
74
+ Caps at 50 candidates. Skips if embedding_store is None or new_record has no embedding.
75
+ """
76
+ if embedding_store is None:
77
+ return []
78
+
79
+ new_vec = embedding_store.get("memories", new_record.id)
80
+ if new_vec is None:
81
+ return []
82
+
83
+ from ..embeddings.provider import _cosine
84
+
85
+ capped = [c for c in candidates if c.id != new_record.id][:50]
86
+ if not capped:
87
+ return []
88
+
89
+ candidate_ids = [c.id for c in capped]
90
+ stored = embedding_store.get_batch("memories", candidate_ids)
91
+
92
+ conflicts = []
93
+ for candidate in capped:
94
+ c_vec = stored.get(candidate.id)
95
+ if c_vec is None:
96
+ continue
97
+ if _cosine(new_vec, c_vec) >= threshold:
98
+ conflicts.append(ConflictRecord(
99
+ record_a_id=candidate.id,
100
+ record_b_id=new_record.id,
101
+ conflict_type="semantic",
102
+ ))
103
+ return conflicts