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.
- {amfs_http_server-0.3.37 → amfs_http_server-0.3.39}/PKG-INFO +3 -3
- {amfs_http_server-0.3.37 → amfs_http_server-0.3.39}/pyproject.toml +3 -3
- {amfs_http_server-0.3.37 → amfs_http_server-0.3.39}/src/amfs_http/models.py +17 -3
- {amfs_http_server-0.3.37 → amfs_http_server-0.3.39}/src/amfs_http/server.py +442 -68
- {amfs_http_server-0.3.37 → amfs_http_server-0.3.39}/.gitignore +0 -0
- {amfs_http_server-0.3.37 → amfs_http_server-0.3.39}/src/amfs_http/__init__.py +0 -0
- {amfs_http_server-0.3.37 → amfs_http_server-0.3.39}/src/amfs_http/auth.py +0 -0
- {amfs_http_server-0.3.37 → amfs_http_server-0.3.39}/src/amfs_http/openrouter_proxy.py +0 -0
- {amfs_http_server-0.3.37 → amfs_http_server-0.3.39}/src/amfs_http/pro_provider.py +0 -0
- {amfs_http_server-0.3.37 → amfs_http_server-0.3.39}/src/amfs_http/pro_proxy.py +0 -0
- {amfs_http_server-0.3.37 → amfs_http_server-0.3.39}/src/amfs_http/sse.py +0 -0
- {amfs_http_server-0.3.37 → 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
|
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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",
|
|
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
|
|
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,
|
|
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
|
-
|
|
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((
|
|
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
|
|
3053
|
-
|
|
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],
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|