amfs-adapter-http 0.1.0__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.
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: amfs-adapter-http
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AMFS HTTP adapter — routes memory operations through the authenticated REST API
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: amfs-core
|
|
8
|
+
Requires-Dist: httpx<1,>=0.27
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "amfs-adapter-http"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "AMFS HTTP adapter — routes memory operations through the authenticated REST API"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"amfs-core",
|
|
9
|
+
"httpx>=0.27,<1",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["hatchling"]
|
|
14
|
+
build-backend = "hatchling.build"
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
packages = ["src/amfs_adapter_http"]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""HttpAdapter — AdapterABC implementation that proxies all operations through the AMFS HTTP API.
|
|
2
|
+
|
|
3
|
+
Every call carries the ``X-AMFS-API-Key`` header so that the server-side tenant
|
|
4
|
+
middleware can enforce row-level security. This adapter is intentionally
|
|
5
|
+
**synchronous** (httpx sync client) because the MCP server tool functions are
|
|
6
|
+
synchronous.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from amfs_core.abc import AdapterABC, WatchHandle
|
|
18
|
+
from amfs_core.models import (
|
|
19
|
+
DecisionTrace,
|
|
20
|
+
MemoryEntry,
|
|
21
|
+
MemoryStats,
|
|
22
|
+
OutcomeRecord,
|
|
23
|
+
SearchQuery,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_entry(data: dict[str, Any]) -> MemoryEntry:
|
|
32
|
+
"""Reconstruct a MemoryEntry from a JSON dict returned by the API."""
|
|
33
|
+
return MemoryEntry.model_validate(data)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HttpAdapter(AdapterABC):
|
|
37
|
+
"""Storage adapter that delegates to the AMFS HTTP/REST API.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
base_url: Root URL of the AMFS HTTP server (e.g. ``https://amfs-api-xxx.run.app``).
|
|
41
|
+
api_key: Value sent in the ``X-AMFS-API-Key`` header for tenant scoping.
|
|
42
|
+
timeout: Optional httpx Timeout override.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
base_url: str,
|
|
48
|
+
api_key: str,
|
|
49
|
+
*,
|
|
50
|
+
timeout: httpx.Timeout | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self._base = base_url.rstrip("/")
|
|
53
|
+
self._api_key = api_key
|
|
54
|
+
self._client = httpx.Client(
|
|
55
|
+
base_url=self._base,
|
|
56
|
+
headers={"X-AMFS-API-Key": api_key},
|
|
57
|
+
timeout=timeout or _TIMEOUT,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# ── helpers ────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
def _get(self, path: str, **params: Any) -> Any:
|
|
63
|
+
resp = self._client.get(path, params={k: v for k, v in params.items() if v is not None})
|
|
64
|
+
resp.raise_for_status()
|
|
65
|
+
return resp.json()
|
|
66
|
+
|
|
67
|
+
def _post(self, path: str, body: dict[str, Any] | None = None) -> Any:
|
|
68
|
+
resp = self._client.post(path, json=body or {})
|
|
69
|
+
resp.raise_for_status()
|
|
70
|
+
return resp.json()
|
|
71
|
+
|
|
72
|
+
# ── required abstract methods ─────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
def read(
|
|
75
|
+
self,
|
|
76
|
+
entity_path: str,
|
|
77
|
+
key: str,
|
|
78
|
+
*,
|
|
79
|
+
min_confidence: float = 0.0,
|
|
80
|
+
) -> MemoryEntry | None:
|
|
81
|
+
data = self._get(f"/api/v1/entries/{entity_path}/{key}")
|
|
82
|
+
if data.get("status") == "not_found":
|
|
83
|
+
return None
|
|
84
|
+
entry = _parse_entry(data)
|
|
85
|
+
if entry.confidence < min_confidence:
|
|
86
|
+
return None
|
|
87
|
+
return entry
|
|
88
|
+
|
|
89
|
+
def write(self, entry: MemoryEntry) -> MemoryEntry:
|
|
90
|
+
body = {
|
|
91
|
+
"entity_path": entry.entity_path,
|
|
92
|
+
"key": entry.key,
|
|
93
|
+
"value": entry.value,
|
|
94
|
+
"confidence": entry.confidence,
|
|
95
|
+
"pattern_refs": entry.provenance.pattern_refs,
|
|
96
|
+
"memory_type": entry.memory_type.value if hasattr(entry.memory_type, "value") else str(entry.memory_type),
|
|
97
|
+
"shared": entry.shared,
|
|
98
|
+
"branch": entry.branch,
|
|
99
|
+
}
|
|
100
|
+
data = self._post("/api/v1/entries", body)
|
|
101
|
+
return _parse_entry(data)
|
|
102
|
+
|
|
103
|
+
def list(
|
|
104
|
+
self,
|
|
105
|
+
entity_path: str | None = None,
|
|
106
|
+
*,
|
|
107
|
+
include_superseded: bool = False,
|
|
108
|
+
) -> list[MemoryEntry]:
|
|
109
|
+
data = self._get("/api/v1/entries", entity_path=entity_path)
|
|
110
|
+
return [_parse_entry(e) for e in data.get("entries", [])]
|
|
111
|
+
|
|
112
|
+
def watch(
|
|
113
|
+
self,
|
|
114
|
+
entity_path: str,
|
|
115
|
+
callback: Callable[[MemoryEntry], None],
|
|
116
|
+
) -> WatchHandle:
|
|
117
|
+
logger.warning("HttpAdapter.watch() is a no-op over HTTP; use SSE streaming instead")
|
|
118
|
+
return WatchHandle(cancel_fn=lambda: None)
|
|
119
|
+
|
|
120
|
+
def commit_outcome(self, record: OutcomeRecord) -> list[MemoryEntry]:
|
|
121
|
+
body = {
|
|
122
|
+
"outcome_ref": record.outcome_ref,
|
|
123
|
+
"outcome_type": record.outcome_type.value if hasattr(record.outcome_type, "value") else str(record.outcome_type),
|
|
124
|
+
"causal_entry_keys": record.causal_entry_keys,
|
|
125
|
+
"causal_confidence": record.causal_confidence,
|
|
126
|
+
}
|
|
127
|
+
data = self._post("/api/v1/outcomes", body)
|
|
128
|
+
return [_parse_entry(e) for e in data.get("entries", [])]
|
|
129
|
+
|
|
130
|
+
# ── optional overrides ────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
def search(self, query: SearchQuery) -> list[MemoryEntry]:
|
|
133
|
+
body: dict[str, Any] = {
|
|
134
|
+
"entity_path": query.entity_path,
|
|
135
|
+
"min_confidence": query.min_confidence,
|
|
136
|
+
"limit": query.limit,
|
|
137
|
+
"sort_by": query.sort_by or "confidence",
|
|
138
|
+
}
|
|
139
|
+
if query.query:
|
|
140
|
+
body["query"] = query.query
|
|
141
|
+
if query.max_confidence is not None:
|
|
142
|
+
body["max_confidence"] = query.max_confidence
|
|
143
|
+
if query.agent_id:
|
|
144
|
+
body["agent_id"] = query.agent_id
|
|
145
|
+
if query.since:
|
|
146
|
+
body["since"] = query.since.isoformat()
|
|
147
|
+
if query.pattern_ref:
|
|
148
|
+
body["pattern_ref"] = query.pattern_ref
|
|
149
|
+
data = self._post("/api/v1/search", body)
|
|
150
|
+
if isinstance(data, list):
|
|
151
|
+
return [_parse_entry(e) for e in data]
|
|
152
|
+
return [_parse_entry(e) for e in data.get("entries", data if isinstance(data, list) else [])]
|
|
153
|
+
|
|
154
|
+
def stats(self) -> MemoryStats:
|
|
155
|
+
data = self._get("/api/v1/stats")
|
|
156
|
+
return MemoryStats.model_validate(data)
|
|
157
|
+
|
|
158
|
+
def list_outcomes(
|
|
159
|
+
self,
|
|
160
|
+
*,
|
|
161
|
+
entity_path: str | None = None,
|
|
162
|
+
since: datetime | None = None,
|
|
163
|
+
limit: int = 1000,
|
|
164
|
+
) -> list[OutcomeRecord]:
|
|
165
|
+
params: dict[str, Any] = {"limit": limit}
|
|
166
|
+
if entity_path:
|
|
167
|
+
params["entity_path"] = entity_path
|
|
168
|
+
if since:
|
|
169
|
+
params["since"] = since.isoformat()
|
|
170
|
+
data = self._get("/api/v1/outcomes", **params)
|
|
171
|
+
return [OutcomeRecord.model_validate(o) for o in data.get("outcomes", [])]
|
|
172
|
+
|
|
173
|
+
def list_traces(
|
|
174
|
+
self,
|
|
175
|
+
*,
|
|
176
|
+
entity_path: str | None = None,
|
|
177
|
+
agent_id: str | None = None,
|
|
178
|
+
outcome_type: str | None = None,
|
|
179
|
+
limit: int = 100,
|
|
180
|
+
) -> list[DecisionTrace]:
|
|
181
|
+
data = self._get(
|
|
182
|
+
"/api/v1/traces",
|
|
183
|
+
entity_path=entity_path,
|
|
184
|
+
agent_id=agent_id,
|
|
185
|
+
outcome_type=outcome_type,
|
|
186
|
+
limit=limit,
|
|
187
|
+
)
|
|
188
|
+
return [DecisionTrace.model_validate(t) for t in data.get("traces", [])]
|
|
189
|
+
|
|
190
|
+
def get_trace(self, trace_id: str) -> DecisionTrace | None:
|
|
191
|
+
try:
|
|
192
|
+
data = self._get(f"/api/v1/traces/{trace_id}")
|
|
193
|
+
except httpx.HTTPStatusError as exc:
|
|
194
|
+
if exc.response.status_code == 404:
|
|
195
|
+
return None
|
|
196
|
+
raise
|
|
197
|
+
if data.get("error"):
|
|
198
|
+
return None
|
|
199
|
+
return DecisionTrace.model_validate(data)
|