rosen-sdk 0.2.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.
- rosen/__init__.py +314 -0
- rosen/embedder.py +66 -0
- rosen/memory.py +277 -0
- rosen/model.py +233 -0
- rosen/tool.py +259 -0
- rosen_sdk-0.2.0.dist-info/LICENSE +661 -0
- rosen_sdk-0.2.0.dist-info/METADATA +87 -0
- rosen_sdk-0.2.0.dist-info/RECORD +10 -0
- rosen_sdk-0.2.0.dist-info/WHEEL +5 -0
- rosen_sdk-0.2.0.dist-info/top_level.txt +1 -0
rosen/__init__.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Rosen Python agent SDK (local MVP).
|
|
2
|
+
|
|
3
|
+
An agent is a function ``handler(ctx: Context) -> str | Result``. The Rosen
|
|
4
|
+
runtime invokes the agent as a subprocess, writing a JSON request to stdin and
|
|
5
|
+
reading a JSON response from stdout. ``run(handler)`` implements that protocol
|
|
6
|
+
so agent authors only write ``handler``.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import http.server
|
|
12
|
+
import threading
|
|
13
|
+
import os
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
import traceback
|
|
17
|
+
from typing import Callable, Union, TypedDict
|
|
18
|
+
from .model import Engine, Model
|
|
19
|
+
from .tool import Tool, Tools, Transport
|
|
20
|
+
from .memory import Memories, Memory, MemoryBackend
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Context",
|
|
24
|
+
"Engine",
|
|
25
|
+
"Result",
|
|
26
|
+
"Model",
|
|
27
|
+
"Tool",
|
|
28
|
+
"Tools",
|
|
29
|
+
"Transport",
|
|
30
|
+
"Memory",
|
|
31
|
+
"Memories",
|
|
32
|
+
"MemoryBackend",
|
|
33
|
+
"run",
|
|
34
|
+
"serve"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclasses.dataclass
|
|
38
|
+
class Context:
|
|
39
|
+
"""Everything an agent receives for a single run."""
|
|
40
|
+
|
|
41
|
+
input: str
|
|
42
|
+
trace_id: str = ""
|
|
43
|
+
agent: str = ""
|
|
44
|
+
namespace: str = ""
|
|
45
|
+
conversation_id: str = ""
|
|
46
|
+
model: Model = dataclasses.field(default_factory=Model)
|
|
47
|
+
tools: Tools = dataclasses.field(default_factory=Tools)
|
|
48
|
+
memory: Memories = dataclasses.field(default_factory=Memories)
|
|
49
|
+
|
|
50
|
+
def log(self, message: str) -> None:
|
|
51
|
+
"""Emit a trace line to stderr (stdout is the JSON result channel)."""
|
|
52
|
+
print(f"[rosen trace {self.trace_id}] {message}", file=sys.stderr)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclasses.dataclass
|
|
56
|
+
class Result:
|
|
57
|
+
"""An explicit agent result. Returning a plain ``str`` is also accepted."""
|
|
58
|
+
|
|
59
|
+
output: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ToolSpec(TypedDict, total=False):
|
|
63
|
+
name: str
|
|
64
|
+
transport: str
|
|
65
|
+
endpoint: str
|
|
66
|
+
permissions: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ModelSpec(TypedDict, total=False):
|
|
70
|
+
engine: str
|
|
71
|
+
model: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class MemorySpec(TypedDict, total=False):
|
|
75
|
+
name: str
|
|
76
|
+
backend: str
|
|
77
|
+
endpoint: str
|
|
78
|
+
collection: str
|
|
79
|
+
mode: str
|
|
80
|
+
embedder: str
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Request(TypedDict, total=False): # total=False = every key optional
|
|
84
|
+
input: str
|
|
85
|
+
traceId: str
|
|
86
|
+
namespace: str
|
|
87
|
+
conversationId: str
|
|
88
|
+
model: ModelSpec
|
|
89
|
+
tools: list[ToolSpec]
|
|
90
|
+
memory: list[MemorySpec]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
Handler = Callable[[Context], Union[str, Result]]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _build_context(req: dict, memory: Memories | None = None) -> Context:
|
|
97
|
+
"""
|
|
98
|
+
Build a Context from a decoded JSON request (shared by run and server).
|
|
99
|
+
"""
|
|
100
|
+
model_info = req.get("model") or {}
|
|
101
|
+
tools = Tools([
|
|
102
|
+
Tool(
|
|
103
|
+
name=t.get("name", ""),
|
|
104
|
+
transport=t.get("transport", ""),
|
|
105
|
+
endpoint=t.get("endpoint", ""),
|
|
106
|
+
permissions=t.get("permissions", "")
|
|
107
|
+
)
|
|
108
|
+
for t in (req.get("tools") or [])
|
|
109
|
+
])
|
|
110
|
+
|
|
111
|
+
if memory is None:
|
|
112
|
+
memory = _build_memories(req)
|
|
113
|
+
|
|
114
|
+
conversation_id = req.get("conversationId", "")
|
|
115
|
+
|
|
116
|
+
return Context(
|
|
117
|
+
input=req.get("input", ""),
|
|
118
|
+
trace_id=req.get("traceId", ""),
|
|
119
|
+
agent=req.get("agent", ""),
|
|
120
|
+
namespace=req.get("namespace", ""),
|
|
121
|
+
conversation_id=conversation_id,
|
|
122
|
+
model=Model(engine=model_info.get("engine", ""),
|
|
123
|
+
model=model_info.get("model", ""),
|
|
124
|
+
tools=tools,
|
|
125
|
+
memory=memory,
|
|
126
|
+
conversation_id=conversation_id),
|
|
127
|
+
tools=tools,
|
|
128
|
+
memory=memory
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _build_memories(req: dict) -> Memories:
|
|
133
|
+
return Memories([
|
|
134
|
+
Memory(
|
|
135
|
+
name=m.get("name", ""),
|
|
136
|
+
backend=m.get("backend", ""),
|
|
137
|
+
endpoint=m.get("endpoint", ""),
|
|
138
|
+
collection=m.get("collection", ""),
|
|
139
|
+
mode=m.get("mode", ""),
|
|
140
|
+
embedder=m.get("embedder")
|
|
141
|
+
)
|
|
142
|
+
for m in (req.get("memory") or [])
|
|
143
|
+
])
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _invoke(
|
|
147
|
+
handler: Handler,
|
|
148
|
+
req: dict,
|
|
149
|
+
base: dict | None = None,
|
|
150
|
+
base_memory: Memories | None = None) -> dict:
|
|
151
|
+
"""
|
|
152
|
+
Run the handler for one request, returning an {output}/{error} payload.
|
|
153
|
+
"""
|
|
154
|
+
request_memory = "memory" in req
|
|
155
|
+
if base:
|
|
156
|
+
req = {**base, **req}
|
|
157
|
+
memory = None if request_memory else base_memory
|
|
158
|
+
ctx = _build_context(req, memory=memory)
|
|
159
|
+
try:
|
|
160
|
+
result = handler(ctx)
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
traceback.print_exc(file=sys.stderr)
|
|
163
|
+
return {"error": str(exc)}
|
|
164
|
+
output = result.output if isinstance(result, Result) else str(result)
|
|
165
|
+
return {"output": output}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def run(handler: Handler, stdin=None, stdout=None) -> None:
|
|
169
|
+
"""
|
|
170
|
+
Read one JSON request, invoke ``handler``, write one JSON response.
|
|
171
|
+
|
|
172
|
+
The request comes from ``stdin``; if that is empty (a one-shot k8s Job pod has
|
|
173
|
+
no stdin from the controller) it falls back to the ``ROSEN_REQUEST`` env var.
|
|
174
|
+
The response is written to ``stdout`` and, whe ``ROSEN_RESULT_PATH`` is set,
|
|
175
|
+
mirrored there (the Job's termination-log) so k8s surfaces it in the pod's
|
|
176
|
+
termination.message -- a structured result channel, no log scrapping.
|
|
177
|
+
"""
|
|
178
|
+
stdin = stdin if stdin is not None else sys.stdin
|
|
179
|
+
stdout = stdout if stdout is not None else sys.stdout
|
|
180
|
+
|
|
181
|
+
raw = stdin.read()
|
|
182
|
+
if not raw.strip():
|
|
183
|
+
raw = os.environ.get("ROSEN_REQUEST", "")
|
|
184
|
+
try:
|
|
185
|
+
req = json.loads(raw) if raw.strip() else {}
|
|
186
|
+
except json.JSONDecodeError as exc:
|
|
187
|
+
result = {"error": f"invalid request json: {exc}"}
|
|
188
|
+
else:
|
|
189
|
+
result = _invoke(handler, req)
|
|
190
|
+
_write(stdout, result)
|
|
191
|
+
_mirror_result(result)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def serve(handler: Handler, addr: str | None = None) -> None:
|
|
195
|
+
"""
|
|
196
|
+
Run the agent as a long-lived HTTP server (one process, many requests).
|
|
197
|
+
|
|
198
|
+
GET /healthz -> 200: the readiness signal the controller probes.
|
|
199
|
+
POST /invoke -> 202: accept a run, execute the handler on a background
|
|
200
|
+
thread, and ack immediatly with {runId, worker}.
|
|
201
|
+
The worker is this pod's own address so the controller polls
|
|
202
|
+
the exact pod that holds the result.
|
|
203
|
+
GET /result/{runId} -> 200 {status: running|done|error, ...} | 404 unknown.
|
|
204
|
+
|
|
205
|
+
Asynchronous by design: the handler never holds the response open, so a slow run
|
|
206
|
+
can't trip a client-side invoke timeout (the "Failed-but-persisted" split-brain).
|
|
207
|
+
|
|
208
|
+
The bind address somes from ``addr`` or ``ROSEN_SERVE_ADDR`` (default 127.0.0.1:8080);
|
|
209
|
+
``ROSEN_POD_IP`` (injected by the controller via the downward API) is the address
|
|
210
|
+
advertised for polling, falling back to the bind host.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
addr = addr or os.environ.get("ROSEN_SERVE_ADDR", "127.0.0.1:8080")
|
|
214
|
+
host, _, port = addr.rpartition(":")
|
|
215
|
+
worker = f"{os.environ.get('ROSEN_POD_IP', host)}:{port}"
|
|
216
|
+
|
|
217
|
+
raw_bindings = os.environ.get("ROSEN_AGENT_BINDINGS")
|
|
218
|
+
base = json.loads(raw_bindings) if raw_bindings else {}
|
|
219
|
+
base_memory = _build_memories(base)
|
|
220
|
+
|
|
221
|
+
results: dict[str, dict] = {}
|
|
222
|
+
lock = threading.Lock()
|
|
223
|
+
|
|
224
|
+
def _run(run_id: str, req: Request) -> None:
|
|
225
|
+
out = _invoke(handler, req, base, base_memory)
|
|
226
|
+
with lock:
|
|
227
|
+
if "error" in out:
|
|
228
|
+
results[run_id] = {"status": "error", "error": out["error"]}
|
|
229
|
+
else:
|
|
230
|
+
results[run_id] = {"status": "done", "output": out["output"]}
|
|
231
|
+
|
|
232
|
+
class _Handler(http.server.BaseHTTPRequestHandler):
|
|
233
|
+
def do_GET(self) -> None:
|
|
234
|
+
if self.path == "/healthz":
|
|
235
|
+
self._respond(200, {"status": "ok"})
|
|
236
|
+
elif self.path.startswith("/result/"):
|
|
237
|
+
run_id = self.path[len("/result/"):]
|
|
238
|
+
with lock:
|
|
239
|
+
result = results.get(run_id)
|
|
240
|
+
if result is None:
|
|
241
|
+
self._respond(404, {"status": "unknown"})
|
|
242
|
+
else:
|
|
243
|
+
self._respond(200, result)
|
|
244
|
+
else:
|
|
245
|
+
self._respond(404, {"error": "not found"})
|
|
246
|
+
|
|
247
|
+
def do_POST(self) -> None:
|
|
248
|
+
if self.path != "/invoke":
|
|
249
|
+
self._respond(404, {"error": "not found"})
|
|
250
|
+
return
|
|
251
|
+
lenght = int(self.headers.get("Content-Length", 0))
|
|
252
|
+
raw = self.rfile.read(lenght).decode("utf-8")
|
|
253
|
+
try:
|
|
254
|
+
req = json.loads(raw) if raw.strip() else {}
|
|
255
|
+
except json.JSONDecodeError as exc:
|
|
256
|
+
self._respond(400, {"error": f"invalid request json: {exc}"})
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
run_id = req.get("traceId")
|
|
260
|
+
if not run_id:
|
|
261
|
+
self._respond(400, {"error": "missing traceId (run id)"})
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
with lock:
|
|
265
|
+
fresh = run_id not in results
|
|
266
|
+
if fresh:
|
|
267
|
+
results[run_id] = {"status": "running"}
|
|
268
|
+
if fresh:
|
|
269
|
+
threading.Thread(target=_run, args=(run_id, req), daemon=True).start()
|
|
270
|
+
|
|
271
|
+
self._respond(202, {"runId": run_id, "worker": worker})
|
|
272
|
+
|
|
273
|
+
def _respond(self, code: int, payload: dict) -> None:
|
|
274
|
+
body = json.dumps(payload).encode("utf-8")
|
|
275
|
+
try:
|
|
276
|
+
self.send_response(code)
|
|
277
|
+
self.send_header("Content-Type", "application/json")
|
|
278
|
+
self.send_header("Content-Length", str(len(body)))
|
|
279
|
+
self.end_headers()
|
|
280
|
+
self.wfile.write(body)
|
|
281
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
282
|
+
print(f"rosen: client disconnected before response (path={self.path})",
|
|
283
|
+
file=sys.stderr, flush=True)
|
|
284
|
+
|
|
285
|
+
def log_message(self, *args) -> None:
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
server = http.server.ThreadingHTTPServer((host, int(port)), _Handler)
|
|
289
|
+
bound = server.server_address
|
|
290
|
+
print(f"rosen: serving on {bound[0]}:{bound[1]}", file=sys.stderr, flush=True)
|
|
291
|
+
server.serve_forever()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _write(stdout, payload: dict) -> None:
|
|
295
|
+
json.dump(payload, stdout)
|
|
296
|
+
stdout.write("\n")
|
|
297
|
+
stdout.flush()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _mirror_result(payload: dict) -> None:
|
|
301
|
+
"""
|
|
302
|
+
Best-effort: mirror the result to ROSEN_RESULT_PATH (the Job's termination-log)
|
|
303
|
+
so k8s surfaces it in the pod's termination.message. A one-shot Job as no other
|
|
304
|
+
structured result channel; write failures (e.g. path unwritable locally) are logged
|
|
305
|
+
and ignored so they never mask the real result on stdout.
|
|
306
|
+
"""
|
|
307
|
+
path = os.environ.get("ROSEN_RESULT_PATH")
|
|
308
|
+
if not path:
|
|
309
|
+
return
|
|
310
|
+
try:
|
|
311
|
+
with open(path, "w") as f:
|
|
312
|
+
f.write(json.dumps(payload))
|
|
313
|
+
except OSError as exc:
|
|
314
|
+
print(f"rosen: could not write result to {path}: {exc}", file=sys.stderr, flush=True)
|
rosen/embedder.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _stable_hash(token: str) -> int:
|
|
6
|
+
# Python's hash() is salted per process. Vectors must be
|
|
7
|
+
# reproducible accross the serve subprocess boundry, so
|
|
8
|
+
# hash deterministically.
|
|
9
|
+
return int.from_bytes(hashlib.md5(token.encode()).digest()[:4], "big")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Embedder:
|
|
13
|
+
dim = 0
|
|
14
|
+
|
|
15
|
+
def embed(self, texts: list) -> list:
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MockEmbedder(Embedder):
|
|
20
|
+
"""Deterministic, offline embeddings for tests and local dev"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, model: str = "", dim: int = 64):
|
|
23
|
+
self.model = model
|
|
24
|
+
self.dim = dim
|
|
25
|
+
|
|
26
|
+
def embed(self, texts: list) -> list:
|
|
27
|
+
vectors = []
|
|
28
|
+
for text in texts:
|
|
29
|
+
vec = [0.0] * self.dim
|
|
30
|
+
for token in text.lower().split():
|
|
31
|
+
vec[_stable_hash(token) % self.dim] += 1
|
|
32
|
+
vectors.append(vec)
|
|
33
|
+
return vectors
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OpenAIEmbedder(Embedder):
|
|
37
|
+
|
|
38
|
+
def __init__(self, model: str):
|
|
39
|
+
self.model = model
|
|
40
|
+
self.dim = 0
|
|
41
|
+
|
|
42
|
+
def _client(self):
|
|
43
|
+
base_url = os.environ.get("ROSEN_MODEL_BASE_URL")
|
|
44
|
+
api_key = os.environ.get("ROSEN_MODEL_API_KEY")
|
|
45
|
+
if not base_url or not api_key:
|
|
46
|
+
raise RuntimeError("ROSEN_MODEL_BASE_URL and ROSEN_MODEL_API_KEY must be set")
|
|
47
|
+
from openai import OpenAI
|
|
48
|
+
return OpenAI(base_url=base_url, api_key=api_key)
|
|
49
|
+
|
|
50
|
+
def embed(self, texts: list) -> list:
|
|
51
|
+
resp = self._client().embeddings.create(model=self.model, input=texts)
|
|
52
|
+
vectors = [d.embedding for d in resp.data]
|
|
53
|
+
if vectors:
|
|
54
|
+
self.dim = len(vectors[0])
|
|
55
|
+
return vectors
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_EMBEDDERS = {
|
|
59
|
+
"mock": MockEmbedder,
|
|
60
|
+
"openai": OpenAIEmbedder,
|
|
61
|
+
"azure-openai": OpenAIEmbedder
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_embedder(engine: str, model: str) -> Embedder:
|
|
66
|
+
return _EMBEDDERS.get(engine, MockEmbedder)(model)
|
rosen/memory.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import uuid
|
|
3
|
+
|
|
4
|
+
from .embedder import build_embedder
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MemoryBackend:
|
|
8
|
+
def remember(self, text, metadata=None, scope=None) -> str:
|
|
9
|
+
raise NotImplementedError
|
|
10
|
+
|
|
11
|
+
def recall(self, query, k=5, scope=None) -> list:
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
|
|
14
|
+
def history(self, scope, k=None) -> list:
|
|
15
|
+
raise NotImplementedError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MockMemory(MemoryBackend):
|
|
19
|
+
|
|
20
|
+
def __init__(self, name: str, endpoint: str = "", collection: str = "", embedder=None):
|
|
21
|
+
self.name = name
|
|
22
|
+
self.endpoint = endpoint
|
|
23
|
+
self.collection = collection
|
|
24
|
+
self._items = []
|
|
25
|
+
|
|
26
|
+
def remember(self, text, metadata=None, scope=None) -> str:
|
|
27
|
+
item_id = str(len(self._items))
|
|
28
|
+
self._items.append(
|
|
29
|
+
{"id": item_id, "scope": scope, "text": text, "metadata": metadata or {}})
|
|
30
|
+
return item_id
|
|
31
|
+
|
|
32
|
+
def recall(self, query, k=5, scope=None) -> list:
|
|
33
|
+
needle = (query or "").lower()
|
|
34
|
+
hits = [
|
|
35
|
+
{"id": it["id"], "text": it["text"], "metadata": it["metadata"]}
|
|
36
|
+
for it in reversed(self._items)
|
|
37
|
+
if it["scope"] == scope and needle in it["text"].lower()
|
|
38
|
+
]
|
|
39
|
+
return hits
|
|
40
|
+
|
|
41
|
+
def history(self, scope, k=None) -> list:
|
|
42
|
+
turns = [
|
|
43
|
+
{"id": it["id"], "text": it["text"], "metadata": it["metadata"]}
|
|
44
|
+
for it in self._items
|
|
45
|
+
if it["scope"] == scope
|
|
46
|
+
]
|
|
47
|
+
return turns[-k:] if k else turns
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RedisMemory(MemoryBackend):
|
|
51
|
+
"""Conversational memory over a shared Redis. Each scope (conversationId) is a
|
|
52
|
+
Redis list of turns, so a turn landing on any replica reads/writes the same
|
|
53
|
+
list -- conversations survive across replicas and restarts"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, name: str, endpoint: str = "", collection: str = "", embedder=None):
|
|
56
|
+
import redis
|
|
57
|
+
self.name = name
|
|
58
|
+
self.endpoint = endpoint or "redis://127.0.0.1:6379/0"
|
|
59
|
+
self.collection = collection or "rosen"
|
|
60
|
+
self._client = redis.Redis.from_url(self.endpoint, decode_responses=True)
|
|
61
|
+
|
|
62
|
+
def _key(self, scope) -> str:
|
|
63
|
+
return f"{self.collection}:{self.name}:{scope or 'global'}"
|
|
64
|
+
|
|
65
|
+
def remember(self, text, metadata=None, scope=None) -> str:
|
|
66
|
+
item_id = uuid.uuid4().hex
|
|
67
|
+
self._client.rpush(
|
|
68
|
+
self._key(scope),
|
|
69
|
+
json.dumps({"id": item_id, "text": text, "metadata": metadata or {}}))
|
|
70
|
+
return item_id
|
|
71
|
+
|
|
72
|
+
def history(self, scope, k=None) -> list:
|
|
73
|
+
start = -k if k else 0
|
|
74
|
+
return [json.loads(r) for r in self._client.lrange(self._key(scope), start, -1)]
|
|
75
|
+
|
|
76
|
+
def recall(self, query, k=5, scope=None) -> list:
|
|
77
|
+
"""simple keyword scan"""
|
|
78
|
+
needle = (query or "").lower()
|
|
79
|
+
items = [json.loads(r) for r in self._client.lrange(self._key(scope), 0, -1)]
|
|
80
|
+
return [it for it in reversed(items) if needle in it["text"].lower()][:k]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FaissMemory(MemoryBackend):
|
|
84
|
+
"""In-process semantic memory"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, name: str, endpoint: str = "", collection: str = "", embedder=None):
|
|
87
|
+
self.name = name
|
|
88
|
+
self.endpoint = endpoint
|
|
89
|
+
self.collection = collection
|
|
90
|
+
self._embedder = embedder
|
|
91
|
+
self._items = []
|
|
92
|
+
|
|
93
|
+
def _embed(self, text: str) -> list:
|
|
94
|
+
if self._embedder is None:
|
|
95
|
+
raise RuntimeError("FaissMemory needs and embedder; bind one via Memory.spec.embedder")
|
|
96
|
+
return self._embedder.embed([text])[0]
|
|
97
|
+
|
|
98
|
+
def remember(self, text, metadata=None, scope=None) -> str:
|
|
99
|
+
item_id = str(len(self._items))
|
|
100
|
+
self._items.append({
|
|
101
|
+
"id": item_id, "scope": scope, "text": text,
|
|
102
|
+
"metadata": metadata or {}, "vector": self._embed(text)})
|
|
103
|
+
|
|
104
|
+
return item_id
|
|
105
|
+
|
|
106
|
+
def recall(self, query, k=5, scope=None) -> list:
|
|
107
|
+
import faiss
|
|
108
|
+
import numpy as np
|
|
109
|
+
hits = [it for it in self._items if it["scope"] == scope]
|
|
110
|
+
if not hits:
|
|
111
|
+
return []
|
|
112
|
+
|
|
113
|
+
vectors = np.array([it["vector"] for it in hits], dtype="float32")
|
|
114
|
+
faiss.normalize_L2(vectors)
|
|
115
|
+
index = faiss.IndexFlatIP(vectors.shape[1])
|
|
116
|
+
index.add(vectors)
|
|
117
|
+
|
|
118
|
+
q = np.array([self._embed(query)], dtype="float32")
|
|
119
|
+
faiss.normalize_L2(q)
|
|
120
|
+
|
|
121
|
+
_, idxs = index.search(q, min(k, len(hits)))
|
|
122
|
+
return [
|
|
123
|
+
{"id": hits[i]["id"], "text": hits[i]["text"], "metadata": hits[i]["metadata"]}
|
|
124
|
+
for i in idxs[0]
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
def history(self, scope, k=None) -> list:
|
|
128
|
+
raise NotImplementedError("semantic memory has no transcript; use conversational store")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class PgVectorMemory(MemoryBackend):
|
|
132
|
+
"""Shared/persistent vector memory over Postgres + pgvector"""
|
|
133
|
+
|
|
134
|
+
def __init__(self, name: str, endpoint: str = "", collection: str = "", embedder=None):
|
|
135
|
+
import psycopg
|
|
136
|
+
from psycopg import sql
|
|
137
|
+
from pgvector.psycopg import register_vector
|
|
138
|
+
self.name = name
|
|
139
|
+
self.endpoint = endpoint or "postgresql://127.0.0.1:5432/rosen"
|
|
140
|
+
self.collection = collection or "rosen_semantic"
|
|
141
|
+
self._embedder = embedder
|
|
142
|
+
self._table = sql.Identifier(self.collection)
|
|
143
|
+
self._conn = psycopg.connect(self.endpoint, autocommit=True)
|
|
144
|
+
self._conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
|
145
|
+
register_vector(self._conn)
|
|
146
|
+
self._ready = False
|
|
147
|
+
|
|
148
|
+
def _embed(self, text: str) -> list:
|
|
149
|
+
if self._embedder is None:
|
|
150
|
+
raise RuntimeError(
|
|
151
|
+
"PgVectorMemory needs an embedder; bind one via Memory.spec.embedder")
|
|
152
|
+
return self._embedder.embed([text])[0]
|
|
153
|
+
|
|
154
|
+
def _ensure_table(self, dim: int):
|
|
155
|
+
# Lazily create the table on first use: the dimension comes from the
|
|
156
|
+
# embedder's vectors, so we never hardcode it.
|
|
157
|
+
if self._ready:
|
|
158
|
+
return
|
|
159
|
+
from psycopg import sql
|
|
160
|
+
self._conn.execute(sql.SQL(
|
|
161
|
+
"CREATE TABLE IF NOT EXISTS {table} ("
|
|
162
|
+
"id TEXT PRIMARY KEY, scope TEXT, text TEXT, "
|
|
163
|
+
"metadata JSONB, embedding vector({dim}))"
|
|
164
|
+
).format(table=self._table, dim=sql.Literal(int(dim))))
|
|
165
|
+
self._ready = True
|
|
166
|
+
|
|
167
|
+
def remember(self, text, metadata=None, scope=None) -> str:
|
|
168
|
+
from psycopg import sql
|
|
169
|
+
from psycopg.types.json import Jsonb
|
|
170
|
+
from pgvector import Vector
|
|
171
|
+
vector = self._embed(text)
|
|
172
|
+
self._ensure_table(len(vector))
|
|
173
|
+
item_id = uuid.uuid4().hex
|
|
174
|
+
self._conn.execute(
|
|
175
|
+
sql.SQL("INSERT INTO {table} (id, scope, text, metadata, embedding) "
|
|
176
|
+
"VALUES (%s, %s, %s, %s, %s)").format(table=self._table),
|
|
177
|
+
(item_id, scope, text, Jsonb(metadata or {}), Vector(vector)))
|
|
178
|
+
return item_id
|
|
179
|
+
|
|
180
|
+
def recall(self, query, k=5, scope=None) -> list:
|
|
181
|
+
from psycopg import sql
|
|
182
|
+
from pgvector import Vector
|
|
183
|
+
vector = self._embed(query)
|
|
184
|
+
self._ensure_table(len(vector))
|
|
185
|
+
where = sql.SQL("WHERE scope = %s") if scope is not None else sql.SQL("")
|
|
186
|
+
params = ([scope] if scope is not None else []) + [Vector(vector), k]
|
|
187
|
+
rows = self._conn.execute(
|
|
188
|
+
sql.SQL("SELECT id, text, metadata FROM {table} {where} "
|
|
189
|
+
"ORDER BY embedding <=> %s LIMIT %s").format(table=self._table, where=where),
|
|
190
|
+
params).fetchall()
|
|
191
|
+
return [{"id": r[0], "text": r[1], "metadata": r[2]} for r in rows]
|
|
192
|
+
|
|
193
|
+
def history(self, scope, k=None) -> list:
|
|
194
|
+
raise NotImplementedError("semantic memory has no transcript; use conversational store")
|
|
195
|
+
|
|
196
|
+
def close(self):
|
|
197
|
+
self._conn.close()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
_BACKENDS = {
|
|
201
|
+
"mock": MockMemory,
|
|
202
|
+
"inmemory": MockMemory,
|
|
203
|
+
"redis": RedisMemory,
|
|
204
|
+
"faiss": FaissMemory,
|
|
205
|
+
"pgvector": PgVectorMemory
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class Memory:
|
|
210
|
+
|
|
211
|
+
def __init__(self, name="", backend="", endpoint="", collection="", mode="", embedder=None):
|
|
212
|
+
self.name = name
|
|
213
|
+
self.backend = backend
|
|
214
|
+
self.endpoint = endpoint
|
|
215
|
+
self.collection = collection
|
|
216
|
+
self.mode = mode or "semantic"
|
|
217
|
+
built = build_embedder(embedder.get("engine", ""),
|
|
218
|
+
embedder.get("model", "")) if embedder else None
|
|
219
|
+
adapter_cls = _BACKENDS.get(backend, MockMemory)
|
|
220
|
+
self._adapter = adapter_cls(name, endpoint, collection, embedder=built)
|
|
221
|
+
|
|
222
|
+
def remember(self, text, metadata=None, scope=None) -> str:
|
|
223
|
+
return self._adapter.remember(text, metadata, scope)
|
|
224
|
+
|
|
225
|
+
def recall(self, query, k=5, scope=None) -> list:
|
|
226
|
+
return self._adapter.recall(query, k, scope)
|
|
227
|
+
|
|
228
|
+
def history(self, scope, k=None) -> list:
|
|
229
|
+
return self._adapter.history(scope, k)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class Memories:
|
|
233
|
+
|
|
234
|
+
def __init__(self, memories=None):
|
|
235
|
+
self._by_name = {m.name: m for m in (memories or [])}
|
|
236
|
+
|
|
237
|
+
def _by_mode(self, mode: str) -> Memory:
|
|
238
|
+
matches = [m for m in self._by_name.values() if m.mode == mode]
|
|
239
|
+
if not matches:
|
|
240
|
+
raise KeyError(f"no {mode} memory is bound to this agent")
|
|
241
|
+
if len(matches) > 1:
|
|
242
|
+
raise KeyError(f"multiple {mode} memory stores bound; address one by name")
|
|
243
|
+
return matches[0]
|
|
244
|
+
|
|
245
|
+
def has_mode(self, mode: str) -> bool:
|
|
246
|
+
return any(m.mode == mode for m in self._by_name.values())
|
|
247
|
+
|
|
248
|
+
def remember(self, text, metadata=None, scope=None) -> str:
|
|
249
|
+
mode = "conversational" if scope else "semantic"
|
|
250
|
+
return self._by_mode(mode).remember(text, metadata, scope)
|
|
251
|
+
|
|
252
|
+
def recall(self, query, k=5, scope=None) -> list:
|
|
253
|
+
return self._by_mode("semantic").recall(query, k, scope)
|
|
254
|
+
|
|
255
|
+
def history(self, scope, k=None) -> list:
|
|
256
|
+
return self._by_mode("conversational").history(scope, k)
|
|
257
|
+
|
|
258
|
+
def get(self, name: str) -> Memory:
|
|
259
|
+
try:
|
|
260
|
+
return self._by_name[name]
|
|
261
|
+
except KeyError:
|
|
262
|
+
raise KeyError(f"memory {name!r} not available to this agent")
|
|
263
|
+
|
|
264
|
+
def __getitem__(self, name: str) -> Memory:
|
|
265
|
+
return self.get(name)
|
|
266
|
+
|
|
267
|
+
def __contains__(self, name: str) -> bool:
|
|
268
|
+
return name in self._by_name
|
|
269
|
+
|
|
270
|
+
def __iter__(self):
|
|
271
|
+
return iter(self._by_name.values())
|
|
272
|
+
|
|
273
|
+
def __len__(self) -> int:
|
|
274
|
+
return len(self._by_name)
|
|
275
|
+
|
|
276
|
+
def name(self):
|
|
277
|
+
return list(self._by_name.keys())
|