Skip to content

Commit 798b20d

Browse files
author
AgentPatterns
committed
feat(examples/python): add memory-augmented-agent runnable example
1 parent b21c10e commit 798b20d

File tree

6 files changed

+1099
-0
lines changed

6 files changed

+1099
-0
lines changed
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Memory-Augmented Agent - Python Implementation
2+
3+
Runnable implementation of a memory-augmented agent flow where the system
4+
captures durable user facts, stores them with policy checks, retrieves relevant
5+
memory in a later session, and applies memory to final response generation.
6+
7+
---
8+
9+
## Quick start
10+
11+
```bash
12+
# (optional) create venv
13+
python -m venv .venv && source .venv/bin/activate
14+
15+
# install dependencies
16+
pip install -r requirements.txt
17+
18+
# set API key
19+
export OPENAI_API_KEY="sk-..."
20+
21+
# run the agent
22+
python main.py
23+
```
24+
25+
## Full walkthrough
26+
27+
Read the complete implementation guide:
28+
https://agentpatterns.tech/en/agent-patterns/memory-augmented-agent
29+
30+
## What's inside
31+
32+
- Capture -> Store -> Retrieve -> Apply flow
33+
- Memory write validation (`key/value/scope/ttl/confidence`)
34+
- Policy allowlist vs execution allowlist for memory keys and scopes
35+
- TTL-based memory lifecycle and bounded in-memory store
36+
- Strict policy boundary: unknown memory `key`/`scope` stops the run
37+
- Grounded final answer with memory-key allowlist check
38+
- `resolved_scopes` in history (execution-gated scopes actually used at runtime)
39+
- Trace and history for auditability
40+
41+
## Project layout
42+
43+
```text
44+
examples/
45+
agent-patterns/
46+
memory-augmented-agent/
47+
python/
48+
README.md
49+
main.py
50+
llm.py
51+
gateway.py
52+
memory_store.py
53+
requirements.txt
54+
```
55+
56+
## Notes
57+
58+
- This example is English-only in code; narrative is localized on the website.
59+
- This demo simulates two sessions within one process run (in-memory store).
60+
- For real cross-session persistence, use an external store (Postgres/Redis/Vector DB).
61+
- The website provides multilingual explanations and theory.
62+
63+
## License
64+
65+
MIT
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import Any
5+
6+
from memory_store import MemoryStore
7+
8+
9+
class StopRun(Exception):
10+
def __init__(self, reason: str):
11+
super().__init__(reason)
12+
self.reason = reason
13+
14+
15+
@dataclass(frozen=True)
16+
class Budget:
17+
max_capture_items: int = 6
18+
max_retrieve_top_k: int = 6
19+
max_query_chars: int = 240
20+
max_answer_chars: int = 700
21+
max_value_chars: int = 120
22+
max_seconds: int = 25
23+
24+
25+
def _is_number(value: Any) -> bool:
26+
return isinstance(value, (int, float)) and not isinstance(value, bool)
27+
28+
29+
def validate_memory_candidates(
30+
raw: Any,
31+
*,
32+
allowed_keys_policy: set[str],
33+
allowed_scopes_policy: set[str],
34+
max_items: int,
35+
max_value_chars: int,
36+
) -> dict[str, Any]:
37+
if not isinstance(raw, dict):
38+
raise StopRun("invalid_memory_candidates:not_object")
39+
40+
items = raw.get("items")
41+
if not isinstance(items, list):
42+
raise StopRun("invalid_memory_candidates:items")
43+
44+
normalized: list[dict[str, Any]] = []
45+
for item in items:
46+
if not isinstance(item, dict):
47+
raise StopRun("invalid_memory_candidates:item")
48+
49+
required_keys = {"key", "value"}
50+
if not required_keys.issubset(item.keys()):
51+
raise StopRun("invalid_memory_candidates:missing_keys")
52+
53+
key = item.get("key")
54+
value = item.get("value")
55+
scope = item.get("scope", "user")
56+
ttl_days = item.get("ttl_days", 180)
57+
confidence = item.get("confidence", 0.8)
58+
59+
if not isinstance(key, str) or not key.strip():
60+
raise StopRun("invalid_memory_candidates:key")
61+
key = key.strip()
62+
if key not in allowed_keys_policy:
63+
raise StopRun(f"memory_key_not_allowed_policy:{key}")
64+
65+
if not isinstance(value, str) or not value.strip():
66+
raise StopRun("invalid_memory_candidates:value")
67+
value = value.strip()
68+
if len(value) > max_value_chars:
69+
raise StopRun("invalid_memory_candidates:value_too_long")
70+
71+
if not isinstance(scope, str) or not scope.strip():
72+
raise StopRun("invalid_memory_candidates:scope")
73+
scope = scope.strip()
74+
if scope not in allowed_scopes_policy:
75+
raise StopRun(f"memory_scope_not_allowed_policy:{scope}")
76+
77+
if not _is_number(ttl_days):
78+
raise StopRun("invalid_memory_candidates:ttl_days")
79+
ttl_days = int(float(ttl_days))
80+
ttl_days = max(1, min(365, ttl_days))
81+
82+
if not _is_number(confidence):
83+
raise StopRun("invalid_memory_candidates:confidence")
84+
confidence = float(confidence)
85+
confidence = max(0.0, min(1.0, confidence))
86+
87+
normalized.append(
88+
{
89+
"key": key,
90+
"value": value,
91+
"scope": scope,
92+
"ttl_days": ttl_days,
93+
"confidence": round(confidence, 3),
94+
}
95+
)
96+
97+
if len(normalized) > max_items:
98+
raise StopRun("invalid_memory_candidates:too_many_items")
99+
100+
return {"items": normalized}
101+
102+
103+
def validate_retrieval_intent(
104+
raw: Any,
105+
*,
106+
allowed_scopes_policy: set[str],
107+
max_top_k: int,
108+
) -> dict[str, Any]:
109+
if not isinstance(raw, dict):
110+
raise StopRun("invalid_retrieval_intent:not_object")
111+
112+
if raw.get("kind") != "retrieve_memory":
113+
raise StopRun("invalid_retrieval_intent:kind")
114+
115+
query = raw.get("query")
116+
if not isinstance(query, str) or not query.strip():
117+
raise StopRun("invalid_retrieval_intent:query")
118+
119+
top_k = raw.get("top_k", 4)
120+
if not isinstance(top_k, int) or not (1 <= top_k <= max_top_k):
121+
raise StopRun("invalid_retrieval_intent:top_k")
122+
123+
scopes_raw = raw.get("scopes")
124+
normalized_scopes: list[str] = []
125+
if scopes_raw is not None:
126+
if not isinstance(scopes_raw, list) or not scopes_raw:
127+
raise StopRun("invalid_retrieval_intent:scopes")
128+
for scope in scopes_raw:
129+
if not isinstance(scope, str) or not scope.strip():
130+
raise StopRun("invalid_retrieval_intent:scope_item")
131+
normalized_scope = scope.strip()
132+
if normalized_scope not in allowed_scopes_policy:
133+
raise StopRun(f"invalid_retrieval_intent:scope_not_allowed:{normalized_scope}")
134+
normalized_scopes.append(normalized_scope)
135+
136+
payload = {
137+
"kind": "retrieve_memory",
138+
"query": query.strip(),
139+
"top_k": top_k,
140+
}
141+
if normalized_scopes:
142+
payload["scopes"] = normalized_scopes
143+
return payload
144+
145+
146+
class MemoryGateway:
147+
def __init__(
148+
self,
149+
*,
150+
store: MemoryStore,
151+
budget: Budget,
152+
allow_execution_keys: set[str],
153+
allow_execution_scopes: set[str],
154+
):
155+
self.store = store
156+
self.budget = budget
157+
self.allow_execution_keys = set(allow_execution_keys)
158+
self.allow_execution_scopes = set(allow_execution_scopes)
159+
160+
def write(
161+
self,
162+
*,
163+
user_id: int,
164+
items: list[dict[str, Any]],
165+
source: str,
166+
) -> dict[str, Any]:
167+
if len(items) > self.budget.max_capture_items:
168+
raise StopRun("max_capture_items")
169+
170+
writable: list[dict[str, Any]] = []
171+
blocked: list[dict[str, Any]] = []
172+
173+
for item in items:
174+
key = item["key"]
175+
scope = item["scope"]
176+
177+
if key not in self.allow_execution_keys:
178+
blocked.append({"key": key, "reason": "key_denied_execution"})
179+
continue
180+
if scope not in self.allow_execution_scopes:
181+
blocked.append(
182+
{
183+
"key": key,
184+
"scope": scope,
185+
"reason": "scope_denied_execution",
186+
}
187+
)
188+
continue
189+
190+
writable.append(item)
191+
192+
written = []
193+
if writable:
194+
written = self.store.upsert_items(user_id=user_id, items=writable, source=source)
195+
196+
return {
197+
"written": written,
198+
"blocked": blocked,
199+
}
200+
201+
def retrieve(
202+
self,
203+
*,
204+
user_id: int,
205+
intent: dict[str, Any],
206+
include_preference_keys: bool = False,
207+
) -> dict[str, Any]:
208+
query = intent["query"]
209+
if len(query) > self.budget.max_query_chars:
210+
raise StopRun("invalid_retrieval_intent:query_too_long")
211+
212+
requested_scopes = set(intent.get("scopes") or self.allow_execution_scopes)
213+
denied = sorted(requested_scopes - self.allow_execution_scopes)
214+
if denied:
215+
raise StopRun(f"scope_denied:{denied[0]}")
216+
217+
items = self.store.search(
218+
user_id=user_id,
219+
query=query,
220+
top_k=intent["top_k"],
221+
scopes=requested_scopes,
222+
include_preference_keys=include_preference_keys,
223+
)
224+
225+
return {
226+
"query": query,
227+
"requested_scopes": sorted(requested_scopes),
228+
"include_preference_keys": include_preference_keys,
229+
"items": items,
230+
}

0 commit comments

Comments
 (0)