amfs-http-server 0.3.47__tar.gz → 0.3.49__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.47 → amfs_http_server-0.3.49}/PKG-INFO +3 -3
- {amfs_http_server-0.3.47 → amfs_http_server-0.3.49}/pyproject.toml +3 -3
- {amfs_http_server-0.3.47 → amfs_http_server-0.3.49}/src/amfs_http/server.py +174 -5
- {amfs_http_server-0.3.47 → amfs_http_server-0.3.49}/.gitignore +0 -0
- {amfs_http_server-0.3.47 → amfs_http_server-0.3.49}/src/amfs_http/__init__.py +0 -0
- {amfs_http_server-0.3.47 → amfs_http_server-0.3.49}/src/amfs_http/auth.py +0 -0
- {amfs_http_server-0.3.47 → amfs_http_server-0.3.49}/src/amfs_http/models.py +0 -0
- {amfs_http_server-0.3.47 → amfs_http_server-0.3.49}/src/amfs_http/openrouter_proxy.py +0 -0
- {amfs_http_server-0.3.47 → amfs_http_server-0.3.49}/src/amfs_http/pro_provider.py +0 -0
- {amfs_http_server-0.3.47 → amfs_http_server-0.3.49}/src/amfs_http/pro_proxy.py +0 -0
- {amfs_http_server-0.3.47 → amfs_http_server-0.3.49}/src/amfs_http/sse.py +0 -0
- {amfs_http_server-0.3.47 → amfs_http_server-0.3.49}/src/amfs_http/tenant_middleware.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: amfs-http-server
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.49
|
|
4
4
|
Summary: AMFS HTTP/REST API server with SSE support
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Requires-Python: >=3.11
|
|
7
|
-
Requires-Dist: amfs-core>=0.3.
|
|
8
|
-
Requires-Dist: amfs>=0.5.
|
|
7
|
+
Requires-Dist: amfs-core>=0.3.41
|
|
8
|
+
Requires-Dist: amfs>=0.5.33
|
|
9
9
|
Requires-Dist: fastapi>=0.115
|
|
10
10
|
Requires-Dist: httpx>=0.27
|
|
11
11
|
Requires-Dist: sse-starlette>=2.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "amfs-http-server"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.49"
|
|
4
4
|
description = "AMFS HTTP/REST API server with SSE support"
|
|
5
5
|
requires-python = ">=3.11"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -13,7 +13,7 @@ dependencies = [
|
|
|
13
13
|
# this tree is what test_dependency_floors forbids.
|
|
14
14
|
# 0.5.18 for AgentMemory.as_agent, which the agent detail page calls to list
|
|
15
15
|
# an agent's entries as that agent rather than as the server.
|
|
16
|
-
"amfs>=0.5.
|
|
16
|
+
"amfs>=0.5.33",
|
|
17
17
|
# 0.3.23 is real in the silent way: the write path calls
|
|
18
18
|
# adapter.scope_counts(), which is a default on AdapterABC from 0.3.23. On an
|
|
19
19
|
# older core the adapters that do not override it — filesystem, S3 — have no
|
|
@@ -27,7 +27,7 @@ dependencies = [
|
|
|
27
27
|
# unexpected keyword, _scope_block swallows it, and the block disappears —
|
|
28
28
|
# while an adapter that ignored the argument instead would name another
|
|
29
29
|
# agent's private keys. Either way the floor is what makes the rule real.
|
|
30
|
-
"amfs-core>=0.3.
|
|
30
|
+
"amfs-core>=0.3.41",
|
|
31
31
|
"fastapi>=0.115",
|
|
32
32
|
# 0.37 is where uvicorn.run() grew timeout_worker_healthcheck, which
|
|
33
33
|
# main() passes unconditionally; on anything older that is a TypeError
|
|
@@ -30,7 +30,7 @@ import time
|
|
|
30
30
|
from concurrent.futures import ThreadPoolExecutor
|
|
31
31
|
from contextlib import asynccontextmanager
|
|
32
32
|
from datetime import UTC, datetime, timedelta, timezone
|
|
33
|
-
from collections.abc import Mapping
|
|
33
|
+
from collections.abc import Mapping, Sequence
|
|
34
34
|
from typing import Any
|
|
35
35
|
|
|
36
36
|
import uvicorn
|
|
@@ -1073,6 +1073,11 @@ def _resolved_actions_from_lessons(
|
|
|
1073
1073
|
return out
|
|
1074
1074
|
|
|
1075
1075
|
|
|
1076
|
+
#: How many of the entity's newest outcomes are scanned for the declared
|
|
1077
|
+
#: situation's own record when the embedding neighbourhood holds none of it.
|
|
1078
|
+
SITUATION_RECORD_SCAN = 1000
|
|
1079
|
+
|
|
1080
|
+
|
|
1076
1081
|
def _priors_for_retrieve(
|
|
1077
1082
|
*,
|
|
1078
1083
|
entity_path: str,
|
|
@@ -1081,6 +1086,7 @@ def _priors_for_retrieve(
|
|
|
1081
1086
|
candidate_actions: list[str] | None,
|
|
1082
1087
|
environment: Mapping[str, Any] | None = None,
|
|
1083
1088
|
query_vector: list[float] | None = None,
|
|
1089
|
+
situation: str | None = None,
|
|
1084
1090
|
) -> dict[str, Any] | None:
|
|
1085
1091
|
"""Action priors for ``entity_path`` on tasks like ``text``.
|
|
1086
1092
|
|
|
@@ -1093,6 +1099,16 @@ def _priors_for_retrieve(
|
|
|
1093
1099
|
nothing rather than a block whose only content is the candidate list the
|
|
1094
1100
|
agent itself supplied.
|
|
1095
1101
|
|
|
1102
|
+
With a declared *situation*, and outcomes in the neighbourhood that carry
|
|
1103
|
+
one, the block is the **situation's own record**: the priors are
|
|
1104
|
+
aggregated over the outcomes whose situation is this one
|
|
1105
|
+
(``situation_record``: how many), and the rest of the neighbourhood is
|
|
1106
|
+
reported separately under ``nearby`` — per situation, what was tried and
|
|
1107
|
+
how it went — for the agent to weigh, never to be planned from. Two
|
|
1108
|
+
situations that differ by one token embed within 0.02 of each other, so
|
|
1109
|
+
text alone pools them; the label is what tells them apart. Outcomes
|
|
1110
|
+
without a situation (older clients) leave the block pooled as before.
|
|
1111
|
+
|
|
1096
1112
|
Synchronous, and blocking on the sync adapter: callers on the event loop
|
|
1097
1113
|
run it through ``_offload``. ``query_vector`` is the caller's embedding of
|
|
1098
1114
|
``text`` when it has one, so the model is not run a second time.
|
|
@@ -1136,11 +1152,83 @@ def _priors_for_retrieve(
|
|
|
1136
1152
|
except Exception: # noqa: BLE001
|
|
1137
1153
|
logger.debug("action_stats failed", exc_info=True)
|
|
1138
1154
|
rows = []
|
|
1139
|
-
|
|
1155
|
+
mine: list[dict[str, Any]] | None = None
|
|
1156
|
+
others: list[dict[str, Any]] = []
|
|
1157
|
+
elsewhere_rows: list[dict[str, Any]] = []
|
|
1158
|
+
declared = _fold_situation(situation)
|
|
1159
|
+
if declared and callable(stats):
|
|
1160
|
+
# The situation's own record is an equality, not a neighbourhood.
|
|
1161
|
+
# The embedding neighbourhood may hold none of it — a queue whose
|
|
1162
|
+
# classes embed far apart returns no neighbour above the floor for a
|
|
1163
|
+
# class seen once — and the fallback to the entity's whole record
|
|
1164
|
+
# would then serve another class's winner as ``act``. Measured on
|
|
1165
|
+
# the ops-queue support demo (2026-09-22): the first Android ticket
|
|
1166
|
+
# was told ``act: explain_and_close`` from the webhook class's record
|
|
1167
|
+
# and spent its three attempts on the entity's winners. So the exact
|
|
1168
|
+
# rows come from the entity's record by situation, whatever the
|
|
1169
|
+
# neighbourhood found; the neighbourhood supplies ``nearby``.
|
|
1170
|
+
exact_rows = [r for r in rows if _fold_situation(r.get("situation")) == declared]
|
|
1171
|
+
# The same scan gives the record *elsewhere* — the entity's other
|
|
1172
|
+
# situations — which is what orders a sweep over the untried
|
|
1173
|
+
# (``amfs_core.actions.sweep_order``).
|
|
1174
|
+
try:
|
|
1175
|
+
seen = {r.get("outcome_ref") for r in exact_rows}
|
|
1176
|
+
for r in stats(entity_path, limit=SITUATION_RECORD_SCAN):
|
|
1177
|
+
if _fold_situation(r.get("situation")) == declared:
|
|
1178
|
+
if r.get("outcome_ref") not in seen:
|
|
1179
|
+
exact_rows.append(r)
|
|
1180
|
+
seen.add(r.get("outcome_ref"))
|
|
1181
|
+
else:
|
|
1182
|
+
elsewhere_rows.append(r)
|
|
1183
|
+
except Exception: # noqa: BLE001
|
|
1184
|
+
logger.debug("action_stats by situation failed", exc_info=True)
|
|
1185
|
+
# Partitioned when the situation has a record, or the neighbourhood
|
|
1186
|
+
# itself carries labels (so an empty record means "not seen yet",
|
|
1187
|
+
# not "older clients"). Labels found only elsewhere on the entity do
|
|
1188
|
+
# not decide it: an unlabelled neighbourhood is the older rows'
|
|
1189
|
+
# pooled reading, as promised.
|
|
1190
|
+
labelled = bool(exact_rows) or any(r.get("situation") for r in rows)
|
|
1191
|
+
# Labels that are not classes — a situation per task, an ID in it —
|
|
1192
|
+
# would leave every record empty and the priors abstaining for
|
|
1193
|
+
# good, where the pooled block served a winner before. When almost
|
|
1194
|
+
# every scanned outcome carries a distinct label, the block stays
|
|
1195
|
+
# pooled as for an undeclared situation. Only ever a question for a
|
|
1196
|
+
# situation with no match: a label that has matched is a class for
|
|
1197
|
+
# this caller, whatever other clients put in theirs.
|
|
1198
|
+
if labelled and not exact_rows and not _labels_are_classes(elsewhere_rows):
|
|
1199
|
+
labelled = False
|
|
1200
|
+
if labelled:
|
|
1201
|
+
mine = exact_rows
|
|
1202
|
+
others = (
|
|
1203
|
+
[r for r in rows if _fold_situation(r.get("situation")) != declared]
|
|
1204
|
+
if source == "similar_outcomes" else []
|
|
1205
|
+
)
|
|
1206
|
+
if not rows and mine is None:
|
|
1140
1207
|
return None
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1208
|
+
if mine is not None:
|
|
1209
|
+
block = aggregate_priors(
|
|
1210
|
+
mine, candidate_actions=candidate_actions, environment=environment or None,
|
|
1211
|
+
situation_exact=True,
|
|
1212
|
+
)
|
|
1213
|
+
block["situation_record"] = {"situation": str(situation)[:200], "outcomes": len(mine)}
|
|
1214
|
+
block["nearby"] = _nearby_record(others, environment=environment or None)
|
|
1215
|
+
if elsewhere_rows:
|
|
1216
|
+
elsewhere = aggregate_priors(elsewhere_rows, environment=environment or None)
|
|
1217
|
+
block["elsewhere"] = {
|
|
1218
|
+
"outcomes": len(elsewhere_rows),
|
|
1219
|
+
"tried": [
|
|
1220
|
+
{k: t[k] for k in ("action_key", "won", "lost", "n", "p")}
|
|
1221
|
+
for t in elsewhere["tried"][:12]
|
|
1222
|
+
],
|
|
1223
|
+
}
|
|
1224
|
+
# The exact record is about this kind of task by construction; the
|
|
1225
|
+
# label ``action_stats`` would have it read as the entity-wide pool.
|
|
1226
|
+
if source != "similar_outcomes":
|
|
1227
|
+
source = "situation_record"
|
|
1228
|
+
else:
|
|
1229
|
+
block = aggregate_priors(
|
|
1230
|
+
rows, candidate_actions=candidate_actions, environment=environment or None
|
|
1231
|
+
)
|
|
1144
1232
|
block["source"] = source
|
|
1145
1233
|
block["entity_path"] = entity_path
|
|
1146
1234
|
if source == "similar_outcomes" and rows:
|
|
@@ -1151,6 +1239,64 @@ def _priors_for_retrieve(
|
|
|
1151
1239
|
return block
|
|
1152
1240
|
|
|
1153
1241
|
|
|
1242
|
+
#: Below this many labelled outcomes the labels are taken as classes: too
|
|
1243
|
+
#: few to tell a class from a one-off — an entity's first outcomes are one
|
|
1244
|
+
#: per class, every label distinct, and a floor of 6 read a queue of eight
|
|
1245
|
+
#: classes as per-task strings at its seventh ticket (ops-queue support run
|
|
1246
|
+
#: 4, 2026-09-22: the pooled fallback served another class's winner, 4
|
|
1247
|
+
#: wasted). Classes recur well within twenty outcomes.
|
|
1248
|
+
LABELS_MIN_ROWS = 20
|
|
1249
|
+
#: Above this share of distinct labels among the labelled outcomes, the
|
|
1250
|
+
#: situation is a per-task string, not a class.
|
|
1251
|
+
LABELS_MAX_DISTINCT_RATIO = 0.9
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def _labels_are_classes(rows: Sequence[Mapping[str, Any]]) -> bool:
|
|
1255
|
+
"""Whether the situations on *rows* recur — a class label — rather than
|
|
1256
|
+
naming each task once. Fewer than ``LABELS_MIN_ROWS`` labelled rows are
|
|
1257
|
+
read as classes."""
|
|
1258
|
+
labels = [_fold_situation(r.get("situation")) for r in rows if r.get("situation")]
|
|
1259
|
+
if len(labels) < LABELS_MIN_ROWS:
|
|
1260
|
+
return True
|
|
1261
|
+
return len(set(labels)) / len(labels) <= LABELS_MAX_DISTINCT_RATIO
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def _fold_situation(value: Any) -> str:
|
|
1265
|
+
"""A situation label as compared: case- and whitespace-folded."""
|
|
1266
|
+
return " ".join(str(value or "").split()).casefold()
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def _nearby_record(
|
|
1270
|
+
rows: list[dict[str, Any]], *, environment: Mapping[str, Any] | None = None
|
|
1271
|
+
) -> dict[str, Any]:
|
|
1272
|
+
"""The neighbourhood outside the declared situation, per situation:
|
|
1273
|
+
``{"outcomes": n, "by_situation": [{"situation", "outcomes", "tried":
|
|
1274
|
+
[...]}]}``, nearest situation first, three at most. What the agent sees
|
|
1275
|
+
as "on nearby kinds of task"; nothing here is planned from."""
|
|
1276
|
+
from amfs_core.actions import aggregate_priors
|
|
1277
|
+
|
|
1278
|
+
groups: dict[str, list[dict[str, Any]]] = {}
|
|
1279
|
+
for r in rows:
|
|
1280
|
+
groups.setdefault(_fold_situation(r.get("situation")) or "(unlabelled)", []).append(r)
|
|
1281
|
+
|
|
1282
|
+
def _nearness(items: list[dict[str, Any]]) -> float:
|
|
1283
|
+
return max(float(r.get("task_similarity") or r.get("similarity") or 0.0) for r in items)
|
|
1284
|
+
|
|
1285
|
+
by_situation = []
|
|
1286
|
+
for key, items in sorted(groups.items(), key=lambda kv: -_nearness(kv[1]))[:3]:
|
|
1287
|
+
label = next((str(r.get("situation")) for r in items if r.get("situation")), key)
|
|
1288
|
+
agg = aggregate_priors(items, environment=environment)
|
|
1289
|
+
by_situation.append({
|
|
1290
|
+
"situation": label[:200],
|
|
1291
|
+
"outcomes": len(items),
|
|
1292
|
+
"tried": [
|
|
1293
|
+
{k: t[k] for k in ("action_key", "won", "lost", "n", "last_3") if k in t}
|
|
1294
|
+
for t in agg["tried"][:6]
|
|
1295
|
+
],
|
|
1296
|
+
})
|
|
1297
|
+
return {"outcomes": len(rows), "by_situation": by_situation}
|
|
1298
|
+
|
|
1299
|
+
|
|
1154
1300
|
def _get_visibility_filter(request: Request):
|
|
1155
1301
|
"""Return the UserVisibilityFilter from request.state, or None."""
|
|
1156
1302
|
return getattr(request.state, "visibility_filter", None)
|
|
@@ -3247,6 +3393,7 @@ async def retrieve_entries(
|
|
|
3247
3393
|
candidate_actions=req.candidate_actions,
|
|
3248
3394
|
environment=environment,
|
|
3249
3395
|
query_vector=priors_vec,
|
|
3396
|
+
situation=req.situation,
|
|
3250
3397
|
)
|
|
3251
3398
|
top = head[0][0] if head else None
|
|
3252
3399
|
top_bd = head[0][2] if head else {}
|
|
@@ -3339,10 +3486,32 @@ async def retrieve_entries(
|
|
|
3339
3486
|
top is not None and not top_rescued and _regime_shifted(top, now=now)
|
|
3340
3487
|
)
|
|
3341
3488
|
hit_statuses = [str(e.evidence_status) for e, _, _ in head if e.evidence_status]
|
|
3489
|
+
# The situation-exact record: the lessons among the hits that are
|
|
3490
|
+
# about this task (their situation compared to the declared one, else
|
|
3491
|
+
# found in the task text). They re-order the plan — priors are pooled
|
|
3492
|
+
# over a neighbourhood that can hold two classes with opposite
|
|
3493
|
+
# answers; a lesson names one class.
|
|
3494
|
+
from amfs_core.lessons import applicable_claims as _applicable_claims
|
|
3495
|
+
from amfs_core.lessons import lesson_of as _lesson_of
|
|
3496
|
+
|
|
3497
|
+
# Not the avoided ones: an avoided lesson is one retrieve hid because
|
|
3498
|
+
# it failed on this kind of task, and its status need not say
|
|
3499
|
+
# ``discredited`` yet — a locally falsified "worked" claim must not
|
|
3500
|
+
# promote the action it was falsified on.
|
|
3501
|
+
avoided_keys = {e.entry_key for e in avoided}
|
|
3502
|
+
lesson_rows = []
|
|
3503
|
+
for e in local_pool:
|
|
3504
|
+
if e.entry_key in avoided_keys:
|
|
3505
|
+
continue
|
|
3506
|
+
lesson = _lesson_of(e.value)
|
|
3507
|
+
if lesson is not None:
|
|
3508
|
+
lesson_rows.append(dict(lesson, evidence_status=e.evidence_status))
|
|
3509
|
+
claims = _applicable_claims(lesson_rows, req.query, declared=req.situation)
|
|
3342
3510
|
recommendation = _recommend(
|
|
3343
3511
|
priors,
|
|
3344
3512
|
agent_id=req.agent_id or "",
|
|
3345
3513
|
candidate_actions=req.candidate_actions,
|
|
3514
|
+
lessons=claims or None,
|
|
3346
3515
|
top_hit_status=(
|
|
3347
3516
|
str(top_bd.get("evidence_status") or top.evidence_status)
|
|
3348
3517
|
if top is not None else None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|