amfs-http-server 0.3.51__tar.gz → 0.3.53__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.51
3
+ Version: 0.3.53
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.43
8
- Requires-Dist: amfs>=0.5.35
7
+ Requires-Dist: amfs-core>=0.3.44
8
+ Requires-Dist: amfs>=0.5.36
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.51"
3
+ version = "0.3.53"
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.35",
16
+ "amfs>=0.5.36",
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.43",
30
+ "amfs-core>=0.3.44",
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, Sequence
33
+ from collections.abc import Callable, Mapping, Sequence
34
34
  from typing import Any
35
35
 
36
36
  import uvicorn
@@ -50,7 +50,7 @@ from amfs_core.aggregates import (
50
50
  )
51
51
  from amfs_core.ranking import composite_score
52
52
  from amfs_core.ranking import entry_text as _entry_text
53
- from amfs_core.scope import SqlScope
53
+ from amfs_core.scope import SqlScope, covers
54
54
  from amfs_core.ranking import keyword_coverage as _keyword_coverage
55
55
  from amfs_core.reuse_value import REUSE_VALUE_HEADER, reuse_value_block
56
56
  from amfs_core.capture import scan_captured_arguments, scan_captured_text
@@ -962,6 +962,112 @@ def _search_sync(adapter: Any, query: SearchQuery, branch: Any) -> list[MemoryEn
962
962
  return adapter.search(query)
963
963
 
964
964
 
965
+ async def _branch_treatment(
966
+ *,
967
+ branch: str,
968
+ entity_path: str | None,
969
+ topical: str,
970
+ query_vector: list[float] | None,
971
+ embedder: Any,
972
+ pool: int,
973
+ vis: Any,
974
+ include_artifacts: bool,
975
+ is_artifact: Callable[[MemoryEntry], bool],
976
+ ) -> list[tuple[MemoryEntry, float, float]]:
977
+ """The entries *branch* changed under *entity_path* that are about *topical*.
978
+
979
+ Retrieve's step 9b: what a read routed to a repair branch exists to put in
980
+ front of the agent. Returns ``(entry, similarity, keyword)`` triples for
981
+ the branch's added or modified entries that the query's semantic pool on
982
+ the branch holds (no confidence gate, no evidence gate), or that a lexical
983
+ read on the branch returns where there are no vectors. Empty on ``main``,
984
+ with no scope, on an adapter without branches, when the branch changed
985
+ nothing under the scope, or when the query is about none of it. Never
986
+ raises: a failure here leaves the ranking as it was.
987
+ """
988
+ if not isinstance(branch, str) or branch.strip() in ("", "main") or not entity_path:
989
+ return []
990
+ mem = _get_memory()
991
+ diff_fn = getattr(mem._adapter, "diff_branch", None)
992
+ if not callable(diff_fn):
993
+ return []
994
+ namespace = getattr(mem._adapter, "_namespace", None) or "default"
995
+ try:
996
+ diff = await _offload(_db_executor, diff_fn, branch, namespace=namespace)
997
+ except Exception: # noqa: BLE001 - best-effort
998
+ logger.debug("branch diff for retrieve failed", exc_info=True)
999
+ return []
1000
+ changed: set[tuple[str, str]] = {
1001
+ (d.entity_path, d.key)
1002
+ for d in diff or []
1003
+ if getattr(d, "diff_type", "") in ("added", "modified")
1004
+ and covers(entity_path, d.entity_path)
1005
+ and not _is_synthetic_key(d.key)
1006
+ and not _is_excluded_entity(d.entity_path)
1007
+ }
1008
+ if not changed:
1009
+ return []
1010
+
1011
+ # Which of them the query is about, read on the branch with no gate: the
1012
+ # semantic pool where vectors exist, the lexical channel where they do not.
1013
+ found: dict[tuple[str, str], tuple[MemoryEntry, float, float]] = {}
1014
+ if embedder is not None and _async_adapter is not None:
1015
+ try:
1016
+ pairs = await _async_adapter.semantic_search(
1017
+ SemanticQuery(
1018
+ text=topical, entity_path=entity_path, min_confidence=0.0,
1019
+ limit=pool, embedding=query_vector,
1020
+ ),
1021
+ embedder,
1022
+ branch=branch,
1023
+ )
1024
+ except Exception: # noqa: BLE001
1025
+ logger.debug("branch semantic read for retrieve failed", exc_info=True)
1026
+ pairs = []
1027
+ floor = _retrieve_min_semantic()
1028
+ for entry, sim in pairs:
1029
+ ref = (entry.entity_path, entry.key)
1030
+ if ref in changed and float(sim) > 0.0 and float(sim) >= floor:
1031
+ found[ref] = (entry, float(sim), 0.0)
1032
+ lex = SearchQuery(
1033
+ query=topical, entity_path=entity_path, min_confidence=0.0, limit=pool,
1034
+ sort_by="confidence", depth=3, include_artifacts=include_artifacts,
1035
+ )
1036
+ try:
1037
+ if _async_adapter is not None:
1038
+ rows = await _async_adapter.search(lex, branch=branch)
1039
+ else:
1040
+ rows = await _offload(_db_executor, _search_sync, mem._adapter, lex, branch)
1041
+ except Exception: # noqa: BLE001
1042
+ logger.debug("branch lexical read for retrieve failed", exc_info=True)
1043
+ rows = []
1044
+ for entry in rows:
1045
+ ref = (entry.entity_path, entry.key)
1046
+ if ref not in changed:
1047
+ continue
1048
+ slot = found.get(ref)
1049
+ found[ref] = (entry, slot[1], 1.0) if slot else (entry, 0.0, 1.0)
1050
+
1051
+ # Discredited or not: the branch's own changes are pinned whatever their
1052
+ # evidence says. The pin exists so the arm under test is *treated*; the
1053
+ # canary is what decides whether the treatment is good, and it can only
1054
+ # decide if the arm read it. Dropping a discredited entry here handed the
1055
+ # verdict to whatever had discredited it first — on clbench (2026-09-30,
1056
+ # fleet 10) the loop's own replays, within minutes of the write, before
1057
+ # the canary's first session in two of six cases; the arm then read its
1058
+ # fix in 0 of 74 sessions and one canary *failed* on an arm that had never
1059
+ # seen the treatment. The row keeps its ``evidence_status``, so the agent
1060
+ # sees the record; it is not hidden from the arm on the record's account.
1061
+ out = [
1062
+ t for t in found.values()
1063
+ if include_artifacts or not is_artifact(t[0])
1064
+ ]
1065
+ if out and vis is not None and vis.should_filter():
1066
+ allowed = {e.entry_key for e in vis.filter_entries([t[0] for t in out])}
1067
+ out = [t for t in out if t[0].entry_key in allowed]
1068
+ return out
1069
+
1070
+
965
1071
  async def _discredited_below_gate(
966
1072
  entity_path: str,
967
1073
  *,
@@ -3325,6 +3431,103 @@ async def retrieve_entries(
3325
3431
  kept.append((entry, score, bd))
3326
3432
  scored = kept
3327
3433
 
3434
+ # 9b. The branch under test. A read routed to a repair branch is the
3435
+ # canary arm of a fix: the branch exists to put that fix in front of
3436
+ # the agent, and the entries it changed are the treatment. Ranked
3437
+ # like any other entry they do not arrive. The repair loop writes a
3438
+ # correction at 60% of the confidence of the rule it corrects — under
3439
+ # the caller's gate as often as not — onto a scope where outcome
3440
+ # learning has already written dozens of validated near-duplicates
3441
+ # of the stale rule, and confidence is a ranking term. Measured on
3442
+ # clbench (2026-09-30): 18 canaries, 14-197 branch reads each, zero
3443
+ # reads of the fix's own entry. Both arms read the same rules, the
3444
+ # canary compared two untreated populations, and 0 vs 0 recurrence
3445
+ # said nothing about the fix.
3446
+ #
3447
+ # So the branch's changed entries that are *about this query* lead
3448
+ # the list, in their own order, marked ``_pinned`` so the client can
3449
+ # see why. "About this query" is the semantic pool on the branch with
3450
+ # no confidence gate (the fix's confidence is the repair loop's
3451
+ # business, not a reason to hide the treatment from its own arm), or
3452
+ # a lexical hit where there are no vectors; an entry the query is not
3453
+ # about stays where it is — a fix to the CI rule is not pinned onto a
3454
+ # support ticket. A discredited entry is pinned all the same, with its
3455
+ # evidence on the row: the canary is the test of the fix, and it can
3456
+ # only test what the arm read. The first version of this step left
3457
+ # discredited entries to the avoid list; measured on clbench
3458
+ # (2026-09-30, fleet 10, six canaries), five corrections were
3459
+ # discredited within 1-24 minutes of being written — by the loop's
3460
+ # own replays, two before the canary's first session — and the arm
3461
+ # they were meant to treat read them in 0 of 74 sessions. What is not
3462
+ # pinned is a procedure whose preconditions contradict the run's
3463
+ # environment — it is named in ``_meta.not_applicable`` like any
3464
+ # other. Main reads are untouched.
3465
+ pinned = await _branch_treatment(
3466
+ branch=branch,
3467
+ entity_path=req.entity_path,
3468
+ topical=topical,
3469
+ query_vector=query_vectors.get(topical),
3470
+ embedder=embedder,
3471
+ pool=pool,
3472
+ vis=vis,
3473
+ include_artifacts=req.include_artifacts,
3474
+ is_artifact=_is_artifact,
3475
+ )
3476
+ if pinned:
3477
+ if environment:
3478
+ from amfs_core.models import preconditions_status as _preconditions_status
3479
+
3480
+ # Named once: a pinned procedure above the caller's gate was a
3481
+ # candidate too, and step 9 has already recorded it.
3482
+ named = {(row["entity_path"], row["key"]) for row in not_applicable}
3483
+ applicable: list[tuple[MemoryEntry, float, float]] = []
3484
+ for entry, sim, keyword in pinned:
3485
+ if _is_procedure(entry):
3486
+ status, detail = _preconditions_status(entry.value, environment)
3487
+ if status == "not_applicable":
3488
+ if (entry.entity_path, entry.key) not in named:
3489
+ not_applicable.append({
3490
+ "entity_path": entry.entity_path, "key": entry.key, "why": detail,
3491
+ })
3492
+ continue
3493
+ applicable.append((entry, sim, keyword))
3494
+ pinned = applicable
3495
+ if pinned:
3496
+ pinned_scored: list[tuple[MemoryEntry, float, dict[str, Any]]] = []
3497
+ for entry, sim, keyword in pinned:
3498
+ written = getattr(entry.provenance, "written_at", None)
3499
+ if written is not None:
3500
+ if written.tzinfo is None:
3501
+ written = written.replace(tzinfo=UTC)
3502
+ recency = 0.5 ** (max(0.0, (now - written).total_seconds() / 86400.0) / half_life)
3503
+ else:
3504
+ recency = 0.0
3505
+ artifact = _is_artifact(entry)
3506
+ procedure = _is_procedure(entry)
3507
+ evidence = _evidence_signal(entry)
3508
+ bd = {
3509
+ "semantic": sim,
3510
+ "relevance": req.semantic_weight * sim + keyword_weight * keyword,
3511
+ "recency": recency,
3512
+ "confidence": float(entry.confidence),
3513
+ "keyword": keyword,
3514
+ "evidence": evidence,
3515
+ "evidence_status": entry.evidence_status,
3516
+ "is_artifact": artifact,
3517
+ "is_procedure": procedure,
3518
+ "pinned": branch,
3519
+ }
3520
+ conf = float(entry.confidence)
3521
+ pinned_scored.append((
3522
+ entry, _composite(sim, recency, conf, keyword, artifact, evidence, procedure), bd,
3523
+ ))
3524
+ pinned_scored.sort(key=lambda t: t[1], reverse=True)
3525
+ pinned_keys = {e.entry_key for e, _, _ in pinned_scored}
3526
+ scored = pinned_scored + [t for t in scored if t[0].entry_key not in pinned_keys]
3527
+ # Once, at the head: a pinned entry the record has discredited is not
3528
+ # also handed back in the avoid list. The row carries its evidence.
3529
+ avoided = [e for e in avoided if e.entry_key not in pinned_keys]
3530
+
3328
3531
  # 10. Reuse accounting. Semantic retrieval is the primary way memories
3329
3532
  # (e.g. browser-extension clips) get surfaced and used, but it
3330
3533
  # historically incremented no counter — so value metrics (rework
@@ -3620,6 +3823,10 @@ async def retrieve_entries(
3620
3823
  # agent reads the top-level field.
3621
3824
  data["evidence_status"] = breakdown.get("evidence_status")
3622
3825
  data["_rescued"] = True
3826
+ if breakdown.get("pinned"):
3827
+ # Changed on the branch this read was routed to (step 9b): the
3828
+ # treatment of the canary arm, ahead of the ranking on purpose.
3829
+ data["_pinned"] = True
3623
3830
  if req.compact:
3624
3831
  data["_breakdown"] = {"evidence_status": breakdown.get("evidence_status")}
3625
3832
  else: