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,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,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
|