amfs-http-server 0.3.37__tar.gz → 0.3.39__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.37
3
+ Version: 0.3.39
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.32
8
- Requires-Dist: amfs>=0.5.22
7
+ Requires-Dist: amfs-core>=0.3.37
8
+ Requires-Dist: amfs>=0.5.27
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.37"
3
+ version = "0.3.39"
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.22",
16
+ "amfs>=0.5.27",
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.32",
30
+ "amfs-core>=0.3.37",
31
31
  "fastapi>=0.115",
32
32
  "uvicorn[standard]>=0.32",
33
33
  "sse-starlette>=2.0",
@@ -93,7 +93,11 @@ class SearchRequest(BaseModel):
93
93
  pattern_ref: str | None = None
94
94
  limit: int = 100
95
95
  sort_by: str = "confidence"
96
- branch: str = "main"
96
+ # ``None`` rather than ``"main"`` so the server can tell a caller that
97
+ # named no branch from one that asked for main: the former may be routed
98
+ # onto a canary branch by the SaaS layer (``request.state.memory_branch``),
99
+ # the latter never is. The SDK omits the field when it is main.
100
+ branch: str | None = None
97
101
  depth: int = 3
98
102
  # When True (default) artifacts are demoted to the bottom of results; when
99
103
  # False they are excluded entirely.
@@ -118,7 +122,7 @@ class AggregateRequest(BaseModel):
118
122
  field: str | None = None
119
123
  group_by: str | None = None
120
124
  row_path: str | None = None
121
- branch: str = "main"
125
+ branch: str | None = None # see SearchRequest.branch
122
126
 
123
127
 
124
128
  class RetrieveRequest(BaseModel):
@@ -136,7 +140,7 @@ class RetrieveRequest(BaseModel):
136
140
  semantic_weight: float = 0.5
137
141
  recency_weight: float = 0.3
138
142
  confidence_weight: float = 0.2
139
- branch: str = "main"
143
+ branch: str | None = None # see SearchRequest.branch
140
144
  # When True (default) artifacts (stored source files) are demoted but still
141
145
  # returned; when False they are excluded entirely from results.
142
146
  include_artifacts: bool = True
@@ -178,6 +182,16 @@ class RetrieveRequest(BaseModel):
178
182
  #: breakdown, importance dimensions, integrity fields, long values trimmed).
179
183
  #: Roughly a third of the tokens of the full payload.
180
184
  compact: bool = False
185
+ #: The asking run's environment — ``{"model", "agent_version", "runtime",
186
+ #: "platform"}`` — from the SDK's session or the MCP identity. A procedure
187
+ #: whose ``preconditions`` contradict it is dropped from the hits (it is a
188
+ #: way of doing the task somewhere else) and the priors weight outcomes
189
+ #: recorded under another environment less. Nothing changes when omitted.
190
+ environment: dict[str, str] | None = None
191
+ #: Say ``abstain`` in the recommendation when nothing in scope has been
192
+ #: tried or validated, instead of staying silent. Opt-in; with it a client
193
+ #: can tell "no guidance" from "guidance not requested".
194
+ abstain: bool = False
181
195
 
182
196
 
183
197
  class ContextRequest(BaseModel):
@@ -30,6 +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
34
  from typing import Any
34
35
 
35
36
  import uvicorn
@@ -53,6 +54,7 @@ from amfs_core.scope import SqlScope
53
54
  from amfs_core.ranking import keyword_coverage as _keyword_coverage
54
55
  from amfs_core.reuse_value import REUSE_VALUE_HEADER, reuse_value_block
55
56
  from amfs_core.capture import scan_captured_arguments, scan_captured_text
57
+ from amfs_core.actions import CONTRAST_MIN_W as _CONTRAST_MIN_W
56
58
  from amfs_core.actions import actions_taken as derive_actions_taken
57
59
  from amfs_core.engine import read_tracker_scope
58
60
  from amfs_core.evidence import evidence_signal as _evidence_signal
@@ -70,6 +72,7 @@ from amfs_core.models import (
70
72
  MemoryEntry,
71
73
  SearchQuery,
72
74
  SemanticQuery,
75
+ SessionMetadata,
73
76
  )
74
77
  from amfs_core.pagination import (
75
78
  InvalidCursorError,
@@ -348,7 +351,9 @@ from amfs_core.evidence import ( # noqa: E402
348
351
  DISCREDIT_THRESHOLD,
349
352
  blend_local_evidence as _blend_local_evidence,
350
353
  is_synthetic_key as _is_synthetic_key,
354
+ locally_discredited as _locally_discredited,
351
355
  locally_valid as _locally_valid,
356
+ replacements_from_lessons as _replacements_from_lessons,
352
357
  )
353
358
 
354
359
 
@@ -903,12 +908,58 @@ async def _discredited_below_gate(
903
908
  ]
904
909
 
905
910
 
911
+ def _as_utc(value: Any) -> datetime | None:
912
+ """A datetime or ISO string as an aware UTC datetime; ``None`` otherwise."""
913
+ if isinstance(value, datetime):
914
+ return value if value.tzinfo else value.replace(tzinfo=UTC)
915
+ if isinstance(value, str) and value:
916
+ try:
917
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
918
+ except ValueError:
919
+ return None
920
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
921
+ return None
922
+
923
+
924
+ def _resolved_actions_from_lessons(
925
+ lessons: list[MemoryEntry], contrasts: list[dict[str, Any]]
926
+ ) -> dict[str, str]:
927
+ """``entry_key -> action_key``: for each entry a contrast lesson names
928
+ under ``avoid``, the action that resolved that task instead.
929
+
930
+ The lesson's own ``resolved_action`` where it carries one (written by
931
+ ``amfs_core.evidence.contrast_lesson`` from the record's actions); else
932
+ the priors contrast for the same ``outcome_ref`` — the lesson and the
933
+ contrast are two readings of one outcome, joined on its ref. Neither the
934
+ nearest contrast nor any other is applied to an entry no lesson ties it
935
+ to: the entry keys are the lesson's, the action is the outcome's, and an
936
+ avoided rule that no fail-then-succeed outcome named gets nothing.
937
+ """
938
+ by_ref = {
939
+ str(c.get("outcome_ref")): str(c["resolved_with"])
940
+ for c in contrasts
941
+ if c.get("outcome_ref") and c.get("resolved_with")
942
+ }
943
+ out: dict[str, str] = {}
944
+ for e in lessons:
945
+ value = e.value if isinstance(e.value, dict) else None
946
+ if not value or not isinstance(value.get("avoid"), list):
947
+ continue
948
+ action = value.get("resolved_action") or by_ref.get(str(value.get("outcome_ref")))
949
+ if not action:
950
+ continue
951
+ for spec in value["avoid"]:
952
+ out.setdefault(str(spec), str(action))
953
+ return out
954
+
955
+
906
956
  def _priors_for_retrieve(
907
957
  *,
908
958
  entity_path: str,
909
959
  text: str,
910
960
  embedder: Any,
911
961
  candidate_actions: list[str] | None,
962
+ environment: Mapping[str, Any] | None = None,
912
963
  query_vector: list[float] | None = None,
913
964
  ) -> dict[str, Any] | None:
914
965
  """Action priors for ``entity_path`` on tasks like ``text``.
@@ -925,6 +976,9 @@ def _priors_for_retrieve(
925
976
  Synchronous, and blocking on the sync adapter: callers on the event loop
926
977
  run it through ``_offload``. ``query_vector`` is the caller's embedding of
927
978
  ``text`` when it has one, so the model is not run a second time.
979
+ *environment* down-weights outcomes recorded under another model /
980
+ runtime / agent version (the rows carry ``session_metadata`` or an
981
+ ``environment`` column when the adapter returns it).
928
982
  """
929
983
  from amfs_core.actions import (
930
984
  PRIORS_K,
@@ -964,7 +1018,9 @@ def _priors_for_retrieve(
964
1018
  rows = []
965
1019
  if not rows:
966
1020
  return None
967
- block = aggregate_priors(rows, candidate_actions=candidate_actions)
1021
+ block = aggregate_priors(
1022
+ rows, candidate_actions=candidate_actions, environment=environment or None
1023
+ )
968
1024
  block["source"] = source
969
1025
  block["entity_path"] = entity_path
970
1026
  if source == "similar_outcomes" and rows:
@@ -980,6 +1036,94 @@ def _get_visibility_filter(request: Request):
980
1036
  return getattr(request.state, "visibility_filter", None)
981
1037
 
982
1038
 
1039
+ #: ``request.state`` attribute a layer in front of these routes may set to move
1040
+ #: a session's *reads* onto a memory branch it never named. The SaaS layer sets
1041
+ #: it when the calling session is in the canary arm of a live repair canary.
1042
+ MEMORY_BRANCH_STATE = "memory_branch"
1043
+ #: ``request.state`` attribute holding attributes the same layer wants on the
1044
+ #: sealed trace — which canary the session was in and which arm. Merged
1045
+ #: server-side, after the caller's bag has been validated, so they are exempt
1046
+ #: from the client cap and cannot be forged from the body.
1047
+ TRACE_ATTRIBUTES_STATE = "trace_attributes"
1048
+
1049
+
1050
+ def _effective_branch(request: Request | None, branch: str | None) -> str:
1051
+ """The branch a read should hit: the caller's when named, else routed, else main.
1052
+
1053
+ A caller that names a branch always gets that branch — an explicit
1054
+ ``branch=main`` from a canary session still reads main, which is what a
1055
+ repair tool inspecting the baseline needs. A caller that names none reads
1056
+ whatever ``request.state.memory_branch`` says, which is how a session the
1057
+ SaaS layer put in a canary arm reads the proposal without knowing it, and
1058
+ ``main`` when nothing is set — the behaviour every route had before.
1059
+
1060
+ Reads only. Writes and commits never consult this: a canary session reads
1061
+ its branch and writes main, because what the agent learns during the test
1062
+ is the user's, not the proposal's.
1063
+ """
1064
+ # ``isinstance`` rather than truthiness: a handler invoked in-process (Pro
1065
+ # composes several, and the tests do) receives the ``Query(...)`` sentinel
1066
+ # as its default, and that object is truthy without being a branch.
1067
+ if isinstance(branch, str) and branch.strip():
1068
+ return branch.strip()
1069
+ state = getattr(request, "state", None) if request is not None else None
1070
+ routed = getattr(state, MEMORY_BRANCH_STATE, None) if state is not None else None
1071
+ if isinstance(routed, str) and routed.strip():
1072
+ return routed.strip()
1073
+ return "main"
1074
+
1075
+
1076
+ #: Attribute keys only the routing layer may set. When it has made a decision
1077
+ #: for the request (``trace_attributes`` is present, even empty) a client's own
1078
+ #: claims under these keys are dropped: a session nothing routed must not be
1079
+ #: able to vote in a canary it was not in.
1080
+ ROUTED_ONLY_ATTRIBUTES = frozenset({"canary_fix_id", "canary_arm"})
1081
+
1082
+
1083
+ def _routed_trace_attributes(request: Request | None) -> dict[str, Any] | None:
1084
+ """Attributes the layer in front of this route wants on the sealed trace.
1085
+
1086
+ ``None`` when no layer made a decision for this request; a dict — possibly
1087
+ empty — when one did. Values are coerced to scalars the trace store
1088
+ accepts; anything else is dropped rather than failing the commit.
1089
+ """
1090
+ state = getattr(request, "state", None) if request is not None else None
1091
+ raw = getattr(state, TRACE_ATTRIBUTES_STATE, None) if state is not None else None
1092
+ if not isinstance(raw, dict):
1093
+ return None
1094
+ out: dict[str, Any] = {}
1095
+ for key, value in raw.items():
1096
+ if not isinstance(key, str) or not key.strip():
1097
+ continue
1098
+ if isinstance(value, (str, int, float, bool)):
1099
+ out[key.strip().lower()] = value
1100
+ return out
1101
+
1102
+
1103
+ def _merge_routed_attributes(
1104
+ request: Request | None, attributes: dict[str, Any] | None
1105
+ ) -> dict[str, Any] | None:
1106
+ """*attributes* with the routed stamps on top; ``None`` stays ``None`` when nothing is added.
1107
+
1108
+ The server's values win. A client must not be able to put itself in the
1109
+ canary arm by sending ``canary_arm`` in its own bag, and the tally reads
1110
+ only these keys to decide which arm a trace belongs to — so once the
1111
+ routing layer has spoken for a request, the client's claims under those
1112
+ keys are dropped even when the layer's answer was "not routed".
1113
+ """
1114
+ routed = _routed_trace_attributes(request)
1115
+ if routed is None:
1116
+ return attributes
1117
+ merged = {
1118
+ k: v for k, v in (attributes or {}).items()
1119
+ if str(k).strip().lower() not in ROUTED_ONLY_ATTRIBUTES
1120
+ }
1121
+ merged.update(routed)
1122
+ if not merged and attributes is None:
1123
+ return None
1124
+ return merged
1125
+
1126
+
983
1127
  def _active_visibility_filter(request: Request):
984
1128
  """Return the visibility filter when per-user scoping applies, else None.
985
1129
 
@@ -1403,7 +1547,7 @@ async def read_entry_by_query(
1403
1547
  request: Request,
1404
1548
  entity_path: str = Query(...),
1405
1549
  key: str = Query(...),
1406
- branch: str = Query("main"),
1550
+ branch: str | None = Query(None),
1407
1551
  response: Response = None,
1408
1552
  _auth: str | None = Depends(verify_api_key),
1409
1553
  ) -> dict[str, Any]:
@@ -1435,7 +1579,7 @@ async def read_entry(
1435
1579
  request: Request,
1436
1580
  entity_path: str,
1437
1581
  key: str,
1438
- branch: str = Query("main"),
1582
+ branch: str | None = Query(None),
1439
1583
  response: Response = None,
1440
1584
  _auth: str | None = Depends(verify_api_key),
1441
1585
  ) -> dict[str, Any]:
@@ -1446,9 +1590,10 @@ async def _read_entry(
1446
1590
  request: Request,
1447
1591
  entity_path: str,
1448
1592
  key: str,
1449
- branch: str,
1593
+ branch: str | None,
1450
1594
  response: Response | None = None,
1451
1595
  ) -> dict[str, Any]:
1596
+ branch = _effective_branch(request, branch)
1452
1597
  mem = _get_memory()
1453
1598
  credited = False
1454
1599
  if _async_adapter is not None:
@@ -1494,10 +1639,11 @@ async def entry_quality(
1494
1639
  request: Request,
1495
1640
  entity_path: str,
1496
1641
  key: str,
1497
- branch: str = Query("main"),
1642
+ branch: str | None = Query(None),
1498
1643
  _auth: str | None = Depends(verify_api_key),
1499
1644
  ) -> dict[str, Any]:
1500
1645
  """Compute a quality report for a stored entry on demand."""
1646
+ branch = _effective_branch(request, branch)
1501
1647
  mem = _get_memory()
1502
1648
  # Read straight from the adapter — this is an internal/dashboard inspection,
1503
1649
  # not an agent recall, so it must NOT increment recall_count (doing so
@@ -1609,11 +1755,7 @@ async def write_entry(
1609
1755
  ) -> dict[str, Any]:
1610
1756
  mem = _get_memory()
1611
1757
 
1612
- type_map = {
1613
- "fact": MemoryType.FACT,
1614
- "belief": MemoryType.BELIEF,
1615
- "experience": MemoryType.EXPERIENCE,
1616
- }
1758
+ type_map = {m.value: m for m in MemoryType}
1617
1759
  mt = type_map.get(req.memory_type.lower(), MemoryType.FACT)
1618
1760
 
1619
1761
  # The caller's identity lives on a per-request handle. This block awaits
@@ -1852,7 +1994,7 @@ async def write_entry(
1852
1994
  async def list_entries(
1853
1995
  request: Request,
1854
1996
  entity_path: str | None = Query(None),
1855
- branch: str = Query("main"),
1997
+ branch: str | None = Query(None),
1856
1998
  include_superseded: bool = Query(False),
1857
1999
  limit: int | None = Query(None, ge=1, le=10_000),
1858
2000
  offset: int = Query(0, ge=0),
@@ -1872,6 +2014,7 @@ async def list_entries(
1872
2014
  "[TLS-DIAG] /entries tls_account=%s state_account=%s state_user=%s has_tenant_ctx=%s",
1873
2015
  _tls_acct, _state_acct, _state_user, _has_ctx,
1874
2016
  )
2017
+ branch = _effective_branch(request, branch)
1875
2018
  mem = _get_memory()
1876
2019
  if limit is None and ENTRIES_DEFAULT_LIMIT > 0:
1877
2020
  limit = ENTRIES_DEFAULT_LIMIT
@@ -2068,7 +2211,7 @@ async def aggregate_entries_endpoint(
2068
2211
  )
2069
2212
 
2070
2213
  mem = _get_memory()
2071
- entries = mem.list(req.entity_path, branch=req.branch)
2214
+ entries = mem.list(req.entity_path, branch=_effective_branch(request, req.branch))
2072
2215
 
2073
2216
  vis = _get_visibility_filter(request)
2074
2217
  if vis is not None and vis.should_filter():
@@ -2238,7 +2381,7 @@ async def search_entries(
2238
2381
  response: Response = None,
2239
2382
  _auth: str | None = Depends(verify_api_key),
2240
2383
  ) -> list[dict[str, Any]]:
2241
- branch = getattr(req, "branch", "main") or "main"
2384
+ branch = _effective_branch(request, getattr(req, "branch", None))
2242
2385
  sq = SearchQuery(
2243
2386
  query=req.query,
2244
2387
  entity_path=req.entity_path,
@@ -2356,10 +2499,10 @@ async def retrieve_entries(
2356
2499
  """
2357
2500
  from datetime import datetime as _dt, timezone as _tz
2358
2501
 
2359
- from amfs_core.content import ARTIFACT_PENALTY, classify_artifact
2502
+ from amfs_core.content import ARTIFACT_PENALTY, PROCEDURE_BOOST, classify_artifact
2360
2503
  from amfs_core.query_norm import normalize_temporal
2361
2504
 
2362
- branch = req.branch or "main"
2505
+ branch = _effective_branch(request, req.branch)
2363
2506
  vis = _get_visibility_filter(request)
2364
2507
  embedder = _get_server_embedder()
2365
2508
 
@@ -2530,7 +2673,14 @@ async def retrieve_entries(
2530
2673
  # 4. Drop benchmark/system scratch namespaces from user recall, and the
2531
2674
  # system-written contrast lessons: those are consumed by the briefing
2532
2675
  # (folded into the discredited section as "replaced by") and are not
2533
- # knowledge an agent should read or be credited for.
2676
+ # knowledge an agent should read or be credited for. The lessons this
2677
+ # query surfaced are kept aside for the avoid list, which names what
2678
+ # replaced each avoided entry the same way the briefing does.
2679
+ lessons: list[MemoryEntry] = [
2680
+ v["entry"] for v in candidates.values()
2681
+ if _is_synthetic_key(getattr(v["entry"], "key", ""))
2682
+ and not _is_excluded_entity(getattr(v["entry"], "entity_path", ""))
2683
+ ]
2534
2684
  candidates = {
2535
2685
  k: v
2536
2686
  for k, v in candidates.items()
@@ -2540,10 +2690,13 @@ async def retrieve_entries(
2540
2690
 
2541
2691
  # 5. Visibility (account RLS already scoped the fetch; this adds per-user +
2542
2692
  # room scoping) applied ONCE over the full merged set so lexical-only
2543
- # hits are filtered exactly like semantic ones — no leak path.
2693
+ # hits are filtered exactly like semantic ones — no leak path. The
2694
+ # lessons kept aside are filtered the same way: a replacement link
2695
+ # read from a lesson the caller cannot see is a leak by another name.
2544
2696
  if vis is not None and vis.should_filter():
2545
2697
  allowed = {e.entry_key for e in vis.filter_entries([v["entry"] for v in candidates.values()])}
2546
2698
  candidates = {k: v for k, v in candidates.items() if k in allowed}
2699
+ lessons = vis.filter_entries(lessons) if lessons else lessons
2547
2700
 
2548
2701
  # 6. Artifact awareness (column authoritative once backfilled; classify on
2549
2702
  # the fly otherwise so demotion works immediately).
@@ -2594,17 +2747,34 @@ async def retrieve_entries(
2594
2747
  # be conditioned on the query — and a discredited rule that still works
2595
2748
  # for tasks like this one is kept, labelled ``contested``, rather than
2596
2749
  # hidden from the one class that needs it.
2750
+ #
2751
+ # The same record, read the other way: a *validated* rule whose two
2752
+ # most recent outcomes on tasks like this one were failures has
2753
+ # stopped working for this class of task, whatever its pooled label
2754
+ # says (``locally_discredited``). It leaves the ranked list for the
2755
+ # avoid list, with what replaced it. Grid v4 measured the alternative
2756
+ # — the rule kept its ``validated`` label and its rank through 4-7
2757
+ # failures on the same quirk, because the pooled record was long and
2758
+ # the failures few.
2597
2759
  local_evidence: dict[str, dict[str, Any]] = {}
2598
2760
  if _local_evidence_enabled() and topical in query_vectors and candidates:
2599
2761
  near_fn = getattr(_get_memory()._adapter, "evidence_near", None)
2600
2762
  if callable(near_fn):
2763
+ from amfs_core.actions import PRIORS_MIN_SIMILARITY as _PRIORS_MIN_SIM
2764
+
2601
2765
  by_relevance = sorted(
2602
2766
  candidates.values(), key=lambda v: (v["sim"], v["keyword"]), reverse=True
2603
2767
  )
2604
2768
  head_keys = [v["entry"].entry_key for v in by_relevance[:LOCAL_EVIDENCE_HEAD]]
2605
2769
  try:
2770
+ # The priors' absolute floor, under the relative weighting the
2771
+ # adapter applies: an outcome on an unrelated task is not local
2772
+ # evidence however alone it is.
2606
2773
  local_evidence = await _offload(
2607
- _db_executor, near_fn, head_keys, query_vectors[topical]
2774
+ _db_executor,
2775
+ functools.partial(near_fn, min_similarity=_PRIORS_MIN_SIM),
2776
+ head_keys,
2777
+ query_vectors[topical],
2608
2778
  )
2609
2779
  except Exception: # noqa: BLE001 - the pooled record stands
2610
2780
  logger.debug("evidence_near failed", exc_info=True)
@@ -2614,6 +2784,8 @@ async def retrieve_entries(
2614
2784
  # entry_key -> (similarity, keyword) for the query-scoped shift reading.
2615
2785
  avoided_match: dict[str, tuple[float, float]] = {}
2616
2786
  rescued: set[str] = set()
2787
+ # Validated by the pooled record, failed on the last two tasks like this.
2788
+ locally_discredited: set[str] = set()
2617
2789
  if not req.include_discredited:
2618
2790
  kept_candidates: dict[str, dict[str, Any]] = {}
2619
2791
  for k, v in candidates.items():
@@ -2624,6 +2796,10 @@ async def retrieve_entries(
2624
2796
  elif v["sim"] > 0.0 or v["keyword"] > 0.0:
2625
2797
  avoided.append(v["entry"])
2626
2798
  avoided_match[k] = (float(v["sim"]), float(v["keyword"]))
2799
+ elif _locally_discredited(local_evidence.get(k)):
2800
+ locally_discredited.add(k)
2801
+ avoided.append(v["entry"])
2802
+ avoided_match[k] = (float(v["sim"]), float(v["keyword"]))
2627
2803
  else:
2628
2804
  kept_candidates[k] = v
2629
2805
  candidates = kept_candidates
@@ -2640,9 +2816,13 @@ async def retrieve_entries(
2640
2816
  keyword_weight = 0.15
2641
2817
  evidence_weight = req.evidence_weight
2642
2818
 
2819
+ def _is_procedure(e: MemoryEntry) -> bool:
2820
+ mt = getattr(e, "memory_type", None)
2821
+ return str(getattr(mt, "value", mt)) == MemoryType.PROCEDURE.value
2822
+
2643
2823
  def _composite(
2644
2824
  relevance: float, recency: float, conf: float, keyword: float, artifact: bool,
2645
- evidence: float = 0.0,
2825
+ evidence: float = 0.0, procedure: bool = False,
2646
2826
  ) -> float:
2647
2827
  """The composite score, in one place because step 8 recomputes it.
2648
2828
 
@@ -2657,6 +2837,9 @@ async def retrieve_entries(
2657
2837
  outcomes; this term is what separates an author's untested 0.9 from a
2658
2838
  0.9 that has been confirmed a dozen times, and what pushes an entry
2659
2839
  with a mixed record below both.
2840
+
2841
+ *procedure* applies ``PROCEDURE_BOOST``: at equal relevance, how to do
2842
+ the task ranks above a fact about it.
2660
2843
  """
2661
2844
  score = composite_score(
2662
2845
  relevance=relevance,
@@ -2671,7 +2854,34 @@ async def retrieve_entries(
2671
2854
  evidence_weight=evidence_weight,
2672
2855
  anchored=anchored,
2673
2856
  )
2674
- return score * ARTIFACT_PENALTY if artifact else score
2857
+ if artifact:
2858
+ score *= ARTIFACT_PENALTY
2859
+ if procedure:
2860
+ score *= PROCEDURE_BOOST
2861
+ return score
2862
+
2863
+ # Environment scoping: a procedure whose stated environment preconditions
2864
+ # contradict the asking run (``{"runtime": "python3.12"}`` against a
2865
+ # python3.9 run) is a way of doing the task somewhere else. It is dropped
2866
+ # from the hits and named in ``_meta.not_applicable`` so the agent knows a
2867
+ # way exists. Without an environment on the request nothing is dropped.
2868
+ environment = {k: v for k, v in (req.environment or {}).items() if v}
2869
+ not_applicable: list[dict[str, Any]] = []
2870
+ if environment:
2871
+ from amfs_core.models import preconditions_status as _preconditions_status
2872
+
2873
+ kept_env: dict[Any, Any] = {}
2874
+ for ck, slot in candidates.items():
2875
+ entry = slot["entry"]
2876
+ if _is_procedure(entry):
2877
+ status, detail = _preconditions_status(entry.value, environment)
2878
+ if status == "not_applicable":
2879
+ not_applicable.append({
2880
+ "entity_path": entry.entity_path, "key": entry.key, "why": detail,
2881
+ })
2882
+ continue
2883
+ kept_env[ck] = slot
2884
+ candidates = kept_env
2675
2885
 
2676
2886
  scored: list[tuple[MemoryEntry, float, dict[str, Any]]] = []
2677
2887
  for slot in candidates.values():
@@ -2688,6 +2898,7 @@ async def retrieve_entries(
2688
2898
  recency = 0.0
2689
2899
  conf = float(entry.confidence)
2690
2900
  artifact = _is_artifact(entry)
2901
+ procedure = _is_procedure(entry)
2691
2902
  pooled = _evidence_signal(entry)
2692
2903
  local = local_evidence.get(entry.entry_key)
2693
2904
  evidence, local_w = _blend_local_evidence(pooled, local)
@@ -2709,6 +2920,7 @@ async def retrieve_entries(
2709
2920
  "evidence": evidence,
2710
2921
  "evidence_status": status,
2711
2922
  "is_artifact": artifact,
2923
+ "is_procedure": procedure,
2712
2924
  }
2713
2925
  if local_w > 0.0 and local is not None:
2714
2926
  bd["evidence_local"] = {
@@ -2717,7 +2929,11 @@ async def retrieve_entries(
2717
2929
  "n": int(local.get("n", 0)),
2718
2930
  "weight": round(local_w, 3),
2719
2931
  }
2720
- scored.append((entry, _composite(sim, recency, conf, keyword, artifact, evidence), bd))
2932
+ scored.append((
2933
+ entry,
2934
+ _composite(sim, recency, conf, keyword, artifact, evidence, procedure),
2935
+ bd,
2936
+ ))
2721
2937
 
2722
2938
  scored.sort(key=lambda t: t[1], reverse=True)
2723
2939
 
@@ -2765,6 +2981,7 @@ async def retrieve_entries(
2765
2981
  _composite(
2766
2982
  norm, bd["recency"], bd["confidence"], bd["keyword"],
2767
2983
  bd["is_artifact"], bd.get("evidence", 0.0),
2984
+ bd.get("is_procedure", False),
2768
2985
  ),
2769
2986
  {**bd, "rerank": rs, "rerank_normalised": norm,
2770
2987
  "rerank_absolute": absolute_score,
@@ -2865,13 +3082,16 @@ async def retrieve_entries(
2865
3082
  # or carries a rare query term the leader lacks (its lexical
2866
3083
  # coverage ahead by ADAPTIVE_K_KEYWORD_GAP) — the cases where the
2867
3084
  # leader's record, not its topic, put it first, and the agent
2868
- # should still see what the query was actually about.
3085
+ # should still see what the query was actually about. A rescued
3086
+ # peer — discredited by the pooled record, confirmed on tasks
3087
+ # like this one — is read as validated here: the record that
3088
+ # kept it is the local one.
2869
3089
  top_rel = float(head[0][2].get("relevance") or 0.0)
2870
3090
  top_kw = float(head[0][2].get("keyword") or 0.0)
2871
3091
  head = [head[0]] + [
2872
3092
  t for t in head[1:]
2873
3093
  if (
2874
- t[0].evidence_status == "validated"
3094
+ (t[0].evidence_status == "validated" or t[0].entry_key in rescued)
2875
3095
  and t[1] >= top_score * ADAPTIVE_K_KEEP_RATIO
2876
3096
  )
2877
3097
  or float(t[2].get("relevance") or 0.0) > top_rel
@@ -2880,49 +3100,15 @@ async def retrieve_entries(
2880
3100
  else:
2881
3101
  head = [t for t in head if t[1] >= top_score * ADAPTIVE_K_KEEP_RATIO] or head[:1]
2882
3102
 
2883
- render = _compact_entry_response if req.compact else _entry_to_response
2884
- out: list[dict[str, Any]] = []
2885
- for rank, (entry, score, breakdown) in enumerate(head):
2886
- data = _compact_entry_response(entry, rank=rank) if req.compact else render(entry)
2887
- data["_score"] = round(score, 4)
2888
- if entry.entry_key in rescued:
2889
- # The label the ranking used, not the pooled one the entry carries:
2890
- # to this query the rule is contested, not discredited, and the
2891
- # agent reads the top-level field.
2892
- data["evidence_status"] = breakdown.get("evidence_status")
2893
- data["_rescued"] = True
2894
- if req.compact:
2895
- data["_breakdown"] = {"evidence_status": breakdown.get("evidence_status")}
2896
- else:
2897
- data["_breakdown"] = {
2898
- k: round(v, 4) if isinstance(v, float) else v
2899
- for k, v in breakdown.items()
2900
- }
2901
- out.append(data)
2902
- if req.include_avoid and avoided:
2903
- avoided.sort(key=lambda e: (e.last_outcome_at or e.provenance.written_at), reverse=True)
2904
- for entry in avoided[:AVOID_LIST_MAX]:
2905
- # A preview, in compact mode: an avoid row's work is to name what
2906
- # stopped working and how it failed, and a 120-character head is
2907
- # enough to recognise it. Rendering it at rank 0 carried the whole
2908
- # value, which for three avoided entries cost more than the hits.
2909
- data = (
2910
- _compact_entry_response(entry, rank=_COMPACT_FULL_HITS) if req.compact
2911
- else render(entry)
2912
- )
2913
- data["_score"] = 0.0
2914
- data["_avoid"] = True
2915
- data["_breakdown"] = {
2916
- "evidence": -1.0,
2917
- "evidence_status": "discredited",
2918
- "failure_count": entry.failure_count,
2919
- "last_outcome": entry.last_outcome,
2920
- }
2921
- out.append(data)
2922
-
2923
3103
  # 12. Action priors and a recommendation, as one trailing element the client
2924
3104
  # asked for. What the entries cannot say — "tried here and failed",
2925
3105
  # "nobody has tried X" — comes from the outcome record, not from memory.
3106
+ # Computed before the hits are rendered: the query-scoped shift it
3107
+ # reads also tightens the hit list (11b), and the contrast it finds
3108
+ # names what to do instead of an avoided entry.
3109
+ meta: dict[str, Any] | None = None
3110
+ priors: dict[str, Any] | None = None
3111
+ shifted_local = False
2926
3112
  if req.include_priors and req.entity_path:
2927
3113
  from amfs_core.actions import recommend as _recommend
2928
3114
 
@@ -2940,6 +3126,7 @@ async def retrieve_entries(
2940
3126
  text=priors_text,
2941
3127
  embedder=embedder,
2942
3128
  candidate_actions=req.candidate_actions,
3129
+ environment=environment,
2943
3130
  query_vector=priors_vec,
2944
3131
  )
2945
3132
  top = head[0][0] if head else None
@@ -3032,6 +3219,7 @@ async def retrieve_entries(
3032
3219
  top_shifted = bool(
3033
3220
  top is not None and not top_rescued and _regime_shifted(top, now=now)
3034
3221
  )
3222
+ hit_statuses = [str(e.evidence_status) for e, _, _ in head if e.evidence_status]
3035
3223
  recommendation = _recommend(
3036
3224
  priors,
3037
3225
  agent_id=req.agent_id or "",
@@ -3044,13 +3232,20 @@ async def retrieve_entries(
3044
3232
  top_hit_shifted=top_shifted,
3045
3233
  regime_shift=shifted_local,
3046
3234
  regime_shift_at=_shift_at(shifted_local_entries),
3235
+ abstain=req.abstain,
3236
+ hit_statuses=hit_statuses,
3047
3237
  # ``action_stats`` is every outcome on the entity, no similarity:
3048
3238
  # a record that can name a winner but is not about this kind of
3049
3239
  # task, so a shift read over it does not send the agent exploring.
3050
3240
  priors_are_local=(priors or {}).get("source") != "action_stats",
3051
3241
  )
3052
- if priors is not None or recommendation is not None or shifted:
3053
- out.append({
3242
+ if (
3243
+ priors is not None or recommendation is not None or shifted
3244
+ or req.abstain or not_applicable
3245
+ ):
3246
+ from amfs_core.actions import guidance_strength as _guidance_strength
3247
+
3248
+ meta = {
3054
3249
  "_meta": True,
3055
3250
  "priors": priors,
3056
3251
  "recommendation": recommendation,
@@ -3058,7 +3253,131 @@ async def retrieve_entries(
3058
3253
  "regime_shift_scope": (
3059
3254
  "query" if shifted_local else ("entity" if shifted else None)
3060
3255
  ),
3061
- })
3256
+ # Rated over the query-scoped shift, like the recommendation:
3257
+ # a rule that stopped working for another class of task does
3258
+ # not thin the guidance for this one.
3259
+ "guidance_strength": _guidance_strength(
3260
+ priors, hit_statuses, regime_shift=shifted_local
3261
+ ),
3262
+ }
3263
+ if not_applicable:
3264
+ meta["not_applicable"] = not_applicable
3265
+ if meta is None and not_applicable:
3266
+ # No priors asked for, but the environment dropped a procedure: say so
3267
+ # in the same trailing element, so the client learns a way exists.
3268
+ meta = {"_meta": True, "not_applicable": not_applicable}
3269
+
3270
+ # 11b. Under a query-scoped shift — a rule the query is about has stopped
3271
+ # working — the alternatives to the leader that the record has
3272
+ # already marked against on tasks like this are noise with a cost:
3273
+ # grid v4 found 5.4 discredited-or-contested rows in the context of
3274
+ # failing episodes against 3.0 in successes. Drop the non-leading
3275
+ # hits whose status is ``contested`` or whose local record on this
3276
+ # kind of task is more failure than success. Never below one hit,
3277
+ # and the leader itself is never dropped here: what to do about the
3278
+ # leader is the recommendation's call. A rescued hit is never weak:
3279
+ # it carries the ``contested`` label too, but it is in the list
3280
+ # because its local record says it works here — the opposite of
3281
+ # what the label means on a pooled record.
3282
+ if req.adaptive_k and shifted_local and len(head) > 1:
3283
+ def _weak_under_shift(t: tuple[MemoryEntry, float, dict[str, Any]]) -> bool:
3284
+ if t[0].entry_key in rescued:
3285
+ return False
3286
+ bd = t[2]
3287
+ if str(bd.get("evidence_status") or "") == "contested":
3288
+ return True
3289
+ local = bd.get("evidence_local") or {}
3290
+ return float(local.get("failure") or 0.0) > float(local.get("success") or 0.0)
3291
+
3292
+ head = [head[0]] + [t for t in head[1:] if not _weak_under_shift(t)]
3293
+
3294
+ # 13. Render. Hits first, then the avoid list, then the trailing meta
3295
+ # element (clients lift it out by its ``_meta`` flag).
3296
+ render = _compact_entry_response if req.compact else _entry_to_response
3297
+ out: list[dict[str, Any]] = []
3298
+ for rank, (entry, score, breakdown) in enumerate(head):
3299
+ data = _compact_entry_response(entry, rank=rank) if req.compact else render(entry)
3300
+ data["_score"] = round(score, 4)
3301
+ if entry.entry_key in rescued:
3302
+ # The label the ranking used, not the pooled one the entry carries:
3303
+ # to this query the rule is contested, not discredited, and the
3304
+ # agent reads the top-level field.
3305
+ data["evidence_status"] = breakdown.get("evidence_status")
3306
+ data["_rescued"] = True
3307
+ if req.compact:
3308
+ data["_breakdown"] = {"evidence_status": breakdown.get("evidence_status")}
3309
+ else:
3310
+ data["_breakdown"] = {
3311
+ k: round(v, 4) if isinstance(v, float) else v
3312
+ for k, v in breakdown.items()
3313
+ }
3314
+ out.append(data)
3315
+ if req.include_avoid and avoided:
3316
+ avoided.sort(key=lambda e: (e.last_outcome_at or e.provenance.written_at), reverse=True)
3317
+ # What replaced each avoided entry, per entry: the entries a contrast
3318
+ # lesson on this query says the task was resolved with (same reading
3319
+ # as the briefing's ``discredited[].replaced_by``), and the action
3320
+ # that resolved it — the lesson's own ``resolved_action`` where it
3321
+ # carries one, else the priors contrast for the same outcome. A
3322
+ # contrast the lessons do not tie to this entry says nothing about
3323
+ # it: two avoided rules must not both claim the one fix.
3324
+ replaced_by = _replacements_from_lessons(lessons) if lessons else {}
3325
+ resolved_action = _resolved_actions_from_lessons(
3326
+ lessons, (priors or {}).get("contrasts") or []
3327
+ )
3328
+ for entry in avoided[:AVOID_LIST_MAX]:
3329
+ data = (
3330
+ _compact_entry_response(entry, rank=_COMPACT_FULL_HITS) if req.compact
3331
+ else render(entry)
3332
+ )
3333
+ data["_score"] = 0.0
3334
+ data["_avoid"] = True
3335
+ local_only = entry.entry_key in locally_discredited
3336
+ replacements = list(replaced_by.get(entry.entry_key) or [])
3337
+ action = resolved_action.get(entry.entry_key)
3338
+ data["_breakdown"] = {
3339
+ "evidence": -1.0,
3340
+ "evidence_status": "discredited",
3341
+ "failure_count": entry.failure_count,
3342
+ "last_outcome": entry.last_outcome,
3343
+ "locally_discredited": local_only,
3344
+ "replaced_by": replacements,
3345
+ "resolved_with_action": action,
3346
+ }
3347
+ if req.compact:
3348
+ # An avoid row's work is to name what stopped working and what
3349
+ # replaced it, not to restate the rule: the value it carried
3350
+ # was, in grid v4, the text failing agents were still acting
3351
+ # on. Compact mode already cut it to a preview; a one-liner
3352
+ # that says so is the whole message. The field stays a string
3353
+ # so every client parses the row as an entry.
3354
+ #
3355
+ # The date is the last *failure*: for a locally discredited
3356
+ # rule the newest nearby outcome (a failure by construction —
3357
+ # the entry's own ``last_outcome_at`` may be a later success
3358
+ # on another class of task); otherwise the entry's last
3359
+ # outcome when that was a failure, else nothing.
3360
+ when = None
3361
+ if local_only:
3362
+ recent = (local_evidence.get(entry.entry_key) or {}).get("recent") or []
3363
+ when = _as_utc(recent[0].get("committed_at")) if recent else None
3364
+ elif entry.last_outcome is not None and not _evidence_is_success(entry.last_outcome):
3365
+ when = entry.last_outcome_at
3366
+ parts = [
3367
+ "stopped working on tasks like this" if local_only else "discredited",
3368
+ f"last failure {when.date().isoformat()}" if when else "",
3369
+ ]
3370
+ if replacements:
3371
+ parts.append("replaced by " + ", ".join(
3372
+ r.rsplit("/", 1)[-1] for r in replacements[:3]
3373
+ ))
3374
+ if action:
3375
+ parts.append(f"resolved instead with {action}")
3376
+ data["value"] = "; ".join(p for p in parts if p)
3377
+ data.pop("value_truncated", None)
3378
+ out.append(data)
3379
+ if meta is not None:
3380
+ out.append(meta)
3062
3381
  return out
3063
3382
 
3064
3383
 
@@ -4199,6 +4518,10 @@ async def commit_outcome(
4199
4518
  raise HTTPException(
4200
4519
  status_code=422, detail=f"session_metadata.attributes: {exc}"
4201
4520
  ) from exc
4521
+ # After validation, never before: the routing layer's stamps (which canary
4522
+ # this session was in, and which arm) are the server's, so they are exempt
4523
+ # from the client's key cap and overwrite anything the body claimed.
4524
+ client_attributes = _merge_routed_attributes(request, client_attributes) or {}
4202
4525
  client_llm_calls = client_meta.get("llm_calls")
4203
4526
  if not isinstance(client_llm_calls, list):
4204
4527
  client_llm_calls = []
@@ -4512,6 +4835,33 @@ async def save_trace(
4512
4835
  session_id=trace.session_id,
4513
4836
  ),
4514
4837
  })
4838
+ raw_meta = body.get("session_metadata") if isinstance(body, dict) else None
4839
+ # The routing layer's stamps, on the same footing as in /outcomes: this is
4840
+ # the other request a routed session's trace can arrive on, and a canary
4841
+ # arm that is stamped on one path and not the other is a tally that counts
4842
+ # SDK sessions in neither arm. Written onto the trace that is saved and onto
4843
+ # the raw metadata that is sealed, since the seal prefers the raw body when
4844
+ # there is one and the trace's own metadata when there is not. Through the
4845
+ # same merge as /outcomes, so a decided-but-unrouted request (``{}``) drops
4846
+ # the client's own canary claims here too — this is the path every
4847
+ # HttpAdapter commit seals on, since ``trace_follows`` skips the other.
4848
+ if _routed_trace_attributes(req) is not None:
4849
+ meta = trace.session_metadata or SessionMetadata()
4850
+ existing = getattr(meta, "attributes", None)
4851
+ merged = _merge_routed_attributes(
4852
+ req, dict(existing) if isinstance(existing, dict) else {}
4853
+ )
4854
+ trace = trace.model_copy(
4855
+ update={"session_metadata": meta.model_copy(update={"attributes": merged or {}})}
4856
+ )
4857
+ if isinstance(raw_meta, dict):
4858
+ raw_attrs = raw_meta.get("attributes")
4859
+ raw_meta = {
4860
+ **raw_meta,
4861
+ "attributes": _merge_routed_attributes(
4862
+ req, dict(raw_attrs) if isinstance(raw_attrs, dict) else {}
4863
+ ) or {},
4864
+ }
4515
4865
  # Both DB round trips off the event loop; the seal takes its own lock on
4516
4866
  # the trace store, and nothing here reads the shared handle's tracker, so
4517
4867
  # unlike /outcomes this path needs no serialisation of its own.
@@ -4522,7 +4872,6 @@ async def save_trace(
4522
4872
  # is sealed, so the immutable copy carries the persisted id; the raw body is
4523
4873
  # passed alongside because ``model_validate`` above dropped the
4524
4874
  # ``session_metadata`` keys the Pro recorder's spans travel in.
4525
- raw_meta = body.get("session_metadata") if isinstance(body, dict) else None
4526
4875
  immutable_trace_id = await _offload(
4527
4876
  _db_executor, _auto_seal_trace, mem, saved, session_metadata=raw_meta
4528
4877
  )
@@ -7853,6 +8202,11 @@ async def get_briefing(
7853
8202
  credit_reuse: bool = Query(False),
7854
8203
  compact: bool = Query(False),
7855
8204
  since: datetime | None = Query(None),
8205
+ branch: str | None = Query(None),
8206
+ env_model: str | None = Query(None),
8207
+ env_agent_version: str | None = Query(None),
8208
+ env_runtime: str | None = Query(None),
8209
+ env_platform: str | None = Query(None),
7856
8210
  # See retrieve_entries: injected on the type, defaulted so the handler stays
7857
8211
  # callable in-process without one.
7858
8212
  response: Response = None,
@@ -7863,6 +8217,11 @@ async def get_briefing(
7863
8217
  *compact* returns only the lead entity digest with its hot context and
7864
8218
  evidence sections, narrative trimmed. *since* trims the list sections to
7865
8219
  what changed after that moment — the delta since the last briefing.
8220
+ *branch* reads the hot context and evidence sections from that memory
8221
+ branch instead of ``main`` — how a repair branch or a canary is briefed
8222
+ before it is merged. The ``env_*`` parameters describe the asking run;
8223
+ with them each procedure in the lead digest is marked applicable or not
8224
+ against its environment preconditions.
7866
8225
 
7867
8226
  *credit_reuse* books the briefing as a real read of the knowledge it
7868
8227
  surfaces. It is off by default and has to be asked for, because the same
@@ -7880,6 +8239,21 @@ async def get_briefing(
7880
8239
  }
7881
8240
  if since is not None:
7882
8241
  briefing_kwargs["since"] = since
8242
+ environment = {
8243
+ k: v for k, v in (
8244
+ ("model", env_model), ("agent_version", env_agent_version),
8245
+ ("runtime", env_runtime), ("platform", env_platform),
8246
+ ) if v
8247
+ }
8248
+ if environment:
8249
+ briefing_kwargs["environment"] = environment
8250
+ # Resolved through the routing hook like every other read, then passed only
8251
+ # when it is not main so a server-side ``briefing`` that predates the
8252
+ # keyword keeps working (``mem.briefing`` here is always current, but the
8253
+ # in-process callers in Pro compose this handler with their own memory).
8254
+ branch = _effective_branch(request, branch)
8255
+ if branch != "main":
8256
+ briefing_kwargs["branch"] = branch
7883
8257
  digests = mem.briefing(**briefing_kwargs)
7884
8258
 
7885
8259
  vis = _get_visibility_filter(request)
@@ -7889,7 +8263,7 @@ async def get_briefing(
7889
8263
  # After visibility filtering, never before: an entry the caller may not see
7890
8264
  # must not be credited to them either.
7891
8265
  if credit_reuse:
7892
- await _credit_briefing_reuse(response, request, digests)
8266
+ await _credit_briefing_reuse(response, request, digests, branch=branch)
7893
8267
 
7894
8268
  return {
7895
8269
  "digests": [d.model_dump(mode="json") for d in digests],