claude-smart 0.2.43 → 0.2.44
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +1 -1
- package/bin/claude-smart.js +2 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/README.md +21 -1
- package/plugin/pyproject.toml +2 -2
- package/plugin/scripts/backend-service.sh +50 -4
- package/plugin/scripts/cli.sh +2 -1
- package/plugin/scripts/ensure-plugin-root.sh +1 -0
- package/plugin/scripts/hook_entry.sh +5 -3
- package/plugin/scripts/smart-install.sh +2 -2
- package/plugin/src/README.md +57 -0
- package/plugin/uv.lock +126 -5
- package/plugin/vendor/reflexio/.env.example +9 -0
- package/plugin/vendor/reflexio/pyproject.toml +5 -2
- package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +0 -1
- package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +7 -4
- package/plugin/vendor/reflexio/reflexio/cli/env_loader.py +4 -4
- package/plugin/vendor/reflexio/reflexio/lib/_storage_labels.py +2 -2
- package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +13 -4
- package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +3 -1
- package/plugin/vendor/reflexio/reflexio/models/api_schema/validators.py +59 -6
- package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
- package/plugin/vendor/reflexio/reflexio/server/README.md +7 -1
- package/plugin/vendor/reflexio/reflexio/server/api.py +188 -34
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +34 -0
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +33 -11
- package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +278 -29
- package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +457 -181
- package/plugin/vendor/reflexio/reflexio/server/llm/llm_utils.py +28 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +22 -12
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +137 -9
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +1 -1
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +36 -3
- package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +13 -3
- package/plugin/vendor/reflexio/reflexio/server/llm/tools.py +17 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.0.prompt.md +1 -1
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.1.prompt.md +69 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.2.prompt.md +71 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.0.prompt.md +27 -23
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.2.prompt.md +234 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.3.prompt.md +244 -0
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.3.0.prompt.md +19 -9
- package/plugin/vendor/reflexio/reflexio/server/services/README.md +58 -0
- package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/group_evaluation_runner.py +1 -5
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +44 -2
- package/plugin/vendor/reflexio/reflexio/server/services/embedding_text.py +62 -0
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/pending_tool_call_dispatch.py +8 -1
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +91 -24
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +3 -1
- package/plugin/vendor/reflexio/reflexio/server/services/extractor_config_utils.py +6 -3
- package/plugin/vendor/reflexio/reflexio/server/services/extractor_interaction_utils.py +1 -1
- package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +18 -5
- package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +2 -2
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_consolidator.py +88 -3
- package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_deduplicator.py +36 -5
- package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_generation_service.py +6 -3
- package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_service.py +6 -3
- package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +32 -22
- package/plugin/vendor/reflexio/reflexio/server/services/service_utils.py +89 -4
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +46 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +12 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +1 -1
- package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +8 -4
|
@@ -1,9 +1,37 @@
|
|
|
1
1
|
import inspect
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
2
4
|
from copy import deepcopy
|
|
3
5
|
from typing import Any
|
|
4
6
|
|
|
5
7
|
from pydantic import BaseModel
|
|
6
8
|
|
|
9
|
+
|
|
10
|
+
def positive_int_env(name: str, default: int, logger: logging.Logger) -> int:
|
|
11
|
+
"""Resolve a strictly-positive int from environment variable ``name``.
|
|
12
|
+
|
|
13
|
+
Falls back to ``default`` when the variable is unset/blank, not a valid
|
|
14
|
+
integer (logging a warning in that case), or not strictly positive.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
name (str): Environment variable to read.
|
|
18
|
+
default (int): Value returned when the variable is missing or invalid.
|
|
19
|
+
logger (logging.Logger): Logger used to warn on a non-integer value.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
int: The parsed positive integer, or ``default``.
|
|
23
|
+
"""
|
|
24
|
+
raw = os.environ.get(name)
|
|
25
|
+
if not raw:
|
|
26
|
+
return default
|
|
27
|
+
try:
|
|
28
|
+
value = int(raw)
|
|
29
|
+
except ValueError:
|
|
30
|
+
logger.warning("Invalid %s=%r; falling back to default %d", name, raw, default)
|
|
31
|
+
return default
|
|
32
|
+
return value if value > 0 else default
|
|
33
|
+
|
|
34
|
+
|
|
7
35
|
_STRICT_SCHEMA_UNSUPPORTED_KEYWORDS = frozenset(
|
|
8
36
|
{
|
|
9
37
|
"exclusiveMaximum",
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Resolution order (highest priority first):
|
|
4
4
|
1. LLMConfig override (org-level configuration)
|
|
5
|
-
2.
|
|
6
|
-
3.
|
|
5
|
+
2. For embeddings, Reflexio's local default model
|
|
6
|
+
3. For other roles, llm_model_setting.json site var and provider autodetection
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
@@ -187,12 +187,12 @@ _PROVIDER_DEFAULTS: dict[str, ProviderDefaults] = {
|
|
|
187
187
|
embedding="local/minilm-l6-v2",
|
|
188
188
|
),
|
|
189
189
|
"openai": ProviderDefaults(
|
|
190
|
-
generation="gpt-5
|
|
191
|
-
evaluation="gpt-5-mini",
|
|
190
|
+
generation="gpt-5.5",
|
|
191
|
+
evaluation="gpt-5.4-mini",
|
|
192
192
|
should_run="gpt-5-nano",
|
|
193
193
|
pre_retrieval="gpt-5-nano",
|
|
194
194
|
embedding="text-embedding-3-small",
|
|
195
|
-
extraction_agent="gpt-5
|
|
195
|
+
extraction_agent="gpt-5.5",
|
|
196
196
|
),
|
|
197
197
|
"anthropic": ProviderDefaults(
|
|
198
198
|
generation="claude-sonnet-4-6",
|
|
@@ -372,16 +372,16 @@ def resolve_model_name(
|
|
|
372
372
|
config_override: str | None = None,
|
|
373
373
|
api_key_config: APIKeyConfig | None = None,
|
|
374
374
|
) -> str:
|
|
375
|
-
"""Resolve a model name using the
|
|
375
|
+
"""Resolve a model name using the role-specific default chain.
|
|
376
376
|
|
|
377
377
|
Resolution order (highest priority first):
|
|
378
378
|
1. config_override (from LLMConfig, org-level)
|
|
379
|
-
2.
|
|
380
|
-
3.
|
|
379
|
+
2. For EMBEDDING, the OSS local MiniLM model
|
|
380
|
+
3. For other roles, site_var_value then auto-detect from available API keys
|
|
381
381
|
|
|
382
382
|
Args:
|
|
383
383
|
role: The model role to resolve.
|
|
384
|
-
site_var_value: Value from llm_model_setting.json.
|
|
384
|
+
site_var_value: Value from llm_model_setting.json. Ignored for embeddings.
|
|
385
385
|
config_override: Value from org-level LLMConfig.
|
|
386
386
|
api_key_config: Optional org-level API key configuration for provider detection.
|
|
387
387
|
|
|
@@ -393,6 +393,11 @@ def resolve_model_name(
|
|
|
393
393
|
"""
|
|
394
394
|
if config_override:
|
|
395
395
|
return config_override
|
|
396
|
+
if role == ModelRole.EMBEDDING:
|
|
397
|
+
return (
|
|
398
|
+
_PROVIDER_DEFAULTS[_LOCAL_EMBEDDING_PROVIDER].embedding
|
|
399
|
+
or "local/minilm-l6-v2"
|
|
400
|
+
)
|
|
396
401
|
if site_var_value:
|
|
397
402
|
return site_var_value
|
|
398
403
|
providers = detect_available_providers(api_key_config)
|
|
@@ -404,7 +409,7 @@ def validate_llm_availability(
|
|
|
404
409
|
) -> None:
|
|
405
410
|
"""Validate that at least one LLM provider and one embedding provider are available.
|
|
406
411
|
|
|
407
|
-
Should be called once during startup. Logs
|
|
412
|
+
Should be called once during startup. Logs available providers at INFO level.
|
|
408
413
|
|
|
409
414
|
Args:
|
|
410
415
|
api_key_config: Optional org-level API key configuration.
|
|
@@ -449,7 +454,11 @@ def validate_llm_availability(
|
|
|
449
454
|
(p for p in providers if _PROVIDER_DEFAULTS[p].embedding), None
|
|
450
455
|
)
|
|
451
456
|
if embedding_provider:
|
|
452
|
-
logger.info(
|
|
457
|
+
logger.info(
|
|
458
|
+
"Cloud embedding provider available: %s (used when the saved "
|
|
459
|
+
"embedding model selects this provider)",
|
|
460
|
+
embedding_provider,
|
|
461
|
+
)
|
|
453
462
|
return
|
|
454
463
|
|
|
455
464
|
from reflexio.server.llm.providers.local_embedding_provider import (
|
|
@@ -458,7 +467,8 @@ def validate_llm_availability(
|
|
|
458
467
|
|
|
459
468
|
if is_chromadb_importable():
|
|
460
469
|
logger.info(
|
|
461
|
-
"
|
|
470
|
+
"Local MiniLM embedding fallback available: %s "
|
|
471
|
+
"(no cloud embedding provider configured)",
|
|
462
472
|
_LOCAL_EMBEDDING_PROVIDER,
|
|
463
473
|
)
|
|
464
474
|
return
|
|
@@ -8,6 +8,7 @@ also supporting a horizontally-scaled internal service in cloud deployments.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import json
|
|
11
12
|
import logging
|
|
12
13
|
import os
|
|
13
14
|
import time
|
|
@@ -25,12 +26,25 @@ _ENV_PROVIDER = "REFLEXIO_EMBEDDING_PROVIDER"
|
|
|
25
26
|
_ENV_SERVICE_URL = "REFLEXIO_EMBEDDING_SERVICE_URL"
|
|
26
27
|
_ENV_TIMEOUT_MS = "REFLEXIO_EMBEDDING_SERVICE_TIMEOUT_MS"
|
|
27
28
|
_ENV_EMBEDDING_PORT = "EMBEDDING_PORT"
|
|
29
|
+
_ENV_DAEMON_HOST = "REFLEXIO_EMBEDDING_DAEMON_HOST"
|
|
30
|
+
_ENV_LOCAL_SERVICE_PROBE_TIMEOUT_MS = (
|
|
31
|
+
"REFLEXIO_EMBEDDING_LOCAL_SERVICE_PROBE_TIMEOUT_MS"
|
|
32
|
+
)
|
|
28
33
|
_ENV_CLAUDE_SMART_LOCAL = "CLAUDE_SMART_USE_LOCAL_EMBEDDING"
|
|
34
|
+
_ENV_MAX_TEXTS_PER_REQUEST = "REFLEXIO_EMBEDDING_SERVICE_MAX_TEXTS_PER_REQUEST"
|
|
29
35
|
_DEFAULT_LOCAL_PORT = 8072
|
|
30
36
|
_DEFAULT_INTERNAL_SERVICE_TIMEOUT_MS = 2_000
|
|
31
37
|
_DEFAULT_LOCAL_SERVICE_TIMEOUT_MS = 30_000
|
|
38
|
+
_DEFAULT_LOCAL_SERVICE_PROBE_TIMEOUT_MS = 200
|
|
39
|
+
# Each request embeds its whole ``input`` list in one ``model.encode()`` on the
|
|
40
|
+
# service, so an unbounded request can exceed the client read timeout on a CPU
|
|
41
|
+
# daemon. Cap texts per request and concatenate; encode batching is a separate
|
|
42
|
+
# server-side knob (REFLEXIO_EMBED_BATCH_SIZE).
|
|
43
|
+
_DEFAULT_MAX_TEXTS_PER_REQUEST = 32
|
|
44
|
+
_LOCAL_SERVICE_PROBE_CACHE_SECONDS = 5.0
|
|
32
45
|
_SERVICE_MODES = {"local_service", "internal_service"}
|
|
33
46
|
_VALID_MODES = {"cloud", *_SERVICE_MODES, "inprocess", "off"}
|
|
47
|
+
_local_service_probe_cache: tuple[float, bool, str | None] | None = None
|
|
34
48
|
|
|
35
49
|
|
|
36
50
|
class EmbeddingUnavailableError(RuntimeError):
|
|
@@ -38,8 +52,25 @@ class EmbeddingUnavailableError(RuntimeError):
|
|
|
38
52
|
|
|
39
53
|
|
|
40
54
|
def _local_service_url() -> str:
|
|
55
|
+
host = os.environ.get(_ENV_DAEMON_HOST, "").strip() or "127.0.0.1"
|
|
41
56
|
port = os.environ.get(_ENV_EMBEDDING_PORT, str(_DEFAULT_LOCAL_PORT))
|
|
42
|
-
return f"http://
|
|
57
|
+
return f"http://{host}:{port}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _local_service_probe_timeout_seconds() -> float:
|
|
61
|
+
raw = os.environ.get(_ENV_LOCAL_SERVICE_PROBE_TIMEOUT_MS)
|
|
62
|
+
if raw is None:
|
|
63
|
+
return _DEFAULT_LOCAL_SERVICE_PROBE_TIMEOUT_MS / 1000
|
|
64
|
+
try:
|
|
65
|
+
timeout_ms = int(raw)
|
|
66
|
+
except ValueError:
|
|
67
|
+
_LOGGER.warning(
|
|
68
|
+
"%s must be an integer number of milliseconds; using %dms",
|
|
69
|
+
_ENV_LOCAL_SERVICE_PROBE_TIMEOUT_MS,
|
|
70
|
+
_DEFAULT_LOCAL_SERVICE_PROBE_TIMEOUT_MS,
|
|
71
|
+
)
|
|
72
|
+
return _DEFAULT_LOCAL_SERVICE_PROBE_TIMEOUT_MS / 1000
|
|
73
|
+
return max(timeout_ms, 1) / 1000
|
|
43
74
|
|
|
44
75
|
|
|
45
76
|
def embedding_service_url(mode: EmbeddingProviderMode | None = None) -> str:
|
|
@@ -78,6 +109,43 @@ def embedding_service_timeout_seconds(
|
|
|
78
109
|
return max(timeout_ms, 1) / 1000
|
|
79
110
|
|
|
80
111
|
|
|
112
|
+
def _is_local_model(model: str | None) -> bool:
|
|
113
|
+
return bool(model and model.startswith("local/"))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _local_service_status() -> tuple[bool, str | None]:
|
|
117
|
+
global _local_service_probe_cache
|
|
118
|
+
now = time.monotonic()
|
|
119
|
+
if (
|
|
120
|
+
_local_service_probe_cache is not None
|
|
121
|
+
and now - _local_service_probe_cache[0] < _LOCAL_SERVICE_PROBE_CACHE_SECONDS
|
|
122
|
+
):
|
|
123
|
+
return _local_service_probe_cache[1], _local_service_probe_cache[2]
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
response = httpx.get(
|
|
127
|
+
f"{_local_service_url()}/health",
|
|
128
|
+
timeout=_local_service_probe_timeout_seconds(),
|
|
129
|
+
)
|
|
130
|
+
reachable = response.status_code < 500
|
|
131
|
+
active_model = response.json().get("active_model") if reachable else None
|
|
132
|
+
if not isinstance(active_model, str):
|
|
133
|
+
active_model = None
|
|
134
|
+
except httpx.HTTPError:
|
|
135
|
+
reachable = False
|
|
136
|
+
active_model = None
|
|
137
|
+
except ValueError:
|
|
138
|
+
reachable = False
|
|
139
|
+
active_model = None
|
|
140
|
+
_local_service_probe_cache = (now, reachable, active_model)
|
|
141
|
+
return reachable, active_model
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _local_service_supports_model(model: str | None) -> bool:
|
|
145
|
+
reachable, active_model = _local_service_status()
|
|
146
|
+
return reachable and (active_model is None or active_model == model)
|
|
147
|
+
|
|
148
|
+
|
|
81
149
|
def _ordered_embeddings_from_response(
|
|
82
150
|
data: Any, expected_count: int
|
|
83
151
|
) -> list[list[float]]:
|
|
@@ -123,10 +191,10 @@ def _ordered_embeddings_from_response(
|
|
|
123
191
|
def embedding_provider_mode(model: str | None = None) -> EmbeddingProviderMode:
|
|
124
192
|
"""Resolve the embedding provider mode for a model.
|
|
125
193
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
194
|
+
Explicit legacy env modes keep their historical behavior. With no explicit
|
|
195
|
+
env mode, routing is model-driven: ``local/*`` uses the local daemon when it
|
|
196
|
+
is reachable and otherwise falls back to the in-process embedder; cloud
|
|
197
|
+
models use their provider directly.
|
|
130
198
|
"""
|
|
131
199
|
configured = os.environ.get(_ENV_PROVIDER)
|
|
132
200
|
if configured:
|
|
@@ -144,8 +212,8 @@ def embedding_provider_mode(model: str | None = None) -> EmbeddingProviderMode:
|
|
|
144
212
|
if os.environ.get(_ENV_SERVICE_URL):
|
|
145
213
|
return "internal_service"
|
|
146
214
|
|
|
147
|
-
if model
|
|
148
|
-
return "inprocess"
|
|
215
|
+
if _is_local_model(model):
|
|
216
|
+
return "local_service" if _local_service_supports_model(model) else "inprocess"
|
|
149
217
|
return "cloud"
|
|
150
218
|
|
|
151
219
|
|
|
@@ -186,11 +254,35 @@ def get_service_embeddings(
|
|
|
186
254
|
)
|
|
187
255
|
|
|
188
256
|
url = f"{embedding_service_url(mode)}/v1/embeddings"
|
|
257
|
+
timeout = embedding_service_timeout_seconds(mode)
|
|
258
|
+
|
|
259
|
+
# Bound each request so a single large publish cannot exceed the client read
|
|
260
|
+
# timeout: split into fixed-size chunks and concatenate the results in order.
|
|
261
|
+
chunk_size = _max_texts_per_request()
|
|
262
|
+
embeddings: list[list[float]] = []
|
|
263
|
+
for start in range(0, len(texts), chunk_size):
|
|
264
|
+
chunk = texts[start : start + chunk_size]
|
|
265
|
+
embeddings.extend(
|
|
266
|
+
_post_embedding_batch(
|
|
267
|
+
url, model=model, texts=chunk, dimensions=dimensions, timeout=timeout
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
return embeddings
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _post_embedding_batch(
|
|
274
|
+
url: str,
|
|
275
|
+
*,
|
|
276
|
+
model: str,
|
|
277
|
+
texts: list[str],
|
|
278
|
+
dimensions: int | None,
|
|
279
|
+
timeout: float,
|
|
280
|
+
) -> list[list[float]]:
|
|
281
|
+
"""POST one bounded batch of texts to the embedding service."""
|
|
189
282
|
payload: dict[str, Any] = {"model": model, "input": texts}
|
|
190
283
|
if dimensions:
|
|
191
284
|
payload["dimensions"] = dimensions
|
|
192
285
|
|
|
193
|
-
timeout = embedding_service_timeout_seconds(mode)
|
|
194
286
|
last_error: Exception | None = None
|
|
195
287
|
for attempt in range(2):
|
|
196
288
|
try:
|
|
@@ -199,12 +291,48 @@ def get_service_embeddings(
|
|
|
199
291
|
response.raise_for_status()
|
|
200
292
|
body = response.json()
|
|
201
293
|
return _ordered_embeddings_from_response(body.get("data"), len(texts))
|
|
202
|
-
except
|
|
294
|
+
except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
|
|
295
|
+
# Connection never established — the request never reached the
|
|
296
|
+
# server, so a single retry is safe and cannot duplicate work.
|
|
203
297
|
last_error = exc
|
|
204
298
|
if attempt == 0:
|
|
205
299
|
time.sleep(0.1)
|
|
300
|
+
except (httpx.HTTPError, json.JSONDecodeError, ValueError) as exc:
|
|
301
|
+
# The server already received the request: a read timeout means it
|
|
302
|
+
# is still encoding, and retrying would queue a second identical
|
|
303
|
+
# encode behind the first and amplify load on an already-saturated
|
|
304
|
+
# daemon. HTTPStatusError (4xx/5xx) and malformed-payload errors
|
|
305
|
+
# (JSONDecodeError / the ValueError raised by
|
|
306
|
+
# _ordered_embeddings_from_response) will not change on retry
|
|
307
|
+
# either. Fail fast. Anything outside this set (AttributeError,
|
|
308
|
+
# TypeError, ...) is a programming bug and propagates raw.
|
|
309
|
+
last_error = exc
|
|
310
|
+
break
|
|
206
311
|
|
|
207
312
|
_LOGGER.warning("Embedding service unavailable at %s: %s", url, last_error)
|
|
208
313
|
raise EmbeddingUnavailableError(
|
|
209
314
|
f"Embedding service unavailable at {url}: {last_error}"
|
|
210
315
|
) from last_error
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _max_texts_per_request() -> int:
|
|
319
|
+
raw = os.environ.get(_ENV_MAX_TEXTS_PER_REQUEST)
|
|
320
|
+
if raw is None:
|
|
321
|
+
return _DEFAULT_MAX_TEXTS_PER_REQUEST
|
|
322
|
+
try:
|
|
323
|
+
value = int(raw)
|
|
324
|
+
except ValueError:
|
|
325
|
+
_LOGGER.warning(
|
|
326
|
+
"%s must be an integer; using %d",
|
|
327
|
+
_ENV_MAX_TEXTS_PER_REQUEST,
|
|
328
|
+
_DEFAULT_MAX_TEXTS_PER_REQUEST,
|
|
329
|
+
)
|
|
330
|
+
return _DEFAULT_MAX_TEXTS_PER_REQUEST
|
|
331
|
+
if value < 1:
|
|
332
|
+
_LOGGER.warning(
|
|
333
|
+
"%s must be >= 1; using %d",
|
|
334
|
+
_ENV_MAX_TEXTS_PER_REQUEST,
|
|
335
|
+
_DEFAULT_MAX_TEXTS_PER_REQUEST,
|
|
336
|
+
)
|
|
337
|
+
return _DEFAULT_MAX_TEXTS_PER_REQUEST
|
|
338
|
+
return value
|
|
@@ -154,7 +154,7 @@ def register_if_chromadb_available() -> bool:
|
|
|
154
154
|
_LOGGER.debug("Local embedder not registered: `chromadb` is not installed.")
|
|
155
155
|
return False
|
|
156
156
|
_REGISTERED = True
|
|
157
|
-
_LOGGER.
|
|
157
|
+
_LOGGER.debug("Registered local MiniLM embedding handler (model=%s)", _MODEL_KEY)
|
|
158
158
|
return True
|
|
159
159
|
|
|
160
160
|
|
|
@@ -34,6 +34,8 @@ import os
|
|
|
34
34
|
import threading
|
|
35
35
|
from typing import Any
|
|
36
36
|
|
|
37
|
+
from reflexio.server.llm.llm_utils import positive_int_env
|
|
38
|
+
|
|
37
39
|
_LOGGER = logging.getLogger(__name__)
|
|
38
40
|
|
|
39
41
|
_ENV_ENABLE = "CLAUDE_SMART_USE_LOCAL_EMBEDDING"
|
|
@@ -54,11 +56,27 @@ _TARGET_DIM = 512
|
|
|
54
56
|
# defensively to avoid pathological multi-MB inputs.
|
|
55
57
|
_MAX_CHARS = 32_000
|
|
56
58
|
|
|
59
|
+
# Encode in small mini-batches so a single large request can't spike memory:
|
|
60
|
+
# peak activation memory scales with batch_size, not with the total number of
|
|
61
|
+
# texts in the request. A small batch is what makes the daemon's bounded
|
|
62
|
+
# concurrency (see ``embedding_service``) safe to raise above 1.
|
|
63
|
+
_DEFAULT_ENCODE_BATCH_SIZE = 4
|
|
64
|
+
_ENV_BATCH_SIZE = "REFLEXIO_EMBED_BATCH_SIZE"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _encode_batch_size() -> int:
|
|
68
|
+
"""Resolve the encode mini-batch size from env, defaulting to 4."""
|
|
69
|
+
return positive_int_env(_ENV_BATCH_SIZE, _DEFAULT_ENCODE_BATCH_SIZE, _LOGGER)
|
|
70
|
+
|
|
57
71
|
|
|
58
72
|
class NomicEmbedderError(RuntimeError):
|
|
59
73
|
"""Raised when the Nomic embedder is requested but its deps are missing."""
|
|
60
74
|
|
|
61
75
|
|
|
76
|
+
def _huggingface_cache_path() -> str:
|
|
77
|
+
return os.environ.get("HF_HOME") or "~/.cache/huggingface"
|
|
78
|
+
|
|
79
|
+
|
|
62
80
|
class NomicEmbedder:
|
|
63
81
|
"""Lazily-loaded singleton wrapping a sentence-transformers model.
|
|
64
82
|
|
|
@@ -102,8 +120,9 @@ class NomicEmbedder:
|
|
|
102
120
|
) from exc
|
|
103
121
|
_LOGGER.info(
|
|
104
122
|
"Loading Nomic embedding model %s — first call may download "
|
|
105
|
-
"~550 MB to
|
|
123
|
+
"~550 MB to %s",
|
|
106
124
|
_HF_MODEL_NAME,
|
|
125
|
+
_huggingface_cache_path(),
|
|
107
126
|
)
|
|
108
127
|
# Force CPU device — MPS init has been observed to hang on some
|
|
109
128
|
# Apple Silicon + macOS combos for several minutes during model
|
|
@@ -120,7 +139,7 @@ class NomicEmbedder:
|
|
|
120
139
|
"Nomic embedder ready (model=%s, target_dim=%d, native_dim=%d)",
|
|
121
140
|
_HF_MODEL_NAME,
|
|
122
141
|
_TARGET_DIM,
|
|
123
|
-
self._model
|
|
142
|
+
_embedding_dimension(self._model),
|
|
124
143
|
)
|
|
125
144
|
return self._model
|
|
126
145
|
|
|
@@ -141,7 +160,12 @@ class NomicEmbedder:
|
|
|
141
160
|
# show_progress_bar=False so server logs stay clean during ingest
|
|
142
161
|
# batches. convert_to_numpy=True returns a numpy ndarray; we slice
|
|
143
162
|
# and renormalise per-row before converting to plain Python lists.
|
|
144
|
-
raw = model.encode(
|
|
163
|
+
raw = model.encode(
|
|
164
|
+
safe,
|
|
165
|
+
batch_size=_encode_batch_size(),
|
|
166
|
+
show_progress_bar=False,
|
|
167
|
+
convert_to_numpy=True,
|
|
168
|
+
)
|
|
145
169
|
return [_truncate_and_renormalise(vec.tolist()) for vec in raw]
|
|
146
170
|
|
|
147
171
|
|
|
@@ -167,6 +191,15 @@ def _truncate_and_renormalise(vec: list[float]) -> list[float]:
|
|
|
167
191
|
return [x / norm for x in sliced]
|
|
168
192
|
|
|
169
193
|
|
|
194
|
+
def _embedding_dimension(model: Any) -> int:
|
|
195
|
+
get_embedding_dimension = getattr(model, "get_embedding_dimension", None)
|
|
196
|
+
if callable(get_embedding_dimension):
|
|
197
|
+
dimension = get_embedding_dimension()
|
|
198
|
+
else:
|
|
199
|
+
dimension = model.get_sentence_embedding_dimension()
|
|
200
|
+
return int(dimension) # type: ignore[arg-type]
|
|
201
|
+
|
|
202
|
+
|
|
170
203
|
_REGISTERED = False
|
|
171
204
|
|
|
172
205
|
|
|
@@ -21,6 +21,7 @@ this module never triggers a model download.
|
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
23
|
import logging
|
|
24
|
+
import os
|
|
24
25
|
import threading
|
|
25
26
|
from typing import Any
|
|
26
27
|
|
|
@@ -31,6 +32,13 @@ _LOGGER = logging.getLogger(__name__)
|
|
|
31
32
|
# well-known MS-MARCO benchmark performance.
|
|
32
33
|
_MODEL_NAME = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
|
33
34
|
|
|
35
|
+
# Device override for the cross-encoder. Defaults to CPU: without an
|
|
36
|
+
# explicit device sentence-transformers auto-selects MPS on Apple
|
|
37
|
+
# Silicon, where torch's caching allocator accumulates gigabytes of
|
|
38
|
+
# GPU buffers it never returns to the OS — and the 22M-param model
|
|
39
|
+
# gains nothing from GPU at K=30 pairs. Mirrors NOMIC_EMBED_DEVICE.
|
|
40
|
+
_DEVICE_ENV_VAR = "REFLEXIO_RERANK_DEVICE"
|
|
41
|
+
|
|
34
42
|
# Singleton state — never accessed directly outside ``_get_model``.
|
|
35
43
|
_MODEL: Any | None = None
|
|
36
44
|
_MODEL_LOCK = threading.Lock()
|
|
@@ -107,13 +115,15 @@ def _get_model() -> Any:
|
|
|
107
115
|
if _MODEL is not None:
|
|
108
116
|
return _MODEL
|
|
109
117
|
cross_encoder_cls = _import_cross_encoder()
|
|
118
|
+
device = os.environ.get(_DEVICE_ENV_VAR, "cpu")
|
|
110
119
|
try:
|
|
111
|
-
|
|
120
|
+
_LOGGER.info("Loading reranker model %s (device=%s)", _MODEL_NAME, device)
|
|
121
|
+
_MODEL = cross_encoder_cls(_MODEL_NAME, device=device)
|
|
112
122
|
except Exception as e: # noqa: BLE001 — surface as a typed failure
|
|
113
123
|
raise CrossEncoderUnavailableError(
|
|
114
124
|
f"Failed to load cross-encoder model {_MODEL_NAME!r}: {e}"
|
|
115
125
|
) from e
|
|
116
|
-
_LOGGER.info("
|
|
126
|
+
_LOGGER.info("Reranker model ready (model=%s)", _MODEL_NAME)
|
|
117
127
|
return _MODEL
|
|
118
128
|
|
|
119
129
|
|
|
@@ -140,7 +150,7 @@ def score_pairs(query: str, docs: list[str]) -> list[float]:
|
|
|
140
150
|
return []
|
|
141
151
|
model = _get_model()
|
|
142
152
|
pairs = [(query, doc) for doc in docs]
|
|
143
|
-
raw_scores = model.predict(pairs)
|
|
153
|
+
raw_scores = model.predict(pairs, show_progress_bar=False)
|
|
144
154
|
# ``predict`` returns a numpy array; convert to plain Python floats so
|
|
145
155
|
# the caller can serialise the result without numpy as a dependency.
|
|
146
156
|
return [float(s) for s in raw_scores]
|
|
@@ -578,6 +578,9 @@ def run_tool_loop(
|
|
|
578
578
|
)
|
|
579
579
|
|
|
580
580
|
# ---- Native tool loop ---------------------------------------------
|
|
581
|
+
# Local import keeps litellm_client a type-only dependency of this module.
|
|
582
|
+
from reflexio.server.llm.litellm_client import LiteLLMClientError
|
|
583
|
+
|
|
581
584
|
local_msgs = list(messages)
|
|
582
585
|
try:
|
|
583
586
|
for _step in range(max_steps):
|
|
@@ -677,6 +680,20 @@ def run_tool_loop(
|
|
|
677
680
|
pending_tool_call_ids=pending_tool_call_ids,
|
|
678
681
|
max_steps_remaining=max_steps - _step - 1,
|
|
679
682
|
)
|
|
683
|
+
except LiteLLMClientError as e:
|
|
684
|
+
# LLM failure after the client exhausted its retries and fallbacks —
|
|
685
|
+
# a known failure mode (timeouts, provider errors), not a bug. Log at
|
|
686
|
+
# warning so it doesn't surface as a Sentry error.
|
|
687
|
+
logger.warning("event=tool_loop_llm_error error=%s", e)
|
|
688
|
+
trace.finished = False
|
|
689
|
+
return ToolLoopResult(
|
|
690
|
+
ctx=ctx,
|
|
691
|
+
trace=trace,
|
|
692
|
+
finished_reason="error",
|
|
693
|
+
messages=local_msgs,
|
|
694
|
+
pending_tool_call_ids=pending_tool_call_ids,
|
|
695
|
+
max_steps_remaining=0,
|
|
696
|
+
)
|
|
680
697
|
except Exception:
|
|
681
698
|
logger.exception("Tool loop raised an unexpected exception")
|
|
682
699
|
trace.finished = False
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
active:
|
|
2
|
+
active: false
|
|
3
3
|
description: "Reconcile newly-extracted playbooks against existing storage. Decides per-candidate whether each one unifies with one-or-more existing rows (growing a coherent multi-rule skill, mixed do/avoid rules allowed), is rejected as redundant against an existing row, should differentiate (refine both triggers), or is independent."
|
|
4
4
|
changelog: "v2.3.0: `unify` now COMPOSES — it may grow a broader multi-rule skill from coherent related fragments (related sub-aspects of one task), not only same-trigger duplicates. A skill MAY hold mixed-polarity rules (do-rules and avoid-rules for different sub-aspects). Replaced the mechanical single-orientation/same-polarity contract with an LLM-judged no-self-contradiction guard: do NOT unify if combining the rules would make the skill contradict itself on the same situation (same trigger/condition with opposite advice) — route those to `differentiate` or `reject_new`. Re-synthesis (fewest/most-general rules, preserve avoid-detail) and the over-budget `differentiate` preference are unchanged. v2.2.0: `unify` re-synthesizes leaner, more general content (MDL / simplify-in-place) instead of concatenating — fewest, most general rules that still cover all inputs; drop redundant/subsumed wording. Asymmetric fidelity: compress/generalize success guidance, but preserve every distinct avoidance/failure detail verbatim — never soften a named pitfall into a vague platitude. Prefer `differentiate` over `unify` when merging would force an over-long, low-cohesion rule. v2.1.0: `unify` no longer emits a `polarity` field — the unified row's orientation is derived from its wording (recommendation vs avoidance) by the apply-path polarity validator. Same-trigger opposite-orientation rules still must not unify; express orientation through the rule wording. v2.0.0: collapsed 5-kind union → 4-kind. `unify` subsumes `duplicate`+`prefer_new`; `reject_new` replaces `prefer_existing`. Output schema is structurally incompatible with v1.x."
|
|
5
5
|
variables:
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
active: false
|
|
3
|
+
description: "Reconcile newly-extracted playbooks against existing storage. Decides per-candidate whether each one unifies with one-or-more existing rows (growing a coherent multi-rule skill, mixed do/avoid rules allowed), is rejected as redundant against an existing row, should differentiate (refine both triggers), or is independent."
|
|
4
|
+
changelog: "v2.3.1: replaces schema example-style output prompting with compact format guidance for the four decision shapes. v2.3.0: `unify` now COMPOSES — it may grow a broader multi-rule skill from coherent related fragments (related sub-aspects of one task), not only same-trigger duplicates. A skill MAY hold mixed-polarity rules (do-rules and avoid-rules for different sub-aspects). Replaced the mechanical single-orientation/same-polarity contract with an LLM-judged no-self-contradiction guard: do NOT unify if combining the rules would make the skill contradict itself on the same situation (same trigger/condition with opposite advice) — route those to `differentiate` or `reject_new`. Re-synthesis (fewest/most-general rules, preserve avoid-detail) and the over-budget `differentiate` preference are unchanged. v2.2.0: `unify` re-synthesizes leaner, more general content (MDL / simplify-in-place) instead of concatenating — fewest, most general rules that still cover all inputs; drop redundant/subsumed wording. Asymmetric fidelity: compress/generalize success guidance, but preserve every distinct avoidance/failure detail verbatim — never soften a named pitfall into a vague platitude. Prefer `differentiate` over `unify` when merging would force an over-long, low-cohesion rule. v2.1.0: `unify` no longer emits a `polarity` field — the unified row's orientation is derived from its wording (recommendation vs avoidance) by the apply-path polarity validator. Same-trigger opposite-orientation rules still must not unify; express orientation through the rule wording. v2.0.0: collapsed 5-kind union → 4-kind. `unify` subsumes `duplicate`+`prefer_new`; `reject_new` replaces `prefer_existing`. Output schema is structurally incompatible with v1.x."
|
|
5
|
+
variables:
|
|
6
|
+
- new_playbook_count
|
|
7
|
+
- new_playbooks
|
|
8
|
+
- existing_playbooks
|
|
9
|
+
---
|
|
10
|
+
You are reconciling a set of newly-extracted playbooks against the related existing playbook rows already in storage. For each new candidate, decide its relationship to the existing rows.
|
|
11
|
+
|
|
12
|
+
Each rendered row carries `Content`, `Trigger`, `Rationale`, `Name`, `Source`, and `Last Modified`. Read each rule's orientation (do-this vs avoid-this) directly from its `Content` / `Rationale` wording. A unified skill may hold both do-rules and avoid-rules for different sub-aspects, so do not treat differing orientation alone as a reason not to merge. Use `Trigger` together with the actual situation each rule addresses as the primary keys for comparison.
|
|
13
|
+
|
|
14
|
+
[New playbooks (count: {new_playbook_count})]
|
|
15
|
+
{new_playbooks}
|
|
16
|
+
|
|
17
|
+
[Existing related playbooks]
|
|
18
|
+
{existing_playbooks}
|
|
19
|
+
|
|
20
|
+
# Decision kinds
|
|
21
|
+
|
|
22
|
+
Emit exactly one decision per NEW candidate. Each decision is one of:
|
|
23
|
+
|
|
24
|
+
- **unify** — the NEW belongs in the same skill as one or more EXISTING rows. This covers two cases:
|
|
25
|
+
1. **Dedup / supersede** — the NEW is the same rule as an EXISTING row (after merge), or supersedes one (stronger / broader / more specific evidence).
|
|
26
|
+
2. **Compose** — the NEW covers a **related sub-aspect of the same task** as an EXISTING playbook (coherent, not a strict duplicate). Grow a **broader multi-rule skill** by incorporating the fragment as an additional rule, instead of forcing `reject_new`/`independent`.
|
|
27
|
+
|
|
28
|
+
Provide the final `content`, `trigger`, and `rationale`. List which EXISTING ids you're archiving in `archive_existing_ids` (use an empty list when no EXISTING rows are absorbed). A composed skill MAY hold **mixed-polarity rules** — do-rules and avoid-rules together — when they address **different** sub-aspects of the one task (e.g. "Do: announce in the deploy channel" alongside "Avoid: Friday-afternoon deploys"). Only unify fragments that are **genuinely coherent** (same task scope); unrelated fragments are `independent`.
|
|
29
|
+
|
|
30
|
+
- **reject_new** — an EXISTING row already covers NEW or makes NEW redundant. Name the EXISTING id that wins via `superseded_by_existing_id`. Storage-stability tie-break: when same-situation opposite advice is balanced, default here.
|
|
31
|
+
|
|
32
|
+
- **differentiate** — both valid in distinct contexts (typically same trigger, opposite advice, where the contexts differ). Set `refined_new_trigger` and `refined_existing_trigger` to be strictly narrower than the originals AND mutually exclusive.
|
|
33
|
+
|
|
34
|
+
- **independent** — different topic or task from any existing row. Insert NEW with no archive.
|
|
35
|
+
|
|
36
|
+
# How to write a unified skill (re-synthesis, not concatenation)
|
|
37
|
+
|
|
38
|
+
When you `unify`, re-synthesize a single leaner skill. Do not stitch the inputs together.
|
|
39
|
+
|
|
40
|
+
- Produce the **fewest, most general rules that still cover all the inputs**. Generalize the shared behavior into one clear statement; drop wording that is redundant with or subsumed by another input.
|
|
41
|
+
- A skill may carry several rules. When it does, keep each rule a clean, self-contained do-rule or avoid-rule for its own sub-aspect — do not blur distinct rules into one.
|
|
42
|
+
- **Preserve every distinct avoidance/failure detail with high fidelity.** This is asymmetric: compress and generalize the success guidance, but keep each specific failure intact. Never collapse a named pitfall, concrete error, or specific anti-pattern into a vague platitude — carry it forward in its specific form.
|
|
43
|
+
- Prefer **`differentiate` over `unify`** when merging the inputs would force an over-long, low-cohesion skill. Two focused rules that each read cleanly beat one bloated skill that tries to say everything. If you cannot state the merged skill concisely without losing distinct failure detail, that is a signal to `differentiate` (or keep NEW `independent`) rather than `unify`.
|
|
44
|
+
|
|
45
|
+
# The no-self-contradiction guard
|
|
46
|
+
|
|
47
|
+
A skill MAY hold mixed-polarity rules (do-rules and avoid-rules) for **different** sub-aspects. What it must NEVER hold is two rules that **contradict each other on the same situation** — the same trigger/condition paired with opposite advice (e.g. "use `-F`" and "avoid `-F`" for the very same case).
|
|
48
|
+
|
|
49
|
+
- Before you `unify`, ask: *would combining these rules produce a skill that contradicts itself on the same situation?* If yes, do **not** unify — route the pair to `differentiate` (refine the triggers so each rule owns a disjoint situation) or `reject_new` (one rule wins).
|
|
50
|
+
- Mixed polarity across **different** sub-aspects is fine and expected; mixed advice on the **same** sub-aspect is the forbidden case.
|
|
51
|
+
|
|
52
|
+
# Hard constraints
|
|
53
|
+
|
|
54
|
+
- A NEW + EXISTING pair that gives opposite advice on the **same** situation (same trigger) MUST route to `differentiate` or `reject_new` — never `unify` (that would make the skill self-contradict) and never `independent`.
|
|
55
|
+
- `differentiate.refined_new_trigger` and `refined_existing_trigger` MUST be non-empty and strictly narrower than the originals.
|
|
56
|
+
- When in doubt about a same-situation opposite-advice stalemate, default to `reject_new`. Storage stability wins ties.
|
|
57
|
+
|
|
58
|
+
# Output Format Guidance
|
|
59
|
+
|
|
60
|
+
Respond ONLY with a valid JSON object matching `PlaybookConsolidationOutput`: `{{"decisions": [<one decision object for each NEW candidate>]}}`.
|
|
61
|
+
|
|
62
|
+
Each decision object MUST include `kind` and `new_id`. The `kind` value determines the remaining required fields:
|
|
63
|
+
|
|
64
|
+
- `unify`: include `archive_existing_ids` as a list of EXISTING list positions, plus final `content`, `trigger`, and `rationale`; optional `reason` may explain the decision.
|
|
65
|
+
- `reject_new`: include `superseded_by_existing_id` as the existing row's database id; optional `reason` may explain the decision.
|
|
66
|
+
- `differentiate`: include `existing_id` as the existing row's database id, plus non-empty `refined_new_trigger` and `refined_existing_trigger`; optional `reason` may explain the decision.
|
|
67
|
+
- `independent`: include only `kind`, `new_id`, and optional `reason`.
|
|
68
|
+
|
|
69
|
+
Do not emit markdown, prose, comments, chain-of-thought, or top-level keys other than `decisions`. Do not include fields from another decision kind.
|