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.
Files changed (75) hide show
  1. rootmemory/__init__.py +84 -0
  2. rootmemory/api/__init__.py +25 -0
  3. rootmemory/api/agents.py +38 -0
  4. rootmemory/api/beliefs.py +66 -0
  5. rootmemory/api/claims.py +87 -0
  6. rootmemory/api/deps.py +21 -0
  7. rootmemory/api/errors.py +36 -0
  8. rootmemory/api/graph.py +32 -0
  9. rootmemory/api/invalidation.py +47 -0
  10. rootmemory/api/observations.py +46 -0
  11. rootmemory/api/promotion.py +37 -0
  12. rootmemory/api/provenance.py +88 -0
  13. rootmemory/api/security.py +53 -0
  14. rootmemory/cli.py +96 -0
  15. rootmemory/client.py +219 -0
  16. rootmemory/config.py +64 -0
  17. rootmemory/db/__init__.py +0 -0
  18. rootmemory/db/base.py +42 -0
  19. rootmemory/db/migrations/env.py +59 -0
  20. rootmemory/db/migrations/script.py.mako +26 -0
  21. rootmemory/db/migrations/versions/d336d5cf7fb9_initial_schema.py +260 -0
  22. rootmemory/db/session.py +138 -0
  23. rootmemory/errors.py +27 -0
  24. rootmemory/integrations/__init__.py +16 -0
  25. rootmemory/integrations/async_client.py +268 -0
  26. rootmemory/integrations/langchain_tools.py +237 -0
  27. rootmemory/integrations/langgraph_memory.py +179 -0
  28. rootmemory/integrations/langgraph_store.py +402 -0
  29. rootmemory/logging_config.py +30 -0
  30. rootmemory/main.py +133 -0
  31. rootmemory/models/__init__.py +22 -0
  32. rootmemory/models/agent.py +30 -0
  33. rootmemory/models/audit.py +51 -0
  34. rootmemory/models/belief.py +39 -0
  35. rootmemory/models/claim.py +52 -0
  36. rootmemory/models/decision.py +38 -0
  37. rootmemory/models/edge.py +43 -0
  38. rootmemory/models/enums.py +104 -0
  39. rootmemory/models/node_ref.py +42 -0
  40. rootmemory/models/observation.py +51 -0
  41. rootmemory/py.typed +0 -0
  42. rootmemory/repositories/__init__.py +19 -0
  43. rootmemory/repositories/agent_repo.py +44 -0
  44. rootmemory/repositories/audit_repo.py +43 -0
  45. rootmemory/repositories/belief_repo.py +63 -0
  46. rootmemory/repositories/claim_repo.py +102 -0
  47. rootmemory/repositories/decision_repo.py +76 -0
  48. rootmemory/repositories/edge_repo.py +77 -0
  49. rootmemory/repositories/observation_repo.py +76 -0
  50. rootmemory/schemas/__init__.py +52 -0
  51. rootmemory/schemas/agent.py +26 -0
  52. rootmemory/schemas/belief.py +49 -0
  53. rootmemory/schemas/claim.py +54 -0
  54. rootmemory/schemas/common.py +27 -0
  55. rootmemory/schemas/observation.py +69 -0
  56. rootmemory/schemas/promotion.py +50 -0
  57. rootmemory/schemas/provenance.py +51 -0
  58. rootmemory/services/__init__.py +35 -0
  59. rootmemory/services/belief_service.py +161 -0
  60. rootmemory/services/contradiction_service.py +148 -0
  61. rootmemory/services/decision_service.py +65 -0
  62. rootmemory/services/graph_service.py +316 -0
  63. rootmemory/services/independence_service.py +159 -0
  64. rootmemory/services/invalidation_service.py +164 -0
  65. rootmemory/services/normalization_service.py +234 -0
  66. rootmemory/services/promotion_service.py +318 -0
  67. rootmemory/services/provenance_service.py +282 -0
  68. rootmemory/services/scoring_service.py +53 -0
  69. rootmemory/static/index.html +866 -0
  70. rootmemory-0.1.0.dist-info/METADATA +266 -0
  71. rootmemory-0.1.0.dist-info/RECORD +75 -0
  72. rootmemory-0.1.0.dist-info/WHEEL +5 -0
  73. rootmemory-0.1.0.dist-info/entry_points.txt +2 -0
  74. rootmemory-0.1.0.dist-info/licenses/LICENSE +21 -0
  75. 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