rootmemory 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.
- rootmemory/__init__.py +84 -0
- rootmemory/api/__init__.py +25 -0
- rootmemory/api/agents.py +38 -0
- rootmemory/api/beliefs.py +66 -0
- rootmemory/api/claims.py +87 -0
- rootmemory/api/deps.py +21 -0
- rootmemory/api/errors.py +36 -0
- rootmemory/api/graph.py +32 -0
- rootmemory/api/invalidation.py +47 -0
- rootmemory/api/observations.py +46 -0
- rootmemory/api/promotion.py +37 -0
- rootmemory/api/provenance.py +88 -0
- rootmemory/api/security.py +53 -0
- rootmemory/cli.py +96 -0
- rootmemory/client.py +219 -0
- rootmemory/config.py +64 -0
- rootmemory/db/__init__.py +0 -0
- rootmemory/db/base.py +42 -0
- rootmemory/db/migrations/env.py +59 -0
- rootmemory/db/migrations/script.py.mako +26 -0
- rootmemory/db/migrations/versions/d336d5cf7fb9_initial_schema.py +260 -0
- rootmemory/db/session.py +138 -0
- rootmemory/errors.py +27 -0
- rootmemory/integrations/__init__.py +16 -0
- rootmemory/integrations/async_client.py +268 -0
- rootmemory/integrations/langchain_tools.py +237 -0
- rootmemory/integrations/langgraph_memory.py +179 -0
- rootmemory/integrations/langgraph_store.py +402 -0
- rootmemory/logging_config.py +30 -0
- rootmemory/main.py +133 -0
- rootmemory/models/__init__.py +22 -0
- rootmemory/models/agent.py +30 -0
- rootmemory/models/audit.py +51 -0
- rootmemory/models/belief.py +39 -0
- rootmemory/models/claim.py +52 -0
- rootmemory/models/decision.py +38 -0
- rootmemory/models/edge.py +43 -0
- rootmemory/models/enums.py +104 -0
- rootmemory/models/node_ref.py +42 -0
- rootmemory/models/observation.py +51 -0
- rootmemory/py.typed +0 -0
- rootmemory/repositories/__init__.py +19 -0
- rootmemory/repositories/agent_repo.py +44 -0
- rootmemory/repositories/audit_repo.py +43 -0
- rootmemory/repositories/belief_repo.py +63 -0
- rootmemory/repositories/claim_repo.py +102 -0
- rootmemory/repositories/decision_repo.py +76 -0
- rootmemory/repositories/edge_repo.py +77 -0
- rootmemory/repositories/observation_repo.py +76 -0
- rootmemory/schemas/__init__.py +52 -0
- rootmemory/schemas/agent.py +26 -0
- rootmemory/schemas/belief.py +49 -0
- rootmemory/schemas/claim.py +54 -0
- rootmemory/schemas/common.py +27 -0
- rootmemory/schemas/observation.py +69 -0
- rootmemory/schemas/promotion.py +50 -0
- rootmemory/schemas/provenance.py +51 -0
- rootmemory/services/__init__.py +35 -0
- rootmemory/services/belief_service.py +161 -0
- rootmemory/services/contradiction_service.py +148 -0
- rootmemory/services/decision_service.py +65 -0
- rootmemory/services/graph_service.py +316 -0
- rootmemory/services/independence_service.py +159 -0
- rootmemory/services/invalidation_service.py +164 -0
- rootmemory/services/normalization_service.py +234 -0
- rootmemory/services/promotion_service.py +318 -0
- rootmemory/services/provenance_service.py +282 -0
- rootmemory/services/scoring_service.py +53 -0
- rootmemory/static/index.html +866 -0
- rootmemory-0.1.0.dist-info/METADATA +266 -0
- rootmemory-0.1.0.dist-info/RECORD +75 -0
- rootmemory-0.1.0.dist-info/WHEEL +5 -0
- rootmemory-0.1.0.dist-info/entry_points.txt +2 -0
- rootmemory-0.1.0.dist-info/licenses/LICENSE +21 -0
- rootmemory-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""An async face on the memory layer.
|
|
2
|
+
|
|
3
|
+
LangGraph, LangChain and most agent runtimes are async. The memory core is
|
|
4
|
+
synchronous SQLAlchemy, so every call here is handed to a worker thread. That
|
|
5
|
+
is the right trade for this workload: the calls are short database operations,
|
|
6
|
+
not long ones, and it keeps a single well-tested code path instead of a second
|
|
7
|
+
async ORM stack that could drift from it.
|
|
8
|
+
|
|
9
|
+
Each call runs in its own session and commits, so a crashed agent step cannot
|
|
10
|
+
leave a half-written belief behind.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import uuid
|
|
17
|
+
from collections.abc import Callable, Sequence
|
|
18
|
+
from typing import Any, TypeVar
|
|
19
|
+
|
|
20
|
+
from sqlalchemy.orm import Session
|
|
21
|
+
|
|
22
|
+
from rootmemory.client import RootMemory
|
|
23
|
+
from rootmemory.db.session import get_session_factory
|
|
24
|
+
from rootmemory.models.enums import EpistemicStatus
|
|
25
|
+
from rootmemory.services.promotion_service import PromotionPolicy
|
|
26
|
+
|
|
27
|
+
T = TypeVar("T")
|
|
28
|
+
|
|
29
|
+
#: Ids arrive as strings as often as UUIDs here: LangGraph checkpoints state as
|
|
30
|
+
#: JSON, and this client hands ids back as strings so they survive that trip.
|
|
31
|
+
#: Coercing at the boundary means callers never have to think about it.
|
|
32
|
+
IdLike = uuid.UUID | str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def as_uuid(value: IdLike) -> uuid.UUID:
|
|
36
|
+
"""Accept either form of id and return a UUID."""
|
|
37
|
+
if isinstance(value, uuid.UUID):
|
|
38
|
+
return value
|
|
39
|
+
try:
|
|
40
|
+
return uuid.UUID(str(value))
|
|
41
|
+
except (ValueError, AttributeError, TypeError) as exc:
|
|
42
|
+
raise ValueError(f"not a valid id: {value!r}") from exc
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AsyncRootMemory:
|
|
46
|
+
"""Async wrapper over :class:`RootMemory`.
|
|
47
|
+
|
|
48
|
+
Returns plain dictionaries rather than ORM objects, because framework state
|
|
49
|
+
has to be serializable - LangGraph checkpoints it, and a detached ORM
|
|
50
|
+
instance would not survive that.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
session_factory: Callable[[], Session] | None = None,
|
|
56
|
+
policy: PromotionPolicy | None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
self._session_factory = session_factory or get_session_factory()
|
|
59
|
+
self._policy = policy
|
|
60
|
+
|
|
61
|
+
async def _run(self, work: Callable[[RootMemory], T]) -> T:
|
|
62
|
+
def call() -> T:
|
|
63
|
+
session = self._session_factory()
|
|
64
|
+
try:
|
|
65
|
+
result = work(RootMemory(session, self._policy))
|
|
66
|
+
session.commit()
|
|
67
|
+
return result
|
|
68
|
+
except Exception:
|
|
69
|
+
session.rollback()
|
|
70
|
+
raise
|
|
71
|
+
finally:
|
|
72
|
+
session.close()
|
|
73
|
+
|
|
74
|
+
return await asyncio.to_thread(call)
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
# agents and evidence
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
async def register_agent(self, name: str, role: str = "") -> uuid.UUID:
|
|
81
|
+
"""Register an agent and return its id. Idempotent on name."""
|
|
82
|
+
return await self._run(lambda m: m.register_agent(name, role).id)
|
|
83
|
+
|
|
84
|
+
async def record_observation(
|
|
85
|
+
self,
|
|
86
|
+
source_type: str,
|
|
87
|
+
content: str,
|
|
88
|
+
source_uri: str | None = None,
|
|
89
|
+
source_actor: str | None = None,
|
|
90
|
+
reliability_score: float = 1.0,
|
|
91
|
+
source_family_id: str | None = None,
|
|
92
|
+
) -> uuid.UUID:
|
|
93
|
+
"""Record something that entered from outside: a tool result, a
|
|
94
|
+
retrieved document, a user message."""
|
|
95
|
+
return await self._run(
|
|
96
|
+
lambda m: m.create_observation(
|
|
97
|
+
source_type=source_type,
|
|
98
|
+
content=content,
|
|
99
|
+
source_uri=source_uri,
|
|
100
|
+
source_actor=source_actor,
|
|
101
|
+
reliability_score=reliability_score,
|
|
102
|
+
source_family_id=source_family_id,
|
|
103
|
+
).id
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
# beliefs
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
async def record_belief(
|
|
111
|
+
self,
|
|
112
|
+
agent_id: IdLike,
|
|
113
|
+
text: str,
|
|
114
|
+
claim_key: str | None,
|
|
115
|
+
confidence: float,
|
|
116
|
+
derived_from: Sequence[IdLike],
|
|
117
|
+
llm_generated: bool = True,
|
|
118
|
+
meta: dict | None = None,
|
|
119
|
+
) -> uuid.UUID:
|
|
120
|
+
"""Record what an agent concluded, citing everything it read.
|
|
121
|
+
|
|
122
|
+
``derived_from`` may mix observation and belief ids; the type of each
|
|
123
|
+
parent is resolved here so callers do not have to track it.
|
|
124
|
+
|
|
125
|
+
Raises ProvenanceRequiredError if nothing is cited - which is the whole
|
|
126
|
+
point. An agent cannot contribute an opinion without saying where it
|
|
127
|
+
came from.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
agent = as_uuid(agent_id)
|
|
131
|
+
parent_ids = [as_uuid(node_id) for node_id in derived_from]
|
|
132
|
+
|
|
133
|
+
def work(memory: RootMemory) -> uuid.UUID:
|
|
134
|
+
parents = [_resolve(memory, node_id) for node_id in parent_ids]
|
|
135
|
+
return memory.create_belief(
|
|
136
|
+
agent_id=agent,
|
|
137
|
+
claim_text=text,
|
|
138
|
+
claim_key=claim_key,
|
|
139
|
+
confidence=confidence,
|
|
140
|
+
derived_from=parents,
|
|
141
|
+
epistemic_status=EpistemicStatus.INFERRED,
|
|
142
|
+
llm_generated=llm_generated,
|
|
143
|
+
meta=meta,
|
|
144
|
+
).id
|
|
145
|
+
|
|
146
|
+
return await self._run(work)
|
|
147
|
+
|
|
148
|
+
async def belief(self, belief_id: IdLike) -> dict[str, Any]:
|
|
149
|
+
"""Read one belief back, as another agent would see it."""
|
|
150
|
+
|
|
151
|
+
node_id = as_uuid(belief_id)
|
|
152
|
+
|
|
153
|
+
def work(memory: RootMemory) -> dict[str, Any]:
|
|
154
|
+
found = memory.get_belief(node_id)
|
|
155
|
+
return {
|
|
156
|
+
"id": str(found.id),
|
|
157
|
+
"text": found.claim_text,
|
|
158
|
+
"claim_key": found.normalized_claim_key,
|
|
159
|
+
"confidence": found.confidence,
|
|
160
|
+
"status": found.epistemic_status,
|
|
161
|
+
"agent_id": str(found.agent_id),
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return await self._run(work)
|
|
165
|
+
|
|
166
|
+
async def evidence_roots(self, belief_id: IdLike) -> list[dict[str, Any]]:
|
|
167
|
+
"""The observations a belief ultimately rests on."""
|
|
168
|
+
|
|
169
|
+
node_id = as_uuid(belief_id)
|
|
170
|
+
|
|
171
|
+
def work(memory: RootMemory) -> list[dict[str, Any]]:
|
|
172
|
+
belief = memory.get_belief(node_id)
|
|
173
|
+
return [
|
|
174
|
+
{
|
|
175
|
+
"id": str(o.id),
|
|
176
|
+
"source_type": o.source_type,
|
|
177
|
+
"content": o.content,
|
|
178
|
+
"validity_status": o.validity_status,
|
|
179
|
+
}
|
|
180
|
+
for o in memory.evidence_roots(belief)
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
return await self._run(work)
|
|
184
|
+
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
# claims
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
async def claim(self, claim_key: str, text: str) -> uuid.UUID:
|
|
190
|
+
return await self._run(lambda m: m.get_or_create_claim(claim_key, text).id)
|
|
191
|
+
|
|
192
|
+
async def support(self, claim_id: IdLike, belief_id: IdLike) -> None:
|
|
193
|
+
claim, belief = as_uuid(claim_id), as_uuid(belief_id)
|
|
194
|
+
await self._run(lambda m: m.support_claim(claim, belief))
|
|
195
|
+
|
|
196
|
+
async def contradict(self, claim_id: IdLike, belief_id: IdLike) -> None:
|
|
197
|
+
claim, belief = as_uuid(claim_id), as_uuid(belief_id)
|
|
198
|
+
await self._run(lambda m: m.contradict_claim(claim, belief))
|
|
199
|
+
|
|
200
|
+
async def claim_context(self, claim_id: IdLike) -> dict[str, Any]:
|
|
201
|
+
"""What an agent should read instead of a bare fact: both sides, with
|
|
202
|
+
agreement and independent-evidence counts kept apart."""
|
|
203
|
+
claim = as_uuid(claim_id)
|
|
204
|
+
return await self._run(lambda m: m.get_claim_context(claim).as_dict())
|
|
205
|
+
|
|
206
|
+
async def evaluate(self, claim_id: IdLike) -> dict[str, Any]:
|
|
207
|
+
"""Run the promotion gate without changing anything."""
|
|
208
|
+
claim = as_uuid(claim_id)
|
|
209
|
+
return await self._run(lambda m: m.evaluate_claim(claim).as_dict())
|
|
210
|
+
|
|
211
|
+
async def promote(self, claim_id: IdLike) -> dict[str, Any]:
|
|
212
|
+
"""Attempt to move a claim into shared memory."""
|
|
213
|
+
claim = as_uuid(claim_id)
|
|
214
|
+
return await self._run(lambda m: m.promote_claim(claim).as_dict())
|
|
215
|
+
|
|
216
|
+
async def safe_to_act(self, claim_id: IdLike) -> bool:
|
|
217
|
+
"""True only when the claim passes the gate on independent evidence."""
|
|
218
|
+
verdict = await self.evaluate(claim_id)
|
|
219
|
+
return bool(verdict["decision"] == "promote")
|
|
220
|
+
|
|
221
|
+
# ------------------------------------------------------------------
|
|
222
|
+
# decisions and repair
|
|
223
|
+
# ------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
async def record_decision(
|
|
226
|
+
self,
|
|
227
|
+
agent_id: IdLike,
|
|
228
|
+
decision_type: str,
|
|
229
|
+
content: str,
|
|
230
|
+
depends_on: Sequence[IdLike] = (),
|
|
231
|
+
meta: dict | None = None,
|
|
232
|
+
) -> uuid.UUID:
|
|
233
|
+
"""Record an action and the claims it rests on, so a later retraction
|
|
234
|
+
can flag it."""
|
|
235
|
+
|
|
236
|
+
agent = as_uuid(agent_id)
|
|
237
|
+
claim_ids = [as_uuid(c) for c in depends_on]
|
|
238
|
+
|
|
239
|
+
def work(memory: RootMemory) -> uuid.UUID:
|
|
240
|
+
return memory.decision_service.create_decision(
|
|
241
|
+
agent_id=agent,
|
|
242
|
+
decision_type=decision_type,
|
|
243
|
+
content=content,
|
|
244
|
+
claim_ids=claim_ids,
|
|
245
|
+
meta=meta,
|
|
246
|
+
).id
|
|
247
|
+
|
|
248
|
+
return await self._run(work)
|
|
249
|
+
|
|
250
|
+
async def retract_source(self, observation_id: IdLike, reason: str) -> dict[str, Any]:
|
|
251
|
+
"""Invalidate a source and repair everything built on it."""
|
|
252
|
+
node_id = as_uuid(observation_id)
|
|
253
|
+
return await self._run(
|
|
254
|
+
lambda m: m.invalidate_observation(node_id, reason).as_dict()
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _resolve(memory: RootMemory, node_id: uuid.UUID): # type: ignore[no-untyped-def]
|
|
259
|
+
"""Look up a parent by id without the caller having to say what it is."""
|
|
260
|
+
observation = memory.observations.get(node_id)
|
|
261
|
+
if observation is not None:
|
|
262
|
+
return observation
|
|
263
|
+
belief = memory.find_belief(node_id)
|
|
264
|
+
if belief is not None:
|
|
265
|
+
return belief
|
|
266
|
+
from rootmemory.errors import NodeNotFoundError
|
|
267
|
+
|
|
268
|
+
raise NodeNotFoundError(f"no observation or belief with id {node_id}")
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""LangChain tool adapter.
|
|
2
|
+
|
|
3
|
+
LangChain's tool interface is the closest thing the ecosystem has to a common
|
|
4
|
+
plug: CrewAI, AutoGen's tool bridge, LangGraph's prebuilt agents and anything
|
|
5
|
+
built on `bind_tools` all accept it. Exposing the memory as tools therefore
|
|
6
|
+
covers far more than LangChain itself.
|
|
7
|
+
|
|
8
|
+
from rootmemory.integrations.langchain_tools import build_tools
|
|
9
|
+
tools = build_tools(memory, agent_id)
|
|
10
|
+
model = ChatAnthropic(model="claude-sonnet-4-5").bind_tools(tools)
|
|
11
|
+
|
|
12
|
+
The tools are deliberately shaped so an LLM cannot record an opinion without
|
|
13
|
+
citing something: ``record_belief`` requires ``derived_from``, and the memory
|
|
14
|
+
layer rejects the call if it is empty.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import uuid
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
22
|
+
from pydantic import BaseModel, Field
|
|
23
|
+
|
|
24
|
+
from rootmemory.integrations.async_client import AsyncRootMemory
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
27
|
+
from langchain_core.tools import BaseTool
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _require_langchain() -> Any:
|
|
31
|
+
try:
|
|
32
|
+
from langchain_core.tools import StructuredTool
|
|
33
|
+
except ModuleNotFoundError as exc: # pragma: no cover - environment dependent
|
|
34
|
+
raise ModuleNotFoundError(
|
|
35
|
+
"LangChain tools need langchain-core. Install it with: "
|
|
36
|
+
'pip install "rootmemory[langchain]"'
|
|
37
|
+
) from exc
|
|
38
|
+
return StructuredTool
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ----------------------------------------------------------------------
|
|
42
|
+
# argument schemas - these become the tool signatures the model sees
|
|
43
|
+
# ----------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RecordObservationArgs(BaseModel):
|
|
47
|
+
source_type: str = Field(description="Where this came from: email, api, document, web...")
|
|
48
|
+
content: str = Field(description="The evidence itself, quoted as received.")
|
|
49
|
+
source_actor: str | None = Field(
|
|
50
|
+
default=None, description="Who or what produced it, if known."
|
|
51
|
+
)
|
|
52
|
+
reliability_score: float = Field(
|
|
53
|
+
default=1.0, ge=0.0, le=1.0, description="How much this source can be trusted."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RecordBeliefArgs(BaseModel):
|
|
58
|
+
text: str = Field(description="What you have concluded.")
|
|
59
|
+
claim_key: str = Field(description="Short stable key naming the proposition.")
|
|
60
|
+
confidence: float = Field(ge=0.0, le=1.0, description="How strongly you hold it.")
|
|
61
|
+
derived_from: list[str] = Field(
|
|
62
|
+
description=(
|
|
63
|
+
"REQUIRED. Ids of every observation or belief you read to reach this. "
|
|
64
|
+
"If you read another agent's belief, cite that belief - not the evidence "
|
|
65
|
+
"underneath it."
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ClaimArgs(BaseModel):
|
|
71
|
+
claim_key: str = Field(description="Short stable key naming the proposition.")
|
|
72
|
+
text: str = Field(description="The proposition in plain language.")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class LinkArgs(BaseModel):
|
|
76
|
+
claim_id: str = Field(description="Id of the claim.")
|
|
77
|
+
belief_id: str = Field(description="Id of the belief.")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ClaimIdArgs(BaseModel):
|
|
81
|
+
claim_id: str = Field(description="Id of the claim.")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class BeliefIdArgs(BaseModel):
|
|
85
|
+
belief_id: str = Field(description="Id of the belief.")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ----------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_tools(
|
|
92
|
+
memory: AsyncRootMemory,
|
|
93
|
+
agent_id: uuid.UUID,
|
|
94
|
+
include_write_tools: bool = True,
|
|
95
|
+
) -> list[BaseTool]:
|
|
96
|
+
"""Build the memory toolset for one agent.
|
|
97
|
+
|
|
98
|
+
Set ``include_write_tools=False`` for an agent that should only read shared
|
|
99
|
+
memory and never contribute to it.
|
|
100
|
+
"""
|
|
101
|
+
StructuredTool = _require_langchain()
|
|
102
|
+
|
|
103
|
+
async def record_observation(**kwargs: Any) -> str:
|
|
104
|
+
return str(await memory.record_observation(**kwargs))
|
|
105
|
+
|
|
106
|
+
async def record_belief(
|
|
107
|
+
text: str, claim_key: str, confidence: float, derived_from: list[str]
|
|
108
|
+
) -> str:
|
|
109
|
+
belief_id = await memory.record_belief(
|
|
110
|
+
agent_id=agent_id,
|
|
111
|
+
text=text,
|
|
112
|
+
claim_key=claim_key,
|
|
113
|
+
confidence=confidence,
|
|
114
|
+
derived_from=[uuid.UUID(x) for x in derived_from],
|
|
115
|
+
)
|
|
116
|
+
return str(belief_id)
|
|
117
|
+
|
|
118
|
+
async def open_claim(claim_key: str, text: str) -> str:
|
|
119
|
+
return str(await memory.claim(claim_key, text))
|
|
120
|
+
|
|
121
|
+
async def support_claim(claim_id: str, belief_id: str) -> str:
|
|
122
|
+
await memory.support(uuid.UUID(claim_id), uuid.UUID(belief_id))
|
|
123
|
+
return "recorded as support"
|
|
124
|
+
|
|
125
|
+
async def contradict_claim(claim_id: str, belief_id: str) -> str:
|
|
126
|
+
await memory.contradict(uuid.UUID(claim_id), uuid.UUID(belief_id))
|
|
127
|
+
return "recorded as a contradiction"
|
|
128
|
+
|
|
129
|
+
async def read_claim(claim_id: str) -> str:
|
|
130
|
+
context = await memory.claim_context(uuid.UUID(claim_id))
|
|
131
|
+
support = context["support"]
|
|
132
|
+
against = context["contradictions"]
|
|
133
|
+
return (
|
|
134
|
+
f"Claim: {context['claim']}\n"
|
|
135
|
+
f"Status: {context['status']}\n"
|
|
136
|
+
f"Support: {support['belief_count']} beliefs from "
|
|
137
|
+
f"{support['agreeing_agents']} agents, but only "
|
|
138
|
+
f"{support['independent_sources']} INDEPENDENT source(s), "
|
|
139
|
+
f"confidence {support['confidence']}\n"
|
|
140
|
+
f"Against: {against['belief_count']} beliefs from "
|
|
141
|
+
f"{against['independent_sources']} independent source(s), "
|
|
142
|
+
f"confidence {against['confidence']}\n"
|
|
143
|
+
"Agreement between agents is not evidence. Judge this on the "
|
|
144
|
+
"independent source counts."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
async def check_claim(claim_id: str) -> str:
|
|
148
|
+
verdict = await memory.evaluate(uuid.UUID(claim_id))
|
|
149
|
+
return (
|
|
150
|
+
f"{verdict['decision'].upper()}: {verdict['explanation']} "
|
|
151
|
+
f"({verdict['agreeing_agents']} agents, "
|
|
152
|
+
f"{verdict['independent_support_count']} independent source(s))"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
async def trace_belief(belief_id: str) -> str:
|
|
156
|
+
roots = await memory.evidence_roots(uuid.UUID(belief_id))
|
|
157
|
+
if not roots:
|
|
158
|
+
return "This belief rests on no valid evidence at all."
|
|
159
|
+
lines = [
|
|
160
|
+
f"- [{r['validity_status']}] {r['source_type']}: {r['content'][:120]}" for r in roots
|
|
161
|
+
]
|
|
162
|
+
return f"Rests on {len(roots)} observation(s):\n" + "\n".join(lines)
|
|
163
|
+
|
|
164
|
+
read_tools = [
|
|
165
|
+
StructuredTool.from_function(
|
|
166
|
+
coroutine=read_claim,
|
|
167
|
+
name="read_claim",
|
|
168
|
+
description=(
|
|
169
|
+
"Read what is currently believed about a claim. Returns both sides with "
|
|
170
|
+
"the number of INDEPENDENT evidence sources, which is not the same as "
|
|
171
|
+
"the number of agents who agree."
|
|
172
|
+
),
|
|
173
|
+
args_schema=ClaimIdArgs,
|
|
174
|
+
),
|
|
175
|
+
StructuredTool.from_function(
|
|
176
|
+
coroutine=check_claim,
|
|
177
|
+
name="check_claim_is_safe_to_act_on",
|
|
178
|
+
description=(
|
|
179
|
+
"Ask whether a claim has enough independent evidence behind it to act on. "
|
|
180
|
+
"Use this before taking any consequential action."
|
|
181
|
+
),
|
|
182
|
+
args_schema=ClaimIdArgs,
|
|
183
|
+
),
|
|
184
|
+
StructuredTool.from_function(
|
|
185
|
+
coroutine=trace_belief,
|
|
186
|
+
name="trace_belief_to_evidence",
|
|
187
|
+
description=(
|
|
188
|
+
"Trace a belief back to the original observations it rests on, so you can "
|
|
189
|
+
"tell first-hand evidence from a repeated conclusion."
|
|
190
|
+
),
|
|
191
|
+
args_schema=BeliefIdArgs,
|
|
192
|
+
),
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
if not include_write_tools:
|
|
196
|
+
return read_tools
|
|
197
|
+
|
|
198
|
+
return [
|
|
199
|
+
StructuredTool.from_function(
|
|
200
|
+
coroutine=record_observation,
|
|
201
|
+
name="record_observation",
|
|
202
|
+
description=(
|
|
203
|
+
"Record evidence that came from outside: a document, an API result, a "
|
|
204
|
+
"user message. Returns the observation id, which you must cite when you "
|
|
205
|
+
"later draw a conclusion from it."
|
|
206
|
+
),
|
|
207
|
+
args_schema=RecordObservationArgs,
|
|
208
|
+
),
|
|
209
|
+
StructuredTool.from_function(
|
|
210
|
+
coroutine=record_belief,
|
|
211
|
+
name="record_belief",
|
|
212
|
+
description=(
|
|
213
|
+
"Record a conclusion. You MUST cite in derived_from every observation or "
|
|
214
|
+
"belief you read to reach it. A conclusion with nothing cited is rejected."
|
|
215
|
+
),
|
|
216
|
+
args_schema=RecordBeliefArgs,
|
|
217
|
+
),
|
|
218
|
+
StructuredTool.from_function(
|
|
219
|
+
coroutine=open_claim,
|
|
220
|
+
name="open_claim",
|
|
221
|
+
description="Create or fetch the claim that beliefs argue about.",
|
|
222
|
+
args_schema=ClaimArgs,
|
|
223
|
+
),
|
|
224
|
+
StructuredTool.from_function(
|
|
225
|
+
coroutine=support_claim,
|
|
226
|
+
name="support_claim",
|
|
227
|
+
description="Attach one of your beliefs to a claim as support.",
|
|
228
|
+
args_schema=LinkArgs,
|
|
229
|
+
),
|
|
230
|
+
StructuredTool.from_function(
|
|
231
|
+
coroutine=contradict_claim,
|
|
232
|
+
name="contradict_claim",
|
|
233
|
+
description="Attach one of your beliefs to a claim as a contradiction.",
|
|
234
|
+
args_schema=LinkArgs,
|
|
235
|
+
),
|
|
236
|
+
*read_tools,
|
|
237
|
+
]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""LangGraph adapter.
|
|
2
|
+
|
|
3
|
+
The hard part of wiring provenance into a graph framework is not the API - it
|
|
4
|
+
is that state must carry *what a node read*, not just text. A node returning
|
|
5
|
+
``{"analysis": "supplier is unstable"}`` has thrown away the fact that it was
|
|
6
|
+
reading a colleague rather than a document, and that is exactly the fact the
|
|
7
|
+
memory layer needs.
|
|
8
|
+
|
|
9
|
+
``MemoryState`` carries belief ids alongside the text, and ``remembering()``
|
|
10
|
+
wraps an ordinary node so the citation happens automatically: whatever ids were
|
|
11
|
+
in state when the node ran become the parents of whatever it concluded.
|
|
12
|
+
|
|
13
|
+
Usage::
|
|
14
|
+
|
|
15
|
+
graph = StateGraph(MemoryState)
|
|
16
|
+
graph.add_node("research", remembering(memory, research_agent_id,
|
|
17
|
+
claim_key="supplier_risk")(research))
|
|
18
|
+
graph.add_node("risk", remembering(memory, risk_agent_id,
|
|
19
|
+
claim_key="supplier_risk")(assess))
|
|
20
|
+
|
|
21
|
+
Each wrapped node returns ``{"text": ..., "confidence": ...}`` and the wrapper
|
|
22
|
+
adds ``belief_id`` and appends to ``belief_ids``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import uuid
|
|
28
|
+
from collections.abc import Awaitable, Callable
|
|
29
|
+
from functools import wraps
|
|
30
|
+
from typing import Annotated, Any, TypedDict
|
|
31
|
+
|
|
32
|
+
from rootmemory.integrations.async_client import AsyncRootMemory
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def keep_last(_current: Any, incoming: Any) -> Any:
|
|
36
|
+
"""Reducer: last write wins."""
|
|
37
|
+
return incoming
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def append_unique(current: list[Any] | None, incoming: list[Any] | Any) -> list[Any]:
|
|
41
|
+
"""Reducer: accumulate ids across nodes without duplicating them.
|
|
42
|
+
|
|
43
|
+
LangGraph merges state per key, so this is what lets a node see everything
|
|
44
|
+
produced upstream of it.
|
|
45
|
+
"""
|
|
46
|
+
merged = list(current or [])
|
|
47
|
+
items = incoming if isinstance(incoming, list) else [incoming]
|
|
48
|
+
for item in items:
|
|
49
|
+
if item is not None and item not in merged:
|
|
50
|
+
merged.append(item)
|
|
51
|
+
return merged
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class MemoryState(TypedDict, total=False):
|
|
55
|
+
"""State that remembers where its contents came from.
|
|
56
|
+
|
|
57
|
+
``belief_ids`` accumulates every belief produced so far in the run, which
|
|
58
|
+
is what a downstream node cites. ``observation_ids`` does the same for
|
|
59
|
+
evidence pulled in from outside.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
text: Annotated[str, keep_last]
|
|
63
|
+
confidence: Annotated[float, keep_last]
|
|
64
|
+
belief_id: Annotated[uuid.UUID | None, keep_last]
|
|
65
|
+
belief_ids: Annotated[list[uuid.UUID], append_unique]
|
|
66
|
+
observation_ids: Annotated[list[uuid.UUID], append_unique]
|
|
67
|
+
claim_id: Annotated[uuid.UUID | None, keep_last]
|
|
68
|
+
verdict: Annotated[dict, keep_last]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
#: A node is any async callable taking state and returning a partial update.
|
|
72
|
+
Node = Callable[[dict], Awaitable[dict]]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def remembering(
|
|
76
|
+
memory: AsyncRootMemory,
|
|
77
|
+
agent_id: uuid.UUID,
|
|
78
|
+
claim_key: str,
|
|
79
|
+
*,
|
|
80
|
+
stance: str = "support",
|
|
81
|
+
cite: str = "all",
|
|
82
|
+
) -> Callable[[Node], Node]:
|
|
83
|
+
"""Wrap a node so whatever it concludes is recorded with its provenance.
|
|
84
|
+
|
|
85
|
+
``cite`` controls what the new belief points at:
|
|
86
|
+
|
|
87
|
+
``"all"`` everything upstream - every belief and observation in state.
|
|
88
|
+
``"latest"`` only the immediately preceding belief, which is the honest
|
|
89
|
+
model of an agent that read one colleague's output.
|
|
90
|
+
|
|
91
|
+
The wrapped node returns its own ``{"text": ..., "confidence": ...}``; the
|
|
92
|
+
wrapper adds the belief id and, when the state carries a ``claim_id``,
|
|
93
|
+
attaches the belief to that claim.
|
|
94
|
+
"""
|
|
95
|
+
if cite not in {"all", "latest"}:
|
|
96
|
+
raise ValueError("cite must be 'all' or 'latest'")
|
|
97
|
+
|
|
98
|
+
def decorate(node: Node) -> Node:
|
|
99
|
+
@wraps(node)
|
|
100
|
+
async def wrapper(state: dict) -> dict:
|
|
101
|
+
update = await node(state)
|
|
102
|
+
text = update.get("text")
|
|
103
|
+
if text is None:
|
|
104
|
+
# The node did not conclude anything; nothing to remember.
|
|
105
|
+
return update
|
|
106
|
+
|
|
107
|
+
parents = _parents(state, cite)
|
|
108
|
+
if not parents:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"node {node.__name__!r} produced a conclusion with nothing to cite; "
|
|
111
|
+
"put an observation id in state before the first reasoning node"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
belief_id = await memory.record_belief(
|
|
115
|
+
agent_id=agent_id,
|
|
116
|
+
text=text,
|
|
117
|
+
claim_key=claim_key,
|
|
118
|
+
confidence=float(update.get("confidence", 0.5)),
|
|
119
|
+
derived_from=parents,
|
|
120
|
+
llm_generated=bool(update.get("llm_generated", True)),
|
|
121
|
+
meta={"node": node.__name__},
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
claim_id = state.get("claim_id")
|
|
125
|
+
if claim_id is not None:
|
|
126
|
+
if stance == "contradict":
|
|
127
|
+
await memory.contradict(claim_id, belief_id)
|
|
128
|
+
else:
|
|
129
|
+
await memory.support(claim_id, belief_id)
|
|
130
|
+
|
|
131
|
+
return {**update, "belief_id": belief_id, "belief_ids": [belief_id]}
|
|
132
|
+
|
|
133
|
+
return wrapper
|
|
134
|
+
|
|
135
|
+
return decorate
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parents(state: dict, cite: str) -> list[uuid.UUID]:
|
|
139
|
+
if cite == "latest":
|
|
140
|
+
latest = state.get("belief_id")
|
|
141
|
+
if latest is not None:
|
|
142
|
+
return [latest]
|
|
143
|
+
# Nothing upstream yet: fall back to the evidence in state.
|
|
144
|
+
return list(state.get("observation_ids") or [])
|
|
145
|
+
return [*(state.get("belief_ids") or []), *(state.get("observation_ids") or [])]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def observe_into_state(
|
|
149
|
+
memory: AsyncRootMemory,
|
|
150
|
+
source_type: str,
|
|
151
|
+
content: str,
|
|
152
|
+
**kwargs: Any,
|
|
153
|
+
) -> dict:
|
|
154
|
+
"""Pull evidence in from outside and put it in state.
|
|
155
|
+
|
|
156
|
+
Use this as the entry node of a graph, so later nodes have something
|
|
157
|
+
legitimate to cite.
|
|
158
|
+
"""
|
|
159
|
+
observation_id = await memory.record_observation(source_type, content, **kwargs)
|
|
160
|
+
return {"observation_ids": [observation_id]}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def gate(memory: AsyncRootMemory, state: dict) -> dict:
|
|
164
|
+
"""A node that runs the promotion gate and puts the verdict in state.
|
|
165
|
+
|
|
166
|
+
Route on ``state["verdict"]["decision"]`` to stop a graph from acting on a
|
|
167
|
+
claim that only looks corroborated.
|
|
168
|
+
"""
|
|
169
|
+
claim_id = state.get("claim_id")
|
|
170
|
+
if claim_id is None:
|
|
171
|
+
raise ValueError("gate() needs a claim_id in state")
|
|
172
|
+
return {"verdict": await memory.evaluate(claim_id)}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def route_on_verdict(state: dict, *, on_promote: str, on_reject: str) -> str:
|
|
176
|
+
"""Conditional-edge helper: send the graph one way or the other depending
|
|
177
|
+
on whether the evidence is genuinely independent."""
|
|
178
|
+
verdict = state.get("verdict") or {}
|
|
179
|
+
return on_promote if verdict.get("decision") == "promote" else on_reject
|