graphify-mesh 0.0.22__py3-none-any.whl
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.
- graphify_mesh/__init__.py +20 -0
- graphify_mesh/_version.py +24 -0
- graphify_mesh/server/__init__.py +61 -0
- graphify_mesh/server/anchors.py +223 -0
- graphify_mesh/server/citation.py +17 -0
- graphify_mesh/server/config.py +195 -0
- graphify_mesh/server/context_pack.py +212 -0
- graphify_mesh/server/embed_query.py +206 -0
- graphify_mesh/server/frames.py +93 -0
- graphify_mesh/server/http_app.py +367 -0
- graphify_mesh/server/lexical_read.py +208 -0
- graphify_mesh/server/project_map.py +92 -0
- graphify_mesh/server/ranking.py +209 -0
- graphify_mesh/server/retrieval.py +343 -0
- graphify_mesh/server/rwlock.py +61 -0
- graphify_mesh/server/scope.py +207 -0
- graphify_mesh/server/sdk_app.py +108 -0
- graphify_mesh/server/server.py +813 -0
- graphify_mesh/server/similar.py +284 -0
- graphify_mesh/server/stdio_guard.py +287 -0
- graphify_mesh/server/store.py +733 -0
- graphify_mesh/server/traverse.py +305 -0
- graphify_mesh/sync/__init__.py +26 -0
- graphify_mesh/sync/backend.py +80 -0
- graphify_mesh/sync/cli.py +249 -0
- graphify_mesh/sync/clustering.py +182 -0
- graphify_mesh/sync/config.py +815 -0
- graphify_mesh/sync/discovery.py +668 -0
- graphify_mesh/sync/embed_similarity.py +516 -0
- graphify_mesh/sync/embedding.py +1372 -0
- graphify_mesh/sync/graphify_cli.py +941 -0
- graphify_mesh/sync/lexical_index.py +293 -0
- graphify_mesh/sync/locking.py +30 -0
- graphify_mesh/sync/naming.py +563 -0
- graphify_mesh/sync/naming_state.py +143 -0
- graphify_mesh/sync/overlay.py +146 -0
- graphify_mesh/sync/overlay_api.py +312 -0
- graphify_mesh/sync/overlay_depends.py +324 -0
- graphify_mesh/sync/overlay_refs.py +143 -0
- graphify_mesh/sync/overlay_similar.py +357 -0
- graphify_mesh/sync/perms.py +116 -0
- graphify_mesh/sync/pipeline.py +1891 -0
- graphify_mesh/sync/progress.py +41 -0
- graphify_mesh/sync/publish.py +374 -0
- graphify_mesh/sync/reaper.py +201 -0
- graphify_mesh/sync/reaper_cli.py +62 -0
- graphify_mesh/sync/registry.py +184 -0
- graphify_mesh/sync/repo_tags.py +299 -0
- graphify_mesh/sync/rss.py +68 -0
- graphify_mesh/sync/source_cache.py +149 -0
- graphify_mesh/sync/state.py +268 -0
- graphify_mesh/sync/sync_project.py +793 -0
- graphify_mesh/sync/tls.py +119 -0
- graphify_mesh/sync/validate.py +277 -0
- graphify_mesh/sync/vectors.py +234 -0
- graphify_mesh-0.0.22.dist-info/METADATA +219 -0
- graphify_mesh-0.0.22.dist-info/RECORD +60 -0
- graphify_mesh-0.0.22.dist-info/WHEEL +4 -0
- graphify_mesh-0.0.22.dist-info/entry_points.txt +4 -0
- graphify_mesh-0.0.22.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""`context_pack(goal, scope, token_budget)` tool (WS5 tool 5): evidence
|
|
2
|
+
cards built from `retrieval.rank`'s ranked hits, each carrying an explicit
|
|
3
|
+
`[repo:path:line]` citation (same shape as everywhere else in this
|
|
4
|
+
package), a snippet, and a confidence flag. Truncates strictly by whole-card
|
|
5
|
+
boundary — a card is either fully included or fully excluded, it is never
|
|
6
|
+
sliced mid-way (plan WS5 tool contract: "budgets").
|
|
7
|
+
|
|
8
|
+
Confidence flag is always `ranking.CONFIDENCE_EXTRACTED` here:
|
|
9
|
+
`context_pack` does not expose an `include_inferred` toggle the way
|
|
10
|
+
`rank()` itself does, so every hit this function can ever surface is, by
|
|
11
|
+
construction, drawn from the EXTRACTED-only default structural traversal
|
|
12
|
+
plus lexical/vector matches — there is no code path by which a
|
|
13
|
+
non-EXTRACTED (INFERRED/AMBIGUOUS) sourced hit reaches this function today. The field is still
|
|
14
|
+
carried explicitly (not hardcoded as a bare string) so a future
|
|
15
|
+
`include_inferred` passthrough only needs to plumb the real value through,
|
|
16
|
+
not invent the field.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from graphify_mesh.server import ranking
|
|
26
|
+
from graphify_mesh.server.retrieval import EmbedQueryFn, Hit, rank
|
|
27
|
+
from graphify_mesh.server.scope import RegistryEntry
|
|
28
|
+
from graphify_mesh.server.store import Generation
|
|
29
|
+
from graphify_mesh.sync.embedding import build_snippet, node_line
|
|
30
|
+
|
|
31
|
+
# Rough, dependency-free token estimate — no tokenizer dependency added just
|
|
32
|
+
# for a budget heuristic. ~4 chars/token is the commonly-cited English-text
|
|
33
|
+
# average; deliberately errs on the side of UNDER-counting cards (budget
|
|
34
|
+
# runs out slightly early rather than ever overshooting the caller's stated
|
|
35
|
+
# token_budget).
|
|
36
|
+
CHARS_PER_TOKEN_ESTIMATE = 4
|
|
37
|
+
|
|
38
|
+
# How many ranked candidates to consider building cards from before token
|
|
39
|
+
# budget truncation. Independent of the caller's k (context_pack has no k
|
|
40
|
+
# parameter) — kept generous since truncation is budget-driven, not count-driven.
|
|
41
|
+
CONTEXT_PACK_CANDIDATE_K = 30
|
|
42
|
+
|
|
43
|
+
# Snippets are read from the live working tree at query time (see
|
|
44
|
+
# `_card_from_hit`), not from a snapshot taken at sync time — the citation
|
|
45
|
+
# line number comes from the generation's graph data, so edits made after
|
|
46
|
+
# the last sync can silently shift the cited line. Every card therefore
|
|
47
|
+
# declares its snippet provenance explicitly, and is flagged stale when the
|
|
48
|
+
# source file's mtime is newer than the generation manifest's `created_at`.
|
|
49
|
+
SNIPPET_SOURCE_LIVE_TREE = "live_tree"
|
|
50
|
+
|
|
51
|
+
# The generation manifest's `created_at` format (written by
|
|
52
|
+
# `sync/pipeline.py` via `time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())`).
|
|
53
|
+
_MANIFEST_CREATED_AT_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class EvidenceCard:
|
|
58
|
+
citation: str # "[repo:path:line]"
|
|
59
|
+
repo: str
|
|
60
|
+
label: str
|
|
61
|
+
source_file: str
|
|
62
|
+
line: int | None
|
|
63
|
+
confidence: str
|
|
64
|
+
snippet: str
|
|
65
|
+
score: float
|
|
66
|
+
# Snippet provenance: snippets always come from the live working tree,
|
|
67
|
+
# never from generation data — declared explicitly so clients can tell
|
|
68
|
+
# the snippet's freshness class apart from the citation's (which IS
|
|
69
|
+
# generation data). `snippet_stale=True` marks the degraded case where
|
|
70
|
+
# the source file changed after the generation was built, so the cited
|
|
71
|
+
# line may no longer match the snippet content.
|
|
72
|
+
snippet_source: str = SNIPPET_SOURCE_LIVE_TREE
|
|
73
|
+
snippet_stale: bool = False
|
|
74
|
+
|
|
75
|
+
def estimated_tokens(self) -> int:
|
|
76
|
+
# citation + label are always present and cheap; snippet dominates
|
|
77
|
+
# the estimate. +1 keeps a zero-length snippet from costing 0 tokens.
|
|
78
|
+
return max(
|
|
79
|
+
1,
|
|
80
|
+
(len(self.snippet) + len(self.label) + len(self.citation)) // CHARS_PER_TOKEN_ESTIMATE,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class ContextPackResult:
|
|
86
|
+
goal: str
|
|
87
|
+
cards: list = field(default_factory=list)
|
|
88
|
+
truncated: bool = False
|
|
89
|
+
degraded: list = field(default_factory=list)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _root_for_repo(repo_id: str, registry_entries: list[RegistryEntry]) -> Path | None:
|
|
93
|
+
for entry in registry_entries:
|
|
94
|
+
if entry.repo_id == repo_id:
|
|
95
|
+
return entry.root
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _parse_manifest_timestamp(raw: object) -> float | None:
|
|
100
|
+
if not isinstance(raw, str):
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
parsed = datetime.strptime(raw, _MANIFEST_CREATED_AT_FORMAT)
|
|
104
|
+
except ValueError:
|
|
105
|
+
return None
|
|
106
|
+
return parsed.replace(tzinfo=UTC).timestamp()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _generation_created_at_epoch(generation: Generation) -> float | None:
|
|
110
|
+
"""Staleness baseline (UTC epoch) for mtime comparison. Prefers the
|
|
111
|
+
manifest's `sync_started_at` (files edited DURING a long sync are newer
|
|
112
|
+
than sync start and correctly flagged), falling back to `created_at`
|
|
113
|
+
(publish stamp) for older generations. Returns `None` when absent or
|
|
114
|
+
malformed — staleness then simply cannot be determined and no card is
|
|
115
|
+
flagged."""
|
|
116
|
+
started = _parse_manifest_timestamp(generation.manifest.get("sync_started_at"))
|
|
117
|
+
if started is not None:
|
|
118
|
+
return started
|
|
119
|
+
return _parse_manifest_timestamp(generation.manifest.get("created_at"))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _snippet_file_mtime(root: Path | None, source_file: str | None) -> float | None:
|
|
123
|
+
"""mtime of the live-tree file a snippet was read from, mirroring
|
|
124
|
+
`build_snippet`'s own path-safety gates (relative only, must resolve
|
|
125
|
+
inside `root`) so a hostile `source_file` can't be stat-probed outside
|
|
126
|
+
the repo root. Returns `None` whenever no snippet could have been read."""
|
|
127
|
+
if root is None or not source_file:
|
|
128
|
+
return None
|
|
129
|
+
if Path(source_file).is_absolute():
|
|
130
|
+
return None
|
|
131
|
+
path = (root / source_file).resolve()
|
|
132
|
+
try:
|
|
133
|
+
path.relative_to(root.resolve())
|
|
134
|
+
except ValueError:
|
|
135
|
+
return None
|
|
136
|
+
if not path.is_file():
|
|
137
|
+
return None
|
|
138
|
+
try:
|
|
139
|
+
return path.stat().st_mtime
|
|
140
|
+
except OSError:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _card_from_hit(
|
|
145
|
+
hit: Hit,
|
|
146
|
+
generation: Generation,
|
|
147
|
+
registry_entries: list[RegistryEntry],
|
|
148
|
+
generation_created_at: float | None,
|
|
149
|
+
) -> EvidenceCard:
|
|
150
|
+
node = generation.node_by_id.get(hit.node_id, {})
|
|
151
|
+
line = node_line(node)
|
|
152
|
+
root = _root_for_repo(hit.repo, registry_entries)
|
|
153
|
+
snippet = build_snippet(root, hit.source_file, line) if hit.source_file else ""
|
|
154
|
+
|
|
155
|
+
snippet_stale = False
|
|
156
|
+
# Staleness is checked whenever a source_file is cited, NOT gated on the
|
|
157
|
+
# snippet being non-empty: an empty snippet (cited line window beyond
|
|
158
|
+
# EOF, e.g. file truncated after the generation was built) is exactly
|
|
159
|
+
# the case where the citation is most likely invalid.
|
|
160
|
+
mtime = _snippet_file_mtime(root, hit.source_file) if hit.source_file else None
|
|
161
|
+
if generation_created_at is not None and mtime is not None and mtime > generation_created_at:
|
|
162
|
+
snippet_stale = True
|
|
163
|
+
|
|
164
|
+
line_part = line if line is not None else "?"
|
|
165
|
+
citation = f"[{hit.repo}:{hit.source_file}:{line_part}]"
|
|
166
|
+
return EvidenceCard(
|
|
167
|
+
citation=citation,
|
|
168
|
+
repo=hit.repo,
|
|
169
|
+
label=hit.label,
|
|
170
|
+
source_file=hit.source_file,
|
|
171
|
+
line=line,
|
|
172
|
+
confidence=ranking.CONFIDENCE_EXTRACTED,
|
|
173
|
+
snippet=snippet,
|
|
174
|
+
score=hit.score,
|
|
175
|
+
snippet_source=SNIPPET_SOURCE_LIVE_TREE,
|
|
176
|
+
snippet_stale=snippet_stale,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def build_context_pack(
|
|
181
|
+
goal: str,
|
|
182
|
+
generation: Generation,
|
|
183
|
+
repo_filter: frozenset[str] | None,
|
|
184
|
+
registry_entries: list[RegistryEntry],
|
|
185
|
+
token_budget: int,
|
|
186
|
+
embed_query_fn: EmbedQueryFn,
|
|
187
|
+
) -> ContextPackResult:
|
|
188
|
+
ranked = rank(goal, generation, repo_filter, CONTEXT_PACK_CANDIDATE_K, embed_query_fn)
|
|
189
|
+
generation_created_at = _generation_created_at_epoch(generation)
|
|
190
|
+
|
|
191
|
+
# Cards are built LAZILY inside the budget loop: each build costs a
|
|
192
|
+
# file open + read (`build_snippet`), so building all candidates
|
|
193
|
+
# eagerly wastes I/O whenever the budget keeps only the first few.
|
|
194
|
+
# Truncation stays whole-card and order-preserving: iterate hits in
|
|
195
|
+
# ranked order, stop at the first card that doesn't fit — a card is
|
|
196
|
+
# never split mid-way, and no card after the first non-fitting one is
|
|
197
|
+
# ever built.
|
|
198
|
+
selected: list[EvidenceCard] = []
|
|
199
|
+
remaining = token_budget
|
|
200
|
+
truncated = False
|
|
201
|
+
for hit in ranked.hits:
|
|
202
|
+
card = _card_from_hit(hit, generation, registry_entries, generation_created_at)
|
|
203
|
+
cost = card.estimated_tokens()
|
|
204
|
+
if cost > remaining:
|
|
205
|
+
truncated = True
|
|
206
|
+
break
|
|
207
|
+
selected.append(card)
|
|
208
|
+
remaining -= cost
|
|
209
|
+
|
|
210
|
+
return ContextPackResult(
|
|
211
|
+
goal=goal, cards=selected, truncated=truncated, degraded=ranked.degraded
|
|
212
|
+
)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Query-time embedding wrapper (WS5): turns a raw query string into a
|
|
2
|
+
vector for `retrieval.vector_candidates`, reusing the exact `/api/embed`
|
|
3
|
+
contract and base_url/model config `graphify_mesh.sync.embedding` /
|
|
4
|
+
`graphify_mesh.sync.config` already established for the sync pipeline (C9) — the
|
|
5
|
+
query MUST be embedded with the same model/endpoint that produced the
|
|
6
|
+
published vectors, or cosine similarity is meaningless.
|
|
7
|
+
|
|
8
|
+
Never raises: any transport/shape failure degrades to `None` (the caller,
|
|
9
|
+
`retrieval.vector_candidates`, surfaces an `"embeddings_unavailable:<reason>"`
|
|
10
|
+
marker in the response's `degraded` field), mirroring the pipeline's own
|
|
11
|
+
"never crash on a down local service" convention (C9) — just applied at query
|
|
12
|
+
time instead of build time.
|
|
13
|
+
|
|
14
|
+
Cost of degrading (2026-09-23): a backend can accept TCP and answer its
|
|
15
|
+
control-plane routes while every inference request hangs. Measured against
|
|
16
|
+
the then-configured host: `/api/tags` 200 in 0.07 s, `/api/embed` timing out
|
|
17
|
+
on 7 of 7 attempts. A per-query blocking call with a long timeout therefore
|
|
18
|
+
costs the full timeout on EVERY query for as long as the backend stays in
|
|
19
|
+
that state. Two guards below bound that cost:
|
|
20
|
+
|
|
21
|
+
* a short default timeout — a warm single-query embed measured 0.19-0.28 s
|
|
22
|
+
against a local backend, so the budget here is headroom, not a limit on
|
|
23
|
+
healthy operation;
|
|
24
|
+
* a negative cache — one failure suppresses the next `FAILURE_COOLDOWN`
|
|
25
|
+
seconds of calls in this process, so N degraded queries cost one timeout
|
|
26
|
+
per cooldown window instead of N timeouts.
|
|
27
|
+
|
|
28
|
+
Neither guard is a health probe. A startup probe against `/api/tags` would
|
|
29
|
+
have reported this backend HEALTHY (it answered 200 in 0.07 s while every
|
|
30
|
+
embed hung), so the only honest signal is a real embed call's outcome, which
|
|
31
|
+
is what the negative cache records.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import logging
|
|
37
|
+
import os
|
|
38
|
+
import threading
|
|
39
|
+
import time
|
|
40
|
+
|
|
41
|
+
from graphify_mesh.sync.config import EMBED_DEFAULT_BASE_URL, EMBED_DEFAULT_MODEL
|
|
42
|
+
from graphify_mesh.sync.embedding import embed_batch
|
|
43
|
+
|
|
44
|
+
log = logging.getLogger("graphify_mesh.server.embed_query")
|
|
45
|
+
|
|
46
|
+
# Kept short: a query-time embed call blocks one MCP `search`/`context_pack`
|
|
47
|
+
# invocation, unlike the sync pipeline's bulk embed calls which can afford a
|
|
48
|
+
# longer per-batch timeout. A healthy backend answers a single-input embed in
|
|
49
|
+
# well under a second (0.19-0.28 s measured warm), so this is ~10x headroom;
|
|
50
|
+
# what it actually bounds is the cost of a wedged backend.
|
|
51
|
+
DEFAULT_QUERY_EMBED_TIMEOUT = 2.5
|
|
52
|
+
# After a failure, skip the backend entirely for this long. Sized to be far
|
|
53
|
+
# longer than the timeout (so the amortized cost of a dead backend is near
|
|
54
|
+
# zero) but short enough that a backend coming back is picked up within a
|
|
55
|
+
# minute without restarting the daemon.
|
|
56
|
+
DEFAULT_FAILURE_COOLDOWN = 60.0
|
|
57
|
+
|
|
58
|
+
# Kept as a module constant for callers/tests that imported it before the
|
|
59
|
+
# env override existed.
|
|
60
|
+
QUERY_EMBED_TIMEOUT = DEFAULT_QUERY_EMBED_TIMEOUT
|
|
61
|
+
|
|
62
|
+
# Why the vector retriever contributed nothing. `retrieval` prefixes these
|
|
63
|
+
# with `embeddings_unavailable:` so a client can tell a wedged/unreachable
|
|
64
|
+
# backend apart from a misconfiguration without reading the server log.
|
|
65
|
+
REASON_TIMEOUT = "timeout"
|
|
66
|
+
REASON_UNREACHABLE = "unreachable"
|
|
67
|
+
REASON_BAD_RESPONSE = "bad_response"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _positive_float_env(name: str, default: float) -> float:
|
|
71
|
+
"""Read a positive float from the environment, falling back to `default`
|
|
72
|
+
on anything unparseable or non-positive. A bad value must not be able to
|
|
73
|
+
turn the timeout into 0 (every query degrades) or a negative cooldown."""
|
|
74
|
+
raw = os.environ.get(name)
|
|
75
|
+
if raw is None:
|
|
76
|
+
return default
|
|
77
|
+
try:
|
|
78
|
+
value = float(raw)
|
|
79
|
+
except ValueError:
|
|
80
|
+
log.warning("%s=%r is not a number, using %s", name, raw, default)
|
|
81
|
+
return default
|
|
82
|
+
if value <= 0:
|
|
83
|
+
log.warning("%s=%r is not positive, using %s", name, raw, default)
|
|
84
|
+
return default
|
|
85
|
+
return value
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _classify(exc: Exception) -> str:
|
|
89
|
+
"""Map an `embed_batch` RuntimeError onto one of the REASON_* constants.
|
|
90
|
+
|
|
91
|
+
`embed_batch` flattens every transport failure into one RuntimeError whose
|
|
92
|
+
message carries the original exception's text, so the message is the only
|
|
93
|
+
signal available without changing that function's contract."""
|
|
94
|
+
text = str(exc).lower()
|
|
95
|
+
if "timed out" in text or "timeout" in text:
|
|
96
|
+
return REASON_TIMEOUT
|
|
97
|
+
if "shape mismatch" in text or "not a json object" in text or "too large" in text:
|
|
98
|
+
return REASON_BAD_RESPONSE
|
|
99
|
+
return REASON_UNREACHABLE
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class QueryEmbedder:
|
|
103
|
+
"""Callable matching `retrieval.EmbedQueryFn` (`str -> list[float] | None`),
|
|
104
|
+
plus a `last_failure_reason` attribute the caller reads via `getattr` so
|
|
105
|
+
plain-callable stand-ins in tests keep satisfying the same contract."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, base_url: str, model: str, timeout: float, cooldown: float):
|
|
108
|
+
self.base_url = base_url
|
|
109
|
+
self.model = model
|
|
110
|
+
self.timeout = timeout
|
|
111
|
+
self.cooldown = cooldown
|
|
112
|
+
self.last_failure_reason: str | None = None
|
|
113
|
+
# Guards the cooldown deadline and the reason: the MCP server can
|
|
114
|
+
# serve concurrent requests, and without this two queries racing on a
|
|
115
|
+
# dead backend would each pay a full timeout.
|
|
116
|
+
self._lock = threading.Lock()
|
|
117
|
+
self._cooldown_until = 0.0
|
|
118
|
+
|
|
119
|
+
def _cooling_down(self) -> bool:
|
|
120
|
+
with self._lock:
|
|
121
|
+
return time.monotonic() < self._cooldown_until
|
|
122
|
+
|
|
123
|
+
def _record_failure(self, reason: str) -> None:
|
|
124
|
+
with self._lock:
|
|
125
|
+
self._cooldown_until = time.monotonic() + self.cooldown
|
|
126
|
+
self.last_failure_reason = reason
|
|
127
|
+
|
|
128
|
+
def _record_success(self) -> None:
|
|
129
|
+
with self._lock:
|
|
130
|
+
self._cooldown_until = 0.0
|
|
131
|
+
self.last_failure_reason = None
|
|
132
|
+
|
|
133
|
+
def __call__(self, query: str) -> list[float] | None:
|
|
134
|
+
if not query or not query.strip():
|
|
135
|
+
return None
|
|
136
|
+
if self._cooling_down():
|
|
137
|
+
# Deliberately silent: the failure that opened the cooldown was
|
|
138
|
+
# already logged once. Logging here would restore exactly the
|
|
139
|
+
# per-query noise the cooldown exists to remove.
|
|
140
|
+
return None
|
|
141
|
+
try:
|
|
142
|
+
vectors = embed_batch(self.base_url, self.model, [query], timeout=self.timeout)
|
|
143
|
+
except RuntimeError as exc:
|
|
144
|
+
reason = _classify(exc)
|
|
145
|
+
self._record_failure(reason)
|
|
146
|
+
log.warning(
|
|
147
|
+
"graphify-mesh: query embedding unavailable (%s), degrading to "
|
|
148
|
+
"lexical+structural only for the next %.0fs: %s",
|
|
149
|
+
reason,
|
|
150
|
+
self.cooldown,
|
|
151
|
+
exc,
|
|
152
|
+
)
|
|
153
|
+
return None
|
|
154
|
+
if not vectors:
|
|
155
|
+
self._record_failure(REASON_BAD_RESPONSE)
|
|
156
|
+
log.warning(
|
|
157
|
+
"graphify-mesh: query embedding returned no vector (%s), degrading to "
|
|
158
|
+
"lexical+structural only for the next %.0fs",
|
|
159
|
+
REASON_BAD_RESPONSE,
|
|
160
|
+
self.cooldown,
|
|
161
|
+
)
|
|
162
|
+
return None
|
|
163
|
+
self._record_success()
|
|
164
|
+
return vectors[0]
|
|
165
|
+
|
|
166
|
+
def failure_reason(self) -> str:
|
|
167
|
+
"""The reason to report when this embedder just declined to produce a
|
|
168
|
+
vector. A suppressed call reports the failure that opened the cooldown
|
|
169
|
+
rather than `cooling_down`: the cause is what the client needs, and
|
|
170
|
+
the log line already distinguishes the attempt from the suppression."""
|
|
171
|
+
with self._lock:
|
|
172
|
+
return self.last_failure_reason or REASON_UNREACHABLE
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def make_embed_query_fn(
|
|
176
|
+
base_url: str | None = None,
|
|
177
|
+
model: str | None = None,
|
|
178
|
+
timeout: float | None = None,
|
|
179
|
+
cooldown: float | None = None,
|
|
180
|
+
) -> QueryEmbedder:
|
|
181
|
+
"""Returns a `str -> list[float] | None` callable matching
|
|
182
|
+
`retrieval.EmbedQueryFn`, bound to the resolved base_url/model (env
|
|
183
|
+
override via the SAME `GRAPHIFY_MESH_OLLAMA_EMBED_*` variables the sync
|
|
184
|
+
pipeline reads, so a query never drifts onto a different embedding
|
|
185
|
+
space than the one the published vectors were built with).
|
|
186
|
+
|
|
187
|
+
Timeout and cooldown are read from `GRAPHIFY_MESH_QUERY_EMBED_TIMEOUT` /
|
|
188
|
+
`GRAPHIFY_MESH_QUERY_EMBED_COOLDOWN` so a wedged backend can be ridden out
|
|
189
|
+
by editing the unit, without promoting a new build."""
|
|
190
|
+
resolved_base_url = base_url or os.environ.get(
|
|
191
|
+
"GRAPHIFY_MESH_OLLAMA_EMBED_BASE_URL", EMBED_DEFAULT_BASE_URL
|
|
192
|
+
)
|
|
193
|
+
resolved_model = model or os.environ.get(
|
|
194
|
+
"GRAPHIFY_MESH_OLLAMA_EMBED_MODEL", EMBED_DEFAULT_MODEL
|
|
195
|
+
)
|
|
196
|
+
resolved_timeout = (
|
|
197
|
+
timeout
|
|
198
|
+
if timeout is not None
|
|
199
|
+
else _positive_float_env("GRAPHIFY_MESH_QUERY_EMBED_TIMEOUT", DEFAULT_QUERY_EMBED_TIMEOUT)
|
|
200
|
+
)
|
|
201
|
+
resolved_cooldown = (
|
|
202
|
+
cooldown
|
|
203
|
+
if cooldown is not None
|
|
204
|
+
else _positive_float_env("GRAPHIFY_MESH_QUERY_EMBED_COOLDOWN", DEFAULT_FAILURE_COOLDOWN)
|
|
205
|
+
)
|
|
206
|
+
return QueryEmbedder(resolved_base_url, resolved_model, resolved_timeout, resolved_cooldown)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""JSON-RPC frame limits and envelope validation shared by both transports.
|
|
2
|
+
|
|
3
|
+
Two things must read the same way whichever transport a client speaks:
|
|
4
|
+
|
|
5
|
+
1. The incoming-message size bound. `MAX_MESSAGE_BYTES` is the single number
|
|
6
|
+
both transports state — `stdio_guard.MAX_LINE_BYTES` per line, and the
|
|
7
|
+
HTTP guard plus the SDK session manager per request body.
|
|
8
|
+
2. The error a malformed frame gets back. The SDK validates envelopes with
|
|
9
|
+
pydantic and puts `str(ValidationError)` — which quotes the offending
|
|
10
|
+
input — in the client-visible message, so both transports check the
|
|
11
|
+
envelope themselves first and answer with a fixed generic message,
|
|
12
|
+
logging the detail instead.
|
|
13
|
+
|
|
14
|
+
The checks here are deliberately narrower than the SDK's model validation:
|
|
15
|
+
they reject only what no legal JSON-RPC 2.0 frame can be, so a frame that
|
|
16
|
+
passes still reaches the SDK for full validation. A client-to-server
|
|
17
|
+
*response* (a `result`/`error` frame answering a server-initiated request,
|
|
18
|
+
which MCP uses for sampling and roots) carries no `method` and must pass.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from typing import Final
|
|
25
|
+
|
|
26
|
+
# Hard per-message size cap for both transports. The shared HTTP daemon is one
|
|
27
|
+
# process serving every local agent, so its memory is a machine-wide resource,
|
|
28
|
+
# and no legitimate JSON-RPC frame for these seven tools comes close to 4 MiB —
|
|
29
|
+
# a lower ceiling is worth more here than matching stdio's earlier 10 MB.
|
|
30
|
+
# Over-limit input is rejected without ever being buffered whole.
|
|
31
|
+
MAX_MESSAGE_BYTES: Final = 4 * 1024 * 1024
|
|
32
|
+
|
|
33
|
+
PARSE_ERROR: Final = (-32700, "parse error")
|
|
34
|
+
NOT_A_SINGLE_OBJECT: Final = (
|
|
35
|
+
-32600,
|
|
36
|
+
"invalid request: expected a single JSON-RPC object (batch requests are not supported)",
|
|
37
|
+
)
|
|
38
|
+
INVALID_REQUEST: Final = (-32600, "invalid request")
|
|
39
|
+
INVALID_PARAMS: Final = (-32602, "invalid params")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def frame_id(parsed: object) -> str | int | None:
|
|
43
|
+
"""The request id to correlate an error response with, or `None` when the
|
|
44
|
+
frame carries no usable id. Only the two types JSON-RPC 2.0 allows count:
|
|
45
|
+
an id of any other shape is itself the defect being reported."""
|
|
46
|
+
if not isinstance(parsed, dict):
|
|
47
|
+
return None
|
|
48
|
+
candidate = parsed.get("id")
|
|
49
|
+
if isinstance(candidate, bool): # `True` is an int in Python, not a JSON-RPC id
|
|
50
|
+
return None
|
|
51
|
+
if isinstance(candidate, str | int):
|
|
52
|
+
return candidate
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def envelope_error(parsed: object) -> tuple[int, str] | None:
|
|
57
|
+
"""`(code, message)` for a frame the SDK would reject with a detail-
|
|
58
|
+
carrying validation error, or `None` when the frame is shaped like a
|
|
59
|
+
legal JSON-RPC 2.0 message."""
|
|
60
|
+
if not isinstance(parsed, dict):
|
|
61
|
+
return NOT_A_SINGLE_OBJECT
|
|
62
|
+
if parsed.get("jsonrpc") != "2.0":
|
|
63
|
+
return INVALID_REQUEST
|
|
64
|
+
|
|
65
|
+
raw_id = parsed.get("id")
|
|
66
|
+
if "id" in parsed and (isinstance(raw_id, bool) or not isinstance(raw_id, str | int | None)):
|
|
67
|
+
return INVALID_REQUEST
|
|
68
|
+
|
|
69
|
+
if "method" not in parsed:
|
|
70
|
+
# A response frame (result/error) answering a server-initiated
|
|
71
|
+
# request is the one legal shape with no method.
|
|
72
|
+
if "result" in parsed or "error" in parsed:
|
|
73
|
+
return None
|
|
74
|
+
return INVALID_REQUEST
|
|
75
|
+
if not isinstance(parsed["method"], str) or not parsed["method"]:
|
|
76
|
+
return INVALID_REQUEST
|
|
77
|
+
|
|
78
|
+
params = parsed.get("params")
|
|
79
|
+
if "params" in parsed and params is not None and not isinstance(params, dict):
|
|
80
|
+
# JSON-RPC 2.0 also allows positional array params; MCP does not use
|
|
81
|
+
# them, and the SDK models type `params` as `dict | None`, so an array
|
|
82
|
+
# here is a params-shape error rather than an invalid envelope.
|
|
83
|
+
# Explicit `null` is exempt: it is what that `| None` accepts, and a
|
|
84
|
+
# client whose serializer emits it has always worked.
|
|
85
|
+
return INVALID_PARAMS
|
|
86
|
+
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def error_frame_text(code: int, message: str, request_id: str | int | None = None) -> str:
|
|
91
|
+
return json.dumps(
|
|
92
|
+
{"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
|
|
93
|
+
)
|