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.
- {amfs_http_server-0.3.38 → amfs_http_server-0.3.39}/PKG-INFO +3 -3
- {amfs_http_server-0.3.38 → amfs_http_server-0.3.39}/pyproject.toml +3 -3
- {amfs_http_server-0.3.38 → amfs_http_server-0.3.39}/src/amfs_http/models.py +17 -3
- {amfs_http_server-0.3.38 → amfs_http_server-0.3.39}/src/amfs_http/server.py +233 -21
- {amfs_http_server-0.3.38 → amfs_http_server-0.3.39}/.gitignore +0 -0
- {amfs_http_server-0.3.38 → amfs_http_server-0.3.39}/src/amfs_http/__init__.py +0 -0
- {amfs_http_server-0.3.38 → amfs_http_server-0.3.39}/src/amfs_http/auth.py +0 -0
- {amfs_http_server-0.3.38 → amfs_http_server-0.3.39}/src/amfs_http/openrouter_proxy.py +0 -0
- {amfs_http_server-0.3.38 → amfs_http_server-0.3.39}/src/amfs_http/pro_provider.py +0 -0
- {amfs_http_server-0.3.38 → amfs_http_server-0.3.39}/src/amfs_http/pro_proxy.py +0 -0
- {amfs_http_server-0.3.38 → amfs_http_server-0.3.39}/src/amfs_http/sse.py +0 -0
- {amfs_http_server-0.3.38 → amfs_http_server-0.3.39}/src/amfs_http/tenant_middleware.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: amfs-http-server
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.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.
|
|
8
|
-
Requires-Dist: amfs>=0.5.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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",
|
|
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
|
|
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
|
-
|
|
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((
|
|
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
|
|
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],
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|