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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amfs-http-server
3
- Version: 0.3.3
3
+ Version: 0.3.5
4
4
  Summary: AMFS HTTP/REST API server with SSE support
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.11
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "amfs-http-server"
3
- version = "0.3.3"
3
+ version = "0.3.5"
4
4
  description = "AMFS HTTP/REST API server with SSE support"
5
5
  requires-python = ">=3.11"
6
6
  license = "Apache-2.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 FROM amfs_agents "
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"]] = {"created_at": row["created_at"]}
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": ad["last_active"].isoformat() if ad["last_active"] else None,
1785
+ "lastActive": last_active.isoformat() if last_active else None,
1744
1786
  "createdAt": created.isoformat() if created else None,
1745
- "description": desc_info.get("description", ""),
1746
- "platform": desc_info.get("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
  # ──────────────────────────────────────────────────────────────────────