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.
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: amfs-http-server
3
- Version: 0.3.47
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.39
8
- Requires-Dist: amfs>=0.5.31
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.47"
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.31",
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.39",
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
- if not rows:
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
- block = aggregate_priors(
1142
- rows, candidate_actions=candidate_actions, environment=environment or None
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