amfs-http-server 0.3.3__tar.gz → 0.3.5__tar.gz
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.
- {amfs_http_server-0.3.3 → amfs_http_server-0.3.5}/PKG-INFO +1 -1
- {amfs_http_server-0.3.3 → amfs_http_server-0.3.5}/pyproject.toml +1 -1
- amfs_http_server-0.3.5/src/amfs_http/openrouter_proxy.py +267 -0
- amfs_http_server-0.3.5/src/amfs_http/pro_provider.py +121 -0
- {amfs_http_server-0.3.3 → amfs_http_server-0.3.5}/src/amfs_http/server.py +316 -5
- {amfs_http_server-0.3.3 → amfs_http_server-0.3.5}/.gitignore +0 -0
- {amfs_http_server-0.3.3 → amfs_http_server-0.3.5}/src/amfs_http/__init__.py +0 -0
- {amfs_http_server-0.3.3 → amfs_http_server-0.3.5}/src/amfs_http/auth.py +0 -0
- {amfs_http_server-0.3.3 → amfs_http_server-0.3.5}/src/amfs_http/models.py +0 -0
- {amfs_http_server-0.3.3 → amfs_http_server-0.3.5}/src/amfs_http/pro_proxy.py +0 -0
- {amfs_http_server-0.3.3 → amfs_http_server-0.3.5}/src/amfs_http/sse.py +0 -0
- {amfs_http_server-0.3.3 → amfs_http_server-0.3.5}/src/amfs_http/tenant_middleware.py +0 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Memory-augmenting OpenRouter proxy (OpenAI-compatible chat completions).
|
|
2
|
+
|
|
3
|
+
This is the "Option C" proxy: a drop-in replacement for the OpenRouter chat
|
|
4
|
+
completions endpoint that (1) retrieves relevant memory for the caller's scope
|
|
5
|
+
and injects it as context, (2) forwards the request to OpenRouter unchanged
|
|
6
|
+
otherwise, and (3) writes salient facts from the exchange back to memory and
|
|
7
|
+
seals a decision trace — all without the client changing anything but the base
|
|
8
|
+
URL.
|
|
9
|
+
|
|
10
|
+
Design principles:
|
|
11
|
+
- Fail-open: any memory error (retrieval, injection, write-back) must never
|
|
12
|
+
break the LLM call. On failure we forward the original request untouched.
|
|
13
|
+
- Non-blocking: memory retrieval and write-back are synchronous SDK calls, so
|
|
14
|
+
they run in a threadpool (``asyncio.to_thread``) to keep the event loop free.
|
|
15
|
+
Write-back and trace sealing are fire-and-forget background tasks.
|
|
16
|
+
- Streaming: ``stream: true`` is passed through as an SSE byte stream.
|
|
17
|
+
|
|
18
|
+
Configuration (env):
|
|
19
|
+
- OPENROUTER_API_KEY upstream key (required to enable the route)
|
|
20
|
+
- OPENROUTER_BASE_URL default https://openrouter.ai/api/v1
|
|
21
|
+
- AMFS_PROXY_INJECT_LIMIT max memory facts injected (default 5)
|
|
22
|
+
- AMFS_PROXY_WRITE_BACK "true" to enable fact write-back (default false)
|
|
23
|
+
- AMFS_PROXY_SAFETY_GATE "false" to disable the Pro SafetyGate on write-back
|
|
24
|
+
(default true; no-op when amfs_safety is absent)
|
|
25
|
+
|
|
26
|
+
TODO(integration-test): tests/integration/test_openrouter_proxy.py exercises the
|
|
27
|
+
inject/forward/write-back path against a mocked upstream; it is skipped in unit
|
|
28
|
+
CI (requires fastapi TestClient + httpx-mock) and is unvalidated here.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import asyncio
|
|
34
|
+
import json
|
|
35
|
+
import logging
|
|
36
|
+
import os
|
|
37
|
+
from typing import Any, Callable
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
BASE_URL = os.environ.get("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1").rstrip("/")
|
|
42
|
+
API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
|
|
43
|
+
INJECT_LIMIT = int(os.environ.get("AMFS_PROXY_INJECT_LIMIT", "5"))
|
|
44
|
+
WRITE_BACK = os.environ.get("AMFS_PROXY_WRITE_BACK", "false").lower() == "true"
|
|
45
|
+
# Auto-run the Pro SafetyGate on write-back when available (secret block + PII
|
|
46
|
+
# redact). Disabled explicitly with AMFS_PROXY_SAFETY_GATE=false. Soft/optional:
|
|
47
|
+
# the gate lives in the Pro ``amfs_safety`` package; OSS-only deployments skip it.
|
|
48
|
+
SAFETY_GATE = os.environ.get("AMFS_PROXY_SAFETY_GATE", "true").lower() == "true"
|
|
49
|
+
|
|
50
|
+
_SAFETY_GATE_CACHE: dict[str, Any] = {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _gate_value(mem: Any, scope: str, key: str, value: Any) -> tuple[bool, Any]:
|
|
54
|
+
"""Run the optional Pro SafetyGate over a proposed write-back value.
|
|
55
|
+
|
|
56
|
+
Returns ``(allowed, value_to_write)`` — the value is redacted when the gate
|
|
57
|
+
masks sensitive content. Fail-open: any import/build/scan error allows the
|
|
58
|
+
original value through (the LLM exchange must never be lost to a gate bug).
|
|
59
|
+
"""
|
|
60
|
+
if not SAFETY_GATE:
|
|
61
|
+
return True, value
|
|
62
|
+
try:
|
|
63
|
+
if "gate" not in _SAFETY_GATE_CACHE:
|
|
64
|
+
from amfs_safety import SafetyGate # Pro package, optional
|
|
65
|
+
|
|
66
|
+
adapter = getattr(mem, "_adapter", None)
|
|
67
|
+
_SAFETY_GATE_CACHE["gate"] = SafetyGate(adapter=adapter)
|
|
68
|
+
gate = _SAFETY_GATE_CACHE["gate"]
|
|
69
|
+
|
|
70
|
+
from datetime import datetime, timezone
|
|
71
|
+
|
|
72
|
+
from amfs_core.models import MemoryEntry, Provenance
|
|
73
|
+
|
|
74
|
+
entry = MemoryEntry(
|
|
75
|
+
entity_path=scope,
|
|
76
|
+
key=key,
|
|
77
|
+
value=value,
|
|
78
|
+
provenance=Provenance(
|
|
79
|
+
agent_id="openrouter-proxy",
|
|
80
|
+
session_id="proxy",
|
|
81
|
+
written_at=datetime.now(timezone.utc),
|
|
82
|
+
),
|
|
83
|
+
confidence=0.5,
|
|
84
|
+
)
|
|
85
|
+
decision = gate.check_write(entry)
|
|
86
|
+
if not decision.allowed:
|
|
87
|
+
logger.info("proxy: write-back blocked by SafetyGate (%s)", decision.max_severity)
|
|
88
|
+
return False, value
|
|
89
|
+
return True, decision.entry.value
|
|
90
|
+
except Exception: # noqa: BLE001 - fail-open
|
|
91
|
+
logger.debug("proxy: SafetyGate unavailable/failed; allowing write", exc_info=True)
|
|
92
|
+
return True, value
|
|
93
|
+
|
|
94
|
+
_MEMORY_HEADER = (
|
|
95
|
+
"You have access to the following facts from persistent memory. "
|
|
96
|
+
"Treat them as authoritative context; if none are relevant, ignore them.\n"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def is_configured() -> bool:
|
|
101
|
+
return bool(API_KEY)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _last_user_text(messages: list[dict[str, Any]]) -> str:
|
|
105
|
+
for msg in reversed(messages):
|
|
106
|
+
if msg.get("role") == "user":
|
|
107
|
+
content = msg.get("content")
|
|
108
|
+
if isinstance(content, str):
|
|
109
|
+
return content
|
|
110
|
+
if isinstance(content, list): # OpenAI content-parts form
|
|
111
|
+
return " ".join(p.get("text", "") for p in content if isinstance(p, dict))
|
|
112
|
+
return ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _retrieve_facts(get_memory: Callable[[], Any], scope: str | None, query: str) -> list[str]:
|
|
116
|
+
"""Best-effort memory retrieval. Returns formatted fact strings (fail-open)."""
|
|
117
|
+
if not query:
|
|
118
|
+
return []
|
|
119
|
+
try:
|
|
120
|
+
mem = get_memory()
|
|
121
|
+
except Exception: # noqa: BLE001
|
|
122
|
+
logger.debug("proxy: get_memory failed", exc_info=True)
|
|
123
|
+
return []
|
|
124
|
+
# Prefer the Pro MultiStrategyRetriever (adaptive RRF + rerank) when the
|
|
125
|
+
# amfs_retrieval package is installed; otherwise SDK full-text/semantic
|
|
126
|
+
# search; otherwise list the scope. All fail-open.
|
|
127
|
+
try:
|
|
128
|
+
from amfs_http.pro_provider import build_pro_retriever, pro_retrieve_values
|
|
129
|
+
|
|
130
|
+
adapter = getattr(mem, "_adapter", None)
|
|
131
|
+
retriever = build_pro_retriever(adapter)
|
|
132
|
+
pro_values = pro_retrieve_values(retriever, scope, query, INJECT_LIMIT)
|
|
133
|
+
if pro_values is not None:
|
|
134
|
+
return [
|
|
135
|
+
v if isinstance(v, str) else json.dumps(v, default=str)
|
|
136
|
+
for v in pro_values
|
|
137
|
+
][:INJECT_LIMIT]
|
|
138
|
+
except Exception: # noqa: BLE001 - Pro path optional, fall through to OSS
|
|
139
|
+
logger.debug("proxy: pro retrieval skipped", exc_info=True)
|
|
140
|
+
|
|
141
|
+
# Prefer full-text/semantic search; fall back to listing the scope.
|
|
142
|
+
try:
|
|
143
|
+
search = getattr(mem, "search", None)
|
|
144
|
+
if callable(search):
|
|
145
|
+
results = search(query=query, entity_path=scope, limit=INJECT_LIMIT)
|
|
146
|
+
else:
|
|
147
|
+
results = mem._adapter.list(scope)[:INJECT_LIMIT]
|
|
148
|
+
except Exception: # noqa: BLE001
|
|
149
|
+
logger.debug("proxy: memory search failed", exc_info=True)
|
|
150
|
+
return []
|
|
151
|
+
facts: list[str] = []
|
|
152
|
+
for r in results or []:
|
|
153
|
+
value = getattr(r, "value", None)
|
|
154
|
+
if value is None and isinstance(r, dict):
|
|
155
|
+
value = r.get("value")
|
|
156
|
+
if value is not None:
|
|
157
|
+
facts.append(value if isinstance(value, str) else json.dumps(value, default=str))
|
|
158
|
+
return facts[:INJECT_LIMIT]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _inject(messages: list[dict[str, Any]], facts: list[str]) -> list[dict[str, Any]]:
|
|
162
|
+
if not facts:
|
|
163
|
+
return messages
|
|
164
|
+
block = _MEMORY_HEADER + "\n".join(f"- {f}" for f in facts)
|
|
165
|
+
# Prepend as a system message so it does not overwrite the caller's system prompt.
|
|
166
|
+
return [{"role": "system", "content": block}, *messages]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _write_back(get_memory: Callable[[], Any], scope: str | None,
|
|
170
|
+
query: str, answer: str) -> None:
|
|
171
|
+
"""Persist a salient fact from the exchange + seal a trace (best-effort)."""
|
|
172
|
+
if not WRITE_BACK or not scope or not answer:
|
|
173
|
+
return
|
|
174
|
+
try:
|
|
175
|
+
mem = get_memory()
|
|
176
|
+
key = f"proxy-exchange-{abs(hash(query)) % 10_000_000}"
|
|
177
|
+
value = {"q": query[:500], "a": answer[:1500]}
|
|
178
|
+
allowed, value = _gate_value(mem, scope, key, value)
|
|
179
|
+
if not allowed:
|
|
180
|
+
return
|
|
181
|
+
mem.write(scope, key, value, confidence=0.5)
|
|
182
|
+
commit = getattr(mem, "commit_outcome", None)
|
|
183
|
+
if callable(commit):
|
|
184
|
+
commit("proxy-exchange", "success")
|
|
185
|
+
except Exception: # noqa: BLE001
|
|
186
|
+
logger.debug("proxy: write-back failed", exc_info=True)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def mount_openrouter_proxy(app, *, get_memory: Callable[[], Any]) -> None:
|
|
190
|
+
"""Mount POST /api/v1/proxy/chat/completions if OpenRouter is configured."""
|
|
191
|
+
if not is_configured():
|
|
192
|
+
logger.info("OpenRouter proxy disabled (OPENROUTER_API_KEY not set)")
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
import httpx
|
|
196
|
+
from fastapi import Request
|
|
197
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
198
|
+
|
|
199
|
+
client = httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0))
|
|
200
|
+
|
|
201
|
+
def _scope_of(request: Request, body: dict[str, Any]) -> str | None:
|
|
202
|
+
return (body.get("amfs_scope")
|
|
203
|
+
or request.headers.get("X-AMFS-Scope")
|
|
204
|
+
or os.environ.get("AMFS_PROXY_DEFAULT_SCOPE"))
|
|
205
|
+
|
|
206
|
+
def _upstream_headers(request: Request) -> dict[str, str]:
|
|
207
|
+
headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
|
|
208
|
+
# Pass through OpenRouter attribution headers when present.
|
|
209
|
+
for h in ("HTTP-Referer", "X-Title"):
|
|
210
|
+
if h in request.headers:
|
|
211
|
+
headers[h] = request.headers[h]
|
|
212
|
+
return headers
|
|
213
|
+
|
|
214
|
+
@app.post("/api/v1/proxy/chat/completions")
|
|
215
|
+
async def proxy_chat_completions(request: Request):
|
|
216
|
+
raw = await request.body()
|
|
217
|
+
try:
|
|
218
|
+
body = json.loads(raw)
|
|
219
|
+
except Exception:
|
|
220
|
+
return JSONResponse({"error": "invalid JSON body"}, status_code=400)
|
|
221
|
+
|
|
222
|
+
scope = _scope_of(request, body)
|
|
223
|
+
messages = body.get("messages") or []
|
|
224
|
+
query = _last_user_text(messages)
|
|
225
|
+
|
|
226
|
+
# 1) Retrieve + inject memory (fail-open, off the event loop).
|
|
227
|
+
try:
|
|
228
|
+
facts = await asyncio.to_thread(_retrieve_facts, get_memory, scope, query)
|
|
229
|
+
if facts:
|
|
230
|
+
body = {**body, "messages": _inject(messages, facts)}
|
|
231
|
+
except Exception: # noqa: BLE001
|
|
232
|
+
logger.debug("proxy: injection skipped", exc_info=True)
|
|
233
|
+
|
|
234
|
+
body.pop("amfs_scope", None)
|
|
235
|
+
url = f"{BASE_URL}/chat/completions"
|
|
236
|
+
headers = _upstream_headers(request)
|
|
237
|
+
|
|
238
|
+
# 2a) Streaming: pass the SSE stream straight through.
|
|
239
|
+
if body.get("stream"):
|
|
240
|
+
async def _iter():
|
|
241
|
+
async with client.stream("POST", url, headers=headers, json=body) as resp:
|
|
242
|
+
async for chunk in resp.aiter_raw():
|
|
243
|
+
yield chunk
|
|
244
|
+
# NOTE: write-back on streamed responses requires accumulating the
|
|
245
|
+
# SSE deltas; deferred (TODO) to keep streaming zero-overhead.
|
|
246
|
+
return StreamingResponse(_iter(), media_type="text/event-stream")
|
|
247
|
+
|
|
248
|
+
# 2b) Non-streaming: forward, then schedule background write-back.
|
|
249
|
+
try:
|
|
250
|
+
resp = await client.post(url, headers=headers, json=body)
|
|
251
|
+
except Exception: # noqa: BLE001
|
|
252
|
+
logger.warning("proxy: upstream request failed", exc_info=True)
|
|
253
|
+
return JSONResponse({"error": "upstream unavailable"}, status_code=502)
|
|
254
|
+
|
|
255
|
+
data = resp.json() if resp.content else {}
|
|
256
|
+
try:
|
|
257
|
+
answer = data["choices"][0]["message"]["content"]
|
|
258
|
+
except Exception: # noqa: BLE001
|
|
259
|
+
answer = ""
|
|
260
|
+
|
|
261
|
+
if WRITE_BACK and answer:
|
|
262
|
+
asyncio.create_task(
|
|
263
|
+
asyncio.to_thread(_write_back, get_memory, scope, query, answer))
|
|
264
|
+
|
|
265
|
+
return JSONResponse(data, status_code=resp.status_code)
|
|
266
|
+
|
|
267
|
+
logger.info("OpenRouter memory proxy mounted → POST /api/v1/proxy/chat/completions")
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Optional Pro retrieval/sealing provider for the OpenRouter proxy.
|
|
2
|
+
|
|
3
|
+
The OSS proxy retrieves memory with the plain SDK ``search`` (full-text / list).
|
|
4
|
+
When the Pro ``amfs_retrieval`` package is installed, this module upgrades that to
|
|
5
|
+
the ``MultiStrategyRetriever`` (adaptive-weighted RRF + optional cross-encoder
|
|
6
|
+
rerank over a real default embedder), which is what wins the precision/adversarial
|
|
7
|
+
benchmarks. When ``amfs_traces`` is installed and sealing is enabled, write-backs
|
|
8
|
+
can be sealed into a signed ``ImmutableDecisionTrace``.
|
|
9
|
+
|
|
10
|
+
Everything here is soft/optional and fail-open:
|
|
11
|
+
- OSS-only deployments (no Pro packages) get ``None`` and the proxy falls back to
|
|
12
|
+
SDK search — no error, no behaviour change.
|
|
13
|
+
- Any construction/among failure degrades to the OSS path.
|
|
14
|
+
|
|
15
|
+
Env:
|
|
16
|
+
- AMFS_PROXY_PRO_RETRIEVAL "false" to force the OSS search path (default true)
|
|
17
|
+
- AMFS_PROXY_PRO_RERANK "false" to skip reranking entirely (default true)
|
|
18
|
+
- AMFS_LLM_RERANK "true" to use the LLM listwise reranker (Pro tier) —
|
|
19
|
+
the lever that reaches >90% adversarial Hit@1. Costs
|
|
20
|
+
one LLM call per retrieval, so it is opt-in. Needs
|
|
21
|
+
AMFS_LLM_RERANK_BASE_URL/API_KEY (e.g. OpenRouter).
|
|
22
|
+
When unset/unavailable, falls back to the offline
|
|
23
|
+
cross-encoder.
|
|
24
|
+
|
|
25
|
+
NOTE(integration-test): the Pro retrieval path requires the amfs-internal packages
|
|
26
|
+
and a populated adapter; it is exercised in the amfs-internal retrieval eval, not
|
|
27
|
+
in OSS unit CI. The unit test here only covers graceful absence + value extraction.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
import os
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
PRO_RETRIEVAL = os.environ.get("AMFS_PROXY_PRO_RETRIEVAL", "true").lower() == "true"
|
|
39
|
+
PRO_RERANK = os.environ.get("AMFS_PROXY_PRO_RERANK", "true").lower() == "true"
|
|
40
|
+
|
|
41
|
+
_cache: dict[str, Any] = {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _build_reranker() -> Any | None:
|
|
45
|
+
"""Build the strongest available reranker.
|
|
46
|
+
|
|
47
|
+
Prefers the LLM listwise reranker (Pro tier) when ``AMFS_LLM_RERANK=true`` and
|
|
48
|
+
an OpenAI-compatible client/key are configured — it resolves confusable facts
|
|
49
|
+
under adversarial paraphrase and is the lever for top-1 accuracy, at the cost
|
|
50
|
+
of one LLM call per retrieval. Otherwise falls back to the offline
|
|
51
|
+
``CrossEncoderReranker`` (single-digit ms, zero egress). Returns ``None`` only
|
|
52
|
+
if neither is importable.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
from amfs_retrieval import LLMReranker
|
|
56
|
+
|
|
57
|
+
llm = LLMReranker() # reads AMFS_LLM_RERANK* env
|
|
58
|
+
if llm.available:
|
|
59
|
+
logger.info("pro_provider: LLM listwise reranker enabled for proxy")
|
|
60
|
+
return llm
|
|
61
|
+
except Exception: # noqa: BLE001 - LLM rerank optional
|
|
62
|
+
logger.debug("pro_provider: LLM reranker unavailable", exc_info=True)
|
|
63
|
+
try:
|
|
64
|
+
from amfs_retrieval import CrossEncoderReranker
|
|
65
|
+
|
|
66
|
+
return CrossEncoderReranker()
|
|
67
|
+
except Exception: # noqa: BLE001 - rerank optional
|
|
68
|
+
logger.debug("pro_provider: reranker unavailable", exc_info=True)
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_pro_retriever(adapter: Any) -> Any | None:
|
|
73
|
+
"""Build a Pro MultiStrategyRetriever over ``adapter``, or None if unavailable.
|
|
74
|
+
|
|
75
|
+
Cached on the adapter identity so the embedder/reranker are loaded once.
|
|
76
|
+
"""
|
|
77
|
+
if not PRO_RETRIEVAL or adapter is None:
|
|
78
|
+
return None
|
|
79
|
+
cache_key = f"retriever:{id(adapter)}"
|
|
80
|
+
if cache_key in _cache:
|
|
81
|
+
return _cache[cache_key]
|
|
82
|
+
try:
|
|
83
|
+
from amfs_retrieval import MultiStrategyRetriever, create_pro_embedder
|
|
84
|
+
|
|
85
|
+
reranker = _build_reranker() if PRO_RERANK else None
|
|
86
|
+
|
|
87
|
+
retriever = MultiStrategyRetriever(
|
|
88
|
+
adapter, embedder=create_pro_embedder(), reranker=reranker
|
|
89
|
+
)
|
|
90
|
+
_cache[cache_key] = retriever
|
|
91
|
+
logger.info("pro_provider: Pro MultiStrategyRetriever enabled for proxy")
|
|
92
|
+
return retriever
|
|
93
|
+
except Exception: # noqa: BLE001 - Pro package absent or failed to build
|
|
94
|
+
logger.debug("pro_provider: Pro retrieval unavailable", exc_info=True)
|
|
95
|
+
_cache[cache_key] = None
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _result_value(result: Any) -> Any:
|
|
100
|
+
"""Extract the stored value from a RetrievalResult-like object or dict."""
|
|
101
|
+
entry = getattr(result, "entry", None)
|
|
102
|
+
if entry is None and isinstance(result, dict):
|
|
103
|
+
entry = result.get("entry", result)
|
|
104
|
+
if isinstance(entry, dict):
|
|
105
|
+
return entry.get("value")
|
|
106
|
+
return getattr(entry, "value", None)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def pro_retrieve_values(
|
|
110
|
+
retriever: Any, scope: str | None, query: str, limit: int
|
|
111
|
+
) -> list[Any] | None:
|
|
112
|
+
"""Run Pro retrieval and return raw stored values (fail-open -> None)."""
|
|
113
|
+
if retriever is None or not query:
|
|
114
|
+
return None
|
|
115
|
+
try:
|
|
116
|
+
results = retriever.retrieve(query, entity_path=scope, limit=limit)
|
|
117
|
+
except Exception: # noqa: BLE001
|
|
118
|
+
logger.debug("pro_provider: retrieve failed", exc_info=True)
|
|
119
|
+
return None
|
|
120
|
+
values = [v for v in (_result_value(r) for r in results or []) if v is not None]
|
|
121
|
+
return values or None
|
|
@@ -35,6 +35,7 @@ from amfs import AgentMemory, MemoryType, OutcomeType
|
|
|
35
35
|
from amfs.config import load_config_or_default
|
|
36
36
|
from pydantic import BaseModel, Field
|
|
37
37
|
from amfs_core.models import (
|
|
38
|
+
AgentGroup,
|
|
38
39
|
AMFSConfig,
|
|
39
40
|
DecisionTrace,
|
|
40
41
|
Event,
|
|
@@ -217,6 +218,12 @@ try:
|
|
|
217
218
|
except ImportError:
|
|
218
219
|
pass
|
|
219
220
|
|
|
221
|
+
try:
|
|
222
|
+
from amfs_http.openrouter_proxy import mount_openrouter_proxy
|
|
223
|
+
mount_openrouter_proxy(app, get_memory=_get_memory)
|
|
224
|
+
except Exception: # noqa: BLE001 - proxy is optional; never block startup
|
|
225
|
+
logger.debug("OpenRouter proxy not mounted", exc_info=True)
|
|
226
|
+
|
|
220
227
|
try:
|
|
221
228
|
from amfs_tenant.http_deps import mount_scope_enforcement
|
|
222
229
|
mount_scope_enforcement(app)
|
|
@@ -1027,6 +1034,7 @@ def _infer_tags(
|
|
|
1027
1034
|
|
|
1028
1035
|
@app.put("/api/v1/agents/{agent_id:path}/profile")
|
|
1029
1036
|
async def update_agent_profile(
|
|
1037
|
+
request: Request,
|
|
1030
1038
|
agent_id: str,
|
|
1031
1039
|
body: dict[str, Any],
|
|
1032
1040
|
_auth: str | None = Depends(verify_api_key),
|
|
@@ -1036,6 +1044,10 @@ async def update_agent_profile(
|
|
|
1036
1044
|
mem = _get_memory()
|
|
1037
1045
|
profile = AgentProfile.model_validate(body)
|
|
1038
1046
|
agent = mem._adapter.update_agent_profile(agent_id, profile)
|
|
1047
|
+
# Link the agent to the API key's owner so it shows on the user's dashboard
|
|
1048
|
+
# immediately — set_identity announces through this endpoint before the
|
|
1049
|
+
# agent has written any memory, so the write-path owner link never fires.
|
|
1050
|
+
_ensure_agent_owner(request, agent_id, mem.namespace)
|
|
1039
1051
|
return json.loads(json.dumps(agent.model_dump(mode="json"), default=str))
|
|
1040
1052
|
|
|
1041
1053
|
|
|
@@ -1694,6 +1706,18 @@ async def list_agents(
|
|
|
1694
1706
|
own = vis.get_user_agents()
|
|
1695
1707
|
before_own_filter = list(agent_data.keys())
|
|
1696
1708
|
agent_data = {aid: d for aid, d in agent_data.items() if aid in own}
|
|
1709
|
+
# Include owner-linked agents that have written zero entries (e.g. an
|
|
1710
|
+
# agent that called set_identity over MCP but hasn't written memory
|
|
1711
|
+
# yet). Without this they never appear on the dashboard.
|
|
1712
|
+
for aid in own:
|
|
1713
|
+
if aid not in agent_data:
|
|
1714
|
+
agent_data[aid] = {
|
|
1715
|
+
"agent_id": aid,
|
|
1716
|
+
"entries_written": 0,
|
|
1717
|
+
"entities_touched": set(),
|
|
1718
|
+
"last_active": None,
|
|
1719
|
+
"first_seen": None,
|
|
1720
|
+
}
|
|
1697
1721
|
logger.warning(
|
|
1698
1722
|
"[AGENTS] own_filter: before=%s after=%s own_set=%s",
|
|
1699
1723
|
sorted(before_own_filter), sorted(agent_data.keys()), sorted(own),
|
|
@@ -1710,12 +1734,17 @@ async def list_agents(
|
|
|
1710
1734
|
with conn.cursor() as cur:
|
|
1711
1735
|
placeholders = ", ".join(["%s"] * len(known_agent_ids))
|
|
1712
1736
|
cur.execute(
|
|
1713
|
-
f"SELECT agent_id, created_at
|
|
1737
|
+
f"SELECT agent_id, created_at, last_active_at, profile "
|
|
1738
|
+
f"FROM amfs_agents "
|
|
1714
1739
|
f"WHERE namespace = %s AND agent_id IN ({placeholders})",
|
|
1715
1740
|
[adapter._namespace, *known_agent_ids],
|
|
1716
1741
|
)
|
|
1717
1742
|
for row in cur.fetchall():
|
|
1718
|
-
agent_registration[row["agent_id"]] = {
|
|
1743
|
+
agent_registration[row["agent_id"]] = {
|
|
1744
|
+
"created_at": row["created_at"],
|
|
1745
|
+
"last_active_at": row.get("last_active_at"),
|
|
1746
|
+
"profile": row.get("profile"),
|
|
1747
|
+
}
|
|
1719
1748
|
except (ImportError, Exception):
|
|
1720
1749
|
pass
|
|
1721
1750
|
|
|
@@ -1736,14 +1765,27 @@ async def list_agents(
|
|
|
1736
1765
|
desc_info = agent_descriptions.get(ad["agent_id"], {})
|
|
1737
1766
|
reg = agent_registration.get(ad["agent_id"], {})
|
|
1738
1767
|
created = reg.get("created_at") or ad.get("first_seen")
|
|
1768
|
+
# Zero-write agents have no entry-derived activity; fall back to the
|
|
1769
|
+
# registration row (set via set_identity / profile update).
|
|
1770
|
+
last_active = ad["last_active"] or reg.get("last_active_at")
|
|
1771
|
+
description = desc_info.get("description", "")
|
|
1772
|
+
platform = desc_info.get("platform", "")
|
|
1773
|
+
prof = reg.get("profile")
|
|
1774
|
+
if isinstance(prof, dict):
|
|
1775
|
+
if not description:
|
|
1776
|
+
description = prof.get("description", "") or ""
|
|
1777
|
+
if not platform:
|
|
1778
|
+
sm = prof.get("session_metadata")
|
|
1779
|
+
if isinstance(sm, dict):
|
|
1780
|
+
platform = sm.get("platform", "") or ""
|
|
1739
1781
|
agents.append({
|
|
1740
1782
|
"agentId": ad["agent_id"],
|
|
1741
1783
|
"entriesWritten": ad["entries_written"],
|
|
1742
1784
|
"entitiesTouched": len(ad["entities_touched"]),
|
|
1743
|
-
"lastActive":
|
|
1785
|
+
"lastActive": last_active.isoformat() if last_active else None,
|
|
1744
1786
|
"createdAt": created.isoformat() if created else None,
|
|
1745
|
-
"description":
|
|
1746
|
-
"platform":
|
|
1787
|
+
"description": description,
|
|
1788
|
+
"platform": platform,
|
|
1747
1789
|
})
|
|
1748
1790
|
return {"agents": agents}
|
|
1749
1791
|
|
|
@@ -2095,6 +2137,17 @@ async def log_timeline_event(
|
|
|
2095
2137
|
details=body.details,
|
|
2096
2138
|
actor_agent_id=body.actor_agent_id,
|
|
2097
2139
|
)
|
|
2140
|
+
# WRITE events are authoritatively logged by the write handler
|
|
2141
|
+
# (POST /api/v1/entries). HTTP-backed SDK clients ALSO emit a WRITE event
|
|
2142
|
+
# here from their background log path, which produced two identical WRITE
|
|
2143
|
+
# events per write (the "double-write" bug). The server is the single
|
|
2144
|
+
# source of truth for WRITE timeline events, so drop client-originated
|
|
2145
|
+
# ones here. This is version-agnostic: it fixes every client regardless of
|
|
2146
|
+
# which SDK version they run via `uvx`, without waiting on a PyPI release.
|
|
2147
|
+
# We still return a well-formed (but unpersisted) event so clients that
|
|
2148
|
+
# parse the response don't error.
|
|
2149
|
+
if event_type_enum is EventType.WRITE:
|
|
2150
|
+
return event.model_dump(mode="json")
|
|
2098
2151
|
saved = mem._adapter.log_event(event)
|
|
2099
2152
|
return saved.model_dump(mode="json")
|
|
2100
2153
|
|
|
@@ -2128,6 +2181,264 @@ async def upsert_graph_edge_endpoint(
|
|
|
2128
2181
|
return {"status": "ok"}
|
|
2129
2182
|
|
|
2130
2183
|
|
|
2184
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
2185
|
+
# Agent Groups & Enriched Agents
|
|
2186
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
2187
|
+
|
|
2188
|
+
|
|
2189
|
+
@app.get("/api/v1/agents/enriched")
|
|
2190
|
+
async def list_agents_enriched(
|
|
2191
|
+
_auth: str | None = Depends(verify_api_key),
|
|
2192
|
+
) -> dict[str, Any]:
|
|
2193
|
+
"""Return enriched agent info with activity histograms."""
|
|
2194
|
+
mem = _get_memory()
|
|
2195
|
+
agents = mem._adapter.list_agents_enriched(namespace=mem.namespace)
|
|
2196
|
+
for agent in agents[:100]:
|
|
2197
|
+
aid = agent.get("agent_id") or agent.get("agentId", "")
|
|
2198
|
+
if aid:
|
|
2199
|
+
agent["activity_histogram"] = mem._adapter.get_agent_activity_histogram(
|
|
2200
|
+
aid, days=7, namespace=mem.namespace,
|
|
2201
|
+
)
|
|
2202
|
+
return {"agents": agents}
|
|
2203
|
+
|
|
2204
|
+
|
|
2205
|
+
@app.get("/api/v1/agent-groups")
|
|
2206
|
+
async def list_agent_groups(
|
|
2207
|
+
_auth: str | None = Depends(verify_api_key),
|
|
2208
|
+
) -> dict[str, Any]:
|
|
2209
|
+
"""List all agent groups."""
|
|
2210
|
+
mem = _get_memory()
|
|
2211
|
+
groups = mem._adapter.list_agent_groups(namespace=mem.namespace)
|
|
2212
|
+
return {"groups": [g.model_dump(mode="json") for g in groups]}
|
|
2213
|
+
|
|
2214
|
+
|
|
2215
|
+
@app.post("/api/v1/agent-groups", status_code=201)
|
|
2216
|
+
async def create_agent_group_endpoint(
|
|
2217
|
+
request: Request,
|
|
2218
|
+
_auth: str | None = Depends(verify_api_key),
|
|
2219
|
+
) -> dict[str, Any]:
|
|
2220
|
+
"""Create a new agent group."""
|
|
2221
|
+
body = await request.json()
|
|
2222
|
+
name = body.get("name")
|
|
2223
|
+
if not name:
|
|
2224
|
+
return JSONResponse(
|
|
2225
|
+
status_code=400,
|
|
2226
|
+
content={"error": "name is required"},
|
|
2227
|
+
)
|
|
2228
|
+
mem = _get_memory()
|
|
2229
|
+
group = AgentGroup(
|
|
2230
|
+
namespace=mem.namespace,
|
|
2231
|
+
name=name,
|
|
2232
|
+
description=body.get("description", ""),
|
|
2233
|
+
color=body.get("color"),
|
|
2234
|
+
icon=body.get("icon"),
|
|
2235
|
+
position=body.get("position", 0.0),
|
|
2236
|
+
auto_generated=body.get("autoGenerated", False),
|
|
2237
|
+
source_cluster_id=body.get("sourceClusterId"),
|
|
2238
|
+
)
|
|
2239
|
+
created = mem._adapter.create_agent_group(group, namespace=mem.namespace)
|
|
2240
|
+
return created.model_dump(mode="json")
|
|
2241
|
+
|
|
2242
|
+
|
|
2243
|
+
@app.put("/api/v1/agent-groups/{group_id}")
|
|
2244
|
+
async def update_agent_group_endpoint(
|
|
2245
|
+
group_id: str,
|
|
2246
|
+
request: Request,
|
|
2247
|
+
_auth: str | None = Depends(verify_api_key),
|
|
2248
|
+
) -> dict[str, Any]:
|
|
2249
|
+
"""Update an existing agent group."""
|
|
2250
|
+
body = await request.json()
|
|
2251
|
+
mem = _get_memory()
|
|
2252
|
+
kwargs: dict[str, Any] = {}
|
|
2253
|
+
for field in ("name", "description", "color", "icon", "position"):
|
|
2254
|
+
if field in body and body[field] is not None:
|
|
2255
|
+
kwargs[field] = body[field]
|
|
2256
|
+
updated = mem._adapter.update_agent_group(
|
|
2257
|
+
group_id, namespace=mem.namespace, **kwargs,
|
|
2258
|
+
)
|
|
2259
|
+
if updated is None:
|
|
2260
|
+
raise HTTPException(status_code=404, detail="Group not found")
|
|
2261
|
+
return updated.model_dump(mode="json")
|
|
2262
|
+
|
|
2263
|
+
|
|
2264
|
+
@app.delete("/api/v1/agent-groups/{group_id}")
|
|
2265
|
+
async def delete_agent_group_endpoint(
|
|
2266
|
+
group_id: str,
|
|
2267
|
+
_auth: str | None = Depends(verify_api_key),
|
|
2268
|
+
) -> dict[str, Any]:
|
|
2269
|
+
"""Delete an agent group."""
|
|
2270
|
+
mem = _get_memory()
|
|
2271
|
+
deleted = mem._adapter.delete_agent_group(group_id, namespace=mem.namespace)
|
|
2272
|
+
return {"deleted": deleted}
|
|
2273
|
+
|
|
2274
|
+
|
|
2275
|
+
@app.post("/api/v1/agent-groups/{group_id}/members")
|
|
2276
|
+
async def add_group_members(
|
|
2277
|
+
group_id: str,
|
|
2278
|
+
request: Request,
|
|
2279
|
+
_auth: str | None = Depends(verify_api_key),
|
|
2280
|
+
) -> dict[str, Any]:
|
|
2281
|
+
"""Add agents to a group."""
|
|
2282
|
+
body = await request.json()
|
|
2283
|
+
agent_ids = body.get("agent_ids")
|
|
2284
|
+
if not agent_ids or not isinstance(agent_ids, list):
|
|
2285
|
+
return JSONResponse(
|
|
2286
|
+
status_code=400,
|
|
2287
|
+
content={"error": "agent_ids list is required"},
|
|
2288
|
+
)
|
|
2289
|
+
mem = _get_memory()
|
|
2290
|
+
count = mem._adapter.add_agents_to_group(
|
|
2291
|
+
group_id, agent_ids, namespace=mem.namespace,
|
|
2292
|
+
)
|
|
2293
|
+
return {"added": count}
|
|
2294
|
+
|
|
2295
|
+
|
|
2296
|
+
@app.delete("/api/v1/agent-groups/{group_id}/members")
|
|
2297
|
+
async def remove_group_members(
|
|
2298
|
+
group_id: str,
|
|
2299
|
+
request: Request,
|
|
2300
|
+
_auth: str | None = Depends(verify_api_key),
|
|
2301
|
+
) -> dict[str, Any]:
|
|
2302
|
+
"""Remove agents from a group."""
|
|
2303
|
+
body = await request.json()
|
|
2304
|
+
agent_ids = body.get("agent_ids")
|
|
2305
|
+
if not agent_ids or not isinstance(agent_ids, list):
|
|
2306
|
+
return JSONResponse(
|
|
2307
|
+
status_code=400,
|
|
2308
|
+
content={"error": "agent_ids list is required"},
|
|
2309
|
+
)
|
|
2310
|
+
mem = _get_memory()
|
|
2311
|
+
count = mem._adapter.remove_agents_from_group(
|
|
2312
|
+
group_id, agent_ids, namespace=mem.namespace,
|
|
2313
|
+
)
|
|
2314
|
+
return {"removed": count}
|
|
2315
|
+
|
|
2316
|
+
|
|
2317
|
+
@app.put("/api/v1/agent-groups/reorder")
|
|
2318
|
+
async def reorder_agent_groups_endpoint(
|
|
2319
|
+
request: Request,
|
|
2320
|
+
_auth: str | None = Depends(verify_api_key),
|
|
2321
|
+
) -> dict[str, Any]:
|
|
2322
|
+
"""Reorder agent groups by setting new positions."""
|
|
2323
|
+
body = await request.json()
|
|
2324
|
+
positions = body.get("positions")
|
|
2325
|
+
if not positions or not isinstance(positions, list):
|
|
2326
|
+
return JSONResponse(
|
|
2327
|
+
status_code=400,
|
|
2328
|
+
content={"error": "positions list is required"},
|
|
2329
|
+
)
|
|
2330
|
+
tuples = [(p["group_id"], p["position"]) for p in positions]
|
|
2331
|
+
mem = _get_memory()
|
|
2332
|
+
mem._adapter.reorder_agent_groups(tuples, namespace=mem.namespace)
|
|
2333
|
+
return {"ok": True}
|
|
2334
|
+
|
|
2335
|
+
|
|
2336
|
+
@app.get("/api/v1/agent-groups/suggestions")
|
|
2337
|
+
async def agent_group_suggestions(
|
|
2338
|
+
_auth: str | None = Depends(verify_api_key),
|
|
2339
|
+
) -> dict[str, Any]:
|
|
2340
|
+
"""Return cluster-based group suggestions, excluding dismissed ones."""
|
|
2341
|
+
from amfs_core.models import DigestType
|
|
2342
|
+
|
|
2343
|
+
mem = _get_memory()
|
|
2344
|
+
adapter = mem._adapter
|
|
2345
|
+
digest = adapter.get_digest(
|
|
2346
|
+
DigestType.AGENT_CLUSTERS,
|
|
2347
|
+
f"account:{mem.namespace}",
|
|
2348
|
+
namespace=mem.namespace,
|
|
2349
|
+
)
|
|
2350
|
+
if not digest:
|
|
2351
|
+
return {"suggestions": []}
|
|
2352
|
+
|
|
2353
|
+
try:
|
|
2354
|
+
dismissed = set(adapter.list_dismissed_cluster_ids(mem.namespace))
|
|
2355
|
+
except Exception:
|
|
2356
|
+
dismissed = set()
|
|
2357
|
+
|
|
2358
|
+
clusters = digest.summary.get("clusters", [])
|
|
2359
|
+
suggestions = [c for c in clusters if c.get("cluster_id") not in dismissed]
|
|
2360
|
+
return {"suggestions": suggestions}
|
|
2361
|
+
|
|
2362
|
+
|
|
2363
|
+
@app.post("/api/v1/agent-groups/suggestions/{cluster_id}/accept", status_code=201)
|
|
2364
|
+
async def accept_cluster_suggestion(
|
|
2365
|
+
cluster_id: str,
|
|
2366
|
+
_auth: str | None = Depends(verify_api_key),
|
|
2367
|
+
) -> dict[str, Any]:
|
|
2368
|
+
"""Accept a cluster suggestion — create a group from it."""
|
|
2369
|
+
from amfs_core.models import DigestType
|
|
2370
|
+
|
|
2371
|
+
mem = _get_memory()
|
|
2372
|
+
adapter = mem._adapter
|
|
2373
|
+
digest = adapter.get_digest(
|
|
2374
|
+
DigestType.AGENT_CLUSTERS,
|
|
2375
|
+
f"account:{mem.namespace}",
|
|
2376
|
+
namespace=mem.namespace,
|
|
2377
|
+
)
|
|
2378
|
+
if not digest:
|
|
2379
|
+
raise HTTPException(status_code=404, detail="No cluster digest found")
|
|
2380
|
+
|
|
2381
|
+
clusters = digest.summary.get("clusters", [])
|
|
2382
|
+
cluster = next((c for c in clusters if c.get("cluster_id") == cluster_id), None)
|
|
2383
|
+
if cluster is None:
|
|
2384
|
+
raise HTTPException(status_code=404, detail="Cluster not found")
|
|
2385
|
+
|
|
2386
|
+
group = AgentGroup(
|
|
2387
|
+
namespace=mem.namespace,
|
|
2388
|
+
name=cluster.get("suggested_name", cluster_id),
|
|
2389
|
+
description=cluster.get("rationale", ""),
|
|
2390
|
+
auto_generated=True,
|
|
2391
|
+
source_cluster_id=cluster_id,
|
|
2392
|
+
)
|
|
2393
|
+
created = adapter.create_agent_group(group, namespace=mem.namespace)
|
|
2394
|
+
|
|
2395
|
+
agent_ids = cluster.get("agents", [])
|
|
2396
|
+
if agent_ids:
|
|
2397
|
+
adapter.add_agents_to_group(created.id, agent_ids, namespace=mem.namespace)
|
|
2398
|
+
|
|
2399
|
+
return created.model_dump(mode="json")
|
|
2400
|
+
|
|
2401
|
+
|
|
2402
|
+
@app.post("/api/v1/agent-groups/suggestions/{cluster_id}/dismiss")
|
|
2403
|
+
async def dismiss_cluster_suggestion_endpoint(
|
|
2404
|
+
cluster_id: str,
|
|
2405
|
+
_auth: str | None = Depends(verify_api_key),
|
|
2406
|
+
) -> dict[str, Any]:
|
|
2407
|
+
"""Dismiss a cluster suggestion so it no longer appears."""
|
|
2408
|
+
mem = _get_memory()
|
|
2409
|
+
mem._adapter.dismiss_cluster_suggestion(cluster_id, mem.namespace)
|
|
2410
|
+
return {"dismissed": True}
|
|
2411
|
+
|
|
2412
|
+
|
|
2413
|
+
@app.post("/api/v1/agent-groups/recompute")
|
|
2414
|
+
async def recompute_clusters(
|
|
2415
|
+
_auth: str | None = Depends(verify_api_key),
|
|
2416
|
+
) -> dict[str, Any]:
|
|
2417
|
+
"""Trigger recomputation of agent clusters."""
|
|
2418
|
+
try:
|
|
2419
|
+
from amfs_cortex.compiler import DigestCompiler
|
|
2420
|
+
from amfs_postgres.adapter import PostgresAdapter
|
|
2421
|
+
|
|
2422
|
+
mem = _get_memory()
|
|
2423
|
+
adapter = mem._adapter
|
|
2424
|
+
if not isinstance(adapter, PostgresAdapter):
|
|
2425
|
+
return JSONResponse(
|
|
2426
|
+
status_code=400,
|
|
2427
|
+
content={"error": "Cluster recomputation requires Postgres adapter"},
|
|
2428
|
+
)
|
|
2429
|
+
compiler = DigestCompiler(
|
|
2430
|
+
adapter=adapter,
|
|
2431
|
+
namespace=mem.namespace,
|
|
2432
|
+
)
|
|
2433
|
+
compiler.compile(f"cluster:account:{mem.namespace}")
|
|
2434
|
+
except ImportError:
|
|
2435
|
+
return JSONResponse(
|
|
2436
|
+
status_code=400,
|
|
2437
|
+
content={"error": "amfs-cortex package is required for recomputation"},
|
|
2438
|
+
)
|
|
2439
|
+
return {"ok": True}
|
|
2440
|
+
|
|
2441
|
+
|
|
2131
2442
|
# ──────────────────────────────────────────────────────────────────────
|
|
2132
2443
|
# Agent Snapshots
|
|
2133
2444
|
# ──────────────────────────────────────────────────────────────────────
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|