amfs-http-server 0.3.38__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.38
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.34
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.38"
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.34",
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
@@ -71,6 +72,7 @@ from amfs_core.models import (
71
72
  MemoryEntry,
72
73
  SearchQuery,
73
74
  SemanticQuery,
75
+ SessionMetadata,
74
76
  )
75
77
  from amfs_core.pagination import (
76
78
  InvalidCursorError,
@@ -957,6 +959,7 @@ def _priors_for_retrieve(
957
959
  text: str,
958
960
  embedder: Any,
959
961
  candidate_actions: list[str] | None,
962
+ environment: Mapping[str, Any] | None = None,
960
963
  query_vector: list[float] | None = None,
961
964
  ) -> dict[str, Any] | None:
962
965
  """Action priors for ``entity_path`` on tasks like ``text``.
@@ -973,6 +976,9 @@ def _priors_for_retrieve(
973
976
  Synchronous, and blocking on the sync adapter: callers on the event loop
974
977
  run it through ``_offload``. ``query_vector`` is the caller's embedding of
975
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).
976
982
  """
977
983
  from amfs_core.actions import (
978
984
  PRIORS_K,
@@ -1012,7 +1018,9 @@ def _priors_for_retrieve(
1012
1018
  rows = []
1013
1019
  if not rows:
1014
1020
  return None
1015
- block = aggregate_priors(rows, candidate_actions=candidate_actions)
1021
+ block = aggregate_priors(
1022
+ rows, candidate_actions=candidate_actions, environment=environment or None
1023
+ )
1016
1024
  block["source"] = source
1017
1025
  block["entity_path"] = entity_path
1018
1026
  if source == "similar_outcomes" and rows:
@@ -1028,6 +1036,94 @@ def _get_visibility_filter(request: Request):
1028
1036
  return getattr(request.state, "visibility_filter", None)
1029
1037
 
1030
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
+
1031
1127
  def _active_visibility_filter(request: Request):
1032
1128
  """Return the visibility filter when per-user scoping applies, else None.
1033
1129
 
@@ -1451,7 +1547,7 @@ async def read_entry_by_query(
1451
1547
  request: Request,
1452
1548
  entity_path: str = Query(...),
1453
1549
  key: str = Query(...),
1454
- branch: str = Query("main"),
1550
+ branch: str | None = Query(None),
1455
1551
  response: Response = None,
1456
1552
  _auth: str | None = Depends(verify_api_key),
1457
1553
  ) -> dict[str, Any]:
@@ -1483,7 +1579,7 @@ async def read_entry(
1483
1579
  request: Request,
1484
1580
  entity_path: str,
1485
1581
  key: str,
1486
- branch: str = Query("main"),
1582
+ branch: str | None = Query(None),
1487
1583
  response: Response = None,
1488
1584
  _auth: str | None = Depends(verify_api_key),
1489
1585
  ) -> dict[str, Any]:
@@ -1494,9 +1590,10 @@ async def _read_entry(
1494
1590
  request: Request,
1495
1591
  entity_path: str,
1496
1592
  key: str,
1497
- branch: str,
1593
+ branch: str | None,
1498
1594
  response: Response | None = None,
1499
1595
  ) -> dict[str, Any]:
1596
+ branch = _effective_branch(request, branch)
1500
1597
  mem = _get_memory()
1501
1598
  credited = False
1502
1599
  if _async_adapter is not None:
@@ -1542,10 +1639,11 @@ async def entry_quality(
1542
1639
  request: Request,
1543
1640
  entity_path: str,
1544
1641
  key: str,
1545
- branch: str = Query("main"),
1642
+ branch: str | None = Query(None),
1546
1643
  _auth: str | None = Depends(verify_api_key),
1547
1644
  ) -> dict[str, Any]:
1548
1645
  """Compute a quality report for a stored entry on demand."""
1646
+ branch = _effective_branch(request, branch)
1549
1647
  mem = _get_memory()
1550
1648
  # Read straight from the adapter — this is an internal/dashboard inspection,
1551
1649
  # not an agent recall, so it must NOT increment recall_count (doing so
@@ -1657,11 +1755,7 @@ async def write_entry(
1657
1755
  ) -> dict[str, Any]:
1658
1756
  mem = _get_memory()
1659
1757
 
1660
- type_map = {
1661
- "fact": MemoryType.FACT,
1662
- "belief": MemoryType.BELIEF,
1663
- "experience": MemoryType.EXPERIENCE,
1664
- }
1758
+ type_map = {m.value: m for m in MemoryType}
1665
1759
  mt = type_map.get(req.memory_type.lower(), MemoryType.FACT)
1666
1760
 
1667
1761
  # The caller's identity lives on a per-request handle. This block awaits
@@ -1900,7 +1994,7 @@ async def write_entry(
1900
1994
  async def list_entries(
1901
1995
  request: Request,
1902
1996
  entity_path: str | None = Query(None),
1903
- branch: str = Query("main"),
1997
+ branch: str | None = Query(None),
1904
1998
  include_superseded: bool = Query(False),
1905
1999
  limit: int | None = Query(None, ge=1, le=10_000),
1906
2000
  offset: int = Query(0, ge=0),
@@ -1920,6 +2014,7 @@ async def list_entries(
1920
2014
  "[TLS-DIAG] /entries tls_account=%s state_account=%s state_user=%s has_tenant_ctx=%s",
1921
2015
  _tls_acct, _state_acct, _state_user, _has_ctx,
1922
2016
  )
2017
+ branch = _effective_branch(request, branch)
1923
2018
  mem = _get_memory()
1924
2019
  if limit is None and ENTRIES_DEFAULT_LIMIT > 0:
1925
2020
  limit = ENTRIES_DEFAULT_LIMIT
@@ -2116,7 +2211,7 @@ async def aggregate_entries_endpoint(
2116
2211
  )
2117
2212
 
2118
2213
  mem = _get_memory()
2119
- entries = mem.list(req.entity_path, branch=req.branch)
2214
+ entries = mem.list(req.entity_path, branch=_effective_branch(request, req.branch))
2120
2215
 
2121
2216
  vis = _get_visibility_filter(request)
2122
2217
  if vis is not None and vis.should_filter():
@@ -2286,7 +2381,7 @@ async def search_entries(
2286
2381
  response: Response = None,
2287
2382
  _auth: str | None = Depends(verify_api_key),
2288
2383
  ) -> list[dict[str, Any]]:
2289
- branch = getattr(req, "branch", "main") or "main"
2384
+ branch = _effective_branch(request, getattr(req, "branch", None))
2290
2385
  sq = SearchQuery(
2291
2386
  query=req.query,
2292
2387
  entity_path=req.entity_path,
@@ -2404,10 +2499,10 @@ async def retrieve_entries(
2404
2499
  """
2405
2500
  from datetime import datetime as _dt, timezone as _tz
2406
2501
 
2407
- from amfs_core.content import ARTIFACT_PENALTY, classify_artifact
2502
+ from amfs_core.content import ARTIFACT_PENALTY, PROCEDURE_BOOST, classify_artifact
2408
2503
  from amfs_core.query_norm import normalize_temporal
2409
2504
 
2410
- branch = req.branch or "main"
2505
+ branch = _effective_branch(request, req.branch)
2411
2506
  vis = _get_visibility_filter(request)
2412
2507
  embedder = _get_server_embedder()
2413
2508
 
@@ -2721,9 +2816,13 @@ async def retrieve_entries(
2721
2816
  keyword_weight = 0.15
2722
2817
  evidence_weight = req.evidence_weight
2723
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
+
2724
2823
  def _composite(
2725
2824
  relevance: float, recency: float, conf: float, keyword: float, artifact: bool,
2726
- evidence: float = 0.0,
2825
+ evidence: float = 0.0, procedure: bool = False,
2727
2826
  ) -> float:
2728
2827
  """The composite score, in one place because step 8 recomputes it.
2729
2828
 
@@ -2738,6 +2837,9 @@ async def retrieve_entries(
2738
2837
  outcomes; this term is what separates an author's untested 0.9 from a
2739
2838
  0.9 that has been confirmed a dozen times, and what pushes an entry
2740
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.
2741
2843
  """
2742
2844
  score = composite_score(
2743
2845
  relevance=relevance,
@@ -2752,7 +2854,34 @@ async def retrieve_entries(
2752
2854
  evidence_weight=evidence_weight,
2753
2855
  anchored=anchored,
2754
2856
  )
2755
- 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
2756
2885
 
2757
2886
  scored: list[tuple[MemoryEntry, float, dict[str, Any]]] = []
2758
2887
  for slot in candidates.values():
@@ -2769,6 +2898,7 @@ async def retrieve_entries(
2769
2898
  recency = 0.0
2770
2899
  conf = float(entry.confidence)
2771
2900
  artifact = _is_artifact(entry)
2901
+ procedure = _is_procedure(entry)
2772
2902
  pooled = _evidence_signal(entry)
2773
2903
  local = local_evidence.get(entry.entry_key)
2774
2904
  evidence, local_w = _blend_local_evidence(pooled, local)
@@ -2790,6 +2920,7 @@ async def retrieve_entries(
2790
2920
  "evidence": evidence,
2791
2921
  "evidence_status": status,
2792
2922
  "is_artifact": artifact,
2923
+ "is_procedure": procedure,
2793
2924
  }
2794
2925
  if local_w > 0.0 and local is not None:
2795
2926
  bd["evidence_local"] = {
@@ -2798,7 +2929,11 @@ async def retrieve_entries(
2798
2929
  "n": int(local.get("n", 0)),
2799
2930
  "weight": round(local_w, 3),
2800
2931
  }
2801
- 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
+ ))
2802
2937
 
2803
2938
  scored.sort(key=lambda t: t[1], reverse=True)
2804
2939
 
@@ -2846,6 +2981,7 @@ async def retrieve_entries(
2846
2981
  _composite(
2847
2982
  norm, bd["recency"], bd["confidence"], bd["keyword"],
2848
2983
  bd["is_artifact"], bd.get("evidence", 0.0),
2984
+ bd.get("is_procedure", False),
2849
2985
  ),
2850
2986
  {**bd, "rerank": rs, "rerank_normalised": norm,
2851
2987
  "rerank_absolute": absolute_score,
@@ -2990,6 +3126,7 @@ async def retrieve_entries(
2990
3126
  text=priors_text,
2991
3127
  embedder=embedder,
2992
3128
  candidate_actions=req.candidate_actions,
3129
+ environment=environment,
2993
3130
  query_vector=priors_vec,
2994
3131
  )
2995
3132
  top = head[0][0] if head else None
@@ -3082,6 +3219,7 @@ async def retrieve_entries(
3082
3219
  top_shifted = bool(
3083
3220
  top is not None and not top_rescued and _regime_shifted(top, now=now)
3084
3221
  )
3222
+ hit_statuses = [str(e.evidence_status) for e, _, _ in head if e.evidence_status]
3085
3223
  recommendation = _recommend(
3086
3224
  priors,
3087
3225
  agent_id=req.agent_id or "",
@@ -3094,12 +3232,19 @@ async def retrieve_entries(
3094
3232
  top_hit_shifted=top_shifted,
3095
3233
  regime_shift=shifted_local,
3096
3234
  regime_shift_at=_shift_at(shifted_local_entries),
3235
+ abstain=req.abstain,
3236
+ hit_statuses=hit_statuses,
3097
3237
  # ``action_stats`` is every outcome on the entity, no similarity:
3098
3238
  # a record that can name a winner but is not about this kind of
3099
3239
  # task, so a shift read over it does not send the agent exploring.
3100
3240
  priors_are_local=(priors or {}).get("source") != "action_stats",
3101
3241
  )
3102
- if priors is not None or recommendation is not None or shifted:
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
+
3103
3248
  meta = {
3104
3249
  "_meta": True,
3105
3250
  "priors": priors,
@@ -3108,7 +3253,19 @@ async def retrieve_entries(
3108
3253
  "regime_shift_scope": (
3109
3254
  "query" if shifted_local else ("entity" if shifted else None)
3110
3255
  ),
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
+ ),
3111
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}
3112
3269
 
3113
3270
  # 11b. Under a query-scoped shift — a rule the query is about has stopped
3114
3271
  # working — the alternatives to the leader that the record has
@@ -4361,6 +4518,10 @@ async def commit_outcome(
4361
4518
  raise HTTPException(
4362
4519
  status_code=422, detail=f"session_metadata.attributes: {exc}"
4363
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 {}
4364
4525
  client_llm_calls = client_meta.get("llm_calls")
4365
4526
  if not isinstance(client_llm_calls, list):
4366
4527
  client_llm_calls = []
@@ -4674,6 +4835,33 @@ async def save_trace(
4674
4835
  session_id=trace.session_id,
4675
4836
  ),
4676
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
+ }
4677
4865
  # Both DB round trips off the event loop; the seal takes its own lock on
4678
4866
  # the trace store, and nothing here reads the shared handle's tracker, so
4679
4867
  # unlike /outcomes this path needs no serialisation of its own.
@@ -4684,7 +4872,6 @@ async def save_trace(
4684
4872
  # is sealed, so the immutable copy carries the persisted id; the raw body is
4685
4873
  # passed alongside because ``model_validate`` above dropped the
4686
4874
  # ``session_metadata`` keys the Pro recorder's spans travel in.
4687
- raw_meta = body.get("session_metadata") if isinstance(body, dict) else None
4688
4875
  immutable_trace_id = await _offload(
4689
4876
  _db_executor, _auto_seal_trace, mem, saved, session_metadata=raw_meta
4690
4877
  )
@@ -8015,6 +8202,11 @@ async def get_briefing(
8015
8202
  credit_reuse: bool = Query(False),
8016
8203
  compact: bool = Query(False),
8017
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),
8018
8210
  # See retrieve_entries: injected on the type, defaulted so the handler stays
8019
8211
  # callable in-process without one.
8020
8212
  response: Response = None,
@@ -8025,6 +8217,11 @@ async def get_briefing(
8025
8217
  *compact* returns only the lead entity digest with its hot context and
8026
8218
  evidence sections, narrative trimmed. *since* trims the list sections to
8027
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.
8028
8225
 
8029
8226
  *credit_reuse* books the briefing as a real read of the knowledge it
8030
8227
  surfaces. It is off by default and has to be asked for, because the same
@@ -8042,6 +8239,21 @@ async def get_briefing(
8042
8239
  }
8043
8240
  if since is not None:
8044
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
8045
8257
  digests = mem.briefing(**briefing_kwargs)
8046
8258
 
8047
8259
  vis = _get_visibility_filter(request)
@@ -8051,7 +8263,7 @@ async def get_briefing(
8051
8263
  # After visibility filtering, never before: an entry the caller may not see
8052
8264
  # must not be credited to them either.
8053
8265
  if credit_reuse:
8054
- await _credit_briefing_reuse(response, request, digests)
8266
+ await _credit_briefing_reuse(response, request, digests, branch=branch)
8055
8267
 
8056
8268
  return {
8057
8269
  "digests": [d.model_dump(mode="json") for d in digests],