nexo-brain 7.27.2 → 7.27.6
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/plugin.json +1 -1
- package/README.md +4 -2
- package/bin/windows-wsl-bridge.js +9 -0
- package/package.json +1 -1
- package/src/agent_runner.py +0 -13
- package/src/call_model_raw.py +17 -4
- package/src/classifier_local.py +44 -0
- package/src/client_sync.py +4 -6
- package/src/db/__init__.py +8 -0
- package/src/db/_commitments.py +344 -0
- package/src/db/_memory_v2.py +52 -2
- package/src/db/_schema.py +37 -0
- package/src/desktop_bridge.py +1 -1
- package/src/doctor/providers/runtime.py +14 -3
- package/src/enforcement_engine.py +128 -2
- package/src/hook_guardrails.py +104 -0
- package/src/local_context/api.py +54 -22
- package/src/plugins/protocol.py +96 -0
- package/src/pre_answer_router.py +298 -6
- package/src/r14_correction_learning.py +3 -3
- package/src/requirements.txt +5 -1
- package/src/runtime_versioning.py +11 -1
- package/src/saved_not_used_audit.py +44 -3
- package/src/scripts/nexo-followup-runner.py +194 -0
- package/src/semantic_reasoner.py +2 -2
- package/src/semantic_router.py +58 -11
- package/src/server.py +37 -1
|
@@ -33,6 +33,8 @@ import re
|
|
|
33
33
|
import sqlite3
|
|
34
34
|
import subprocess
|
|
35
35
|
import sys
|
|
36
|
+
from difflib import SequenceMatcher
|
|
37
|
+
from email.utils import parsedate_to_datetime
|
|
36
38
|
from datetime import datetime, date, timedelta
|
|
37
39
|
from pathlib import Path
|
|
38
40
|
|
|
@@ -79,6 +81,8 @@ MAX_STALE_TRIAGE_PER_RUN = 8
|
|
|
79
81
|
MAX_NEEDS_OPERATOR_BRIEFING = 12
|
|
80
82
|
DEFAULT_ASSISTANT_NAME = "Nova"
|
|
81
83
|
DEFAULT_OPERATOR_LANGUAGE = "en"
|
|
84
|
+
DEFAULT_STEADY_STATE_IMAP_HOURS = 12
|
|
85
|
+
DEFAULT_DUPLICATE_NOTE_THRESHOLD = 0.9
|
|
82
86
|
|
|
83
87
|
# ── Logging ─────────────────────────────────────────────────────────────
|
|
84
88
|
def log(msg: str):
|
|
@@ -103,6 +107,159 @@ def save_state(state: dict):
|
|
|
103
107
|
STATE_FILE.write_text(json.dumps(state, indent=2))
|
|
104
108
|
|
|
105
109
|
|
|
110
|
+
def load_runner_config() -> dict:
|
|
111
|
+
config = {
|
|
112
|
+
"steady_state_imap_hours": DEFAULT_STEADY_STATE_IMAP_HOURS,
|
|
113
|
+
"duplicate_note_threshold": DEFAULT_DUPLICATE_NOTE_THRESHOLD,
|
|
114
|
+
}
|
|
115
|
+
config_path = NEXO_HOME / "personal" / "config" / "followup-runner.json"
|
|
116
|
+
if config_path.exists():
|
|
117
|
+
try:
|
|
118
|
+
raw = json.loads(config_path.read_text())
|
|
119
|
+
if isinstance(raw, dict):
|
|
120
|
+
config.update(raw)
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
log(f"Failed to read followup-runner config ({exc})")
|
|
123
|
+
try:
|
|
124
|
+
config["steady_state_imap_hours"] = float(
|
|
125
|
+
os.environ.get("NEXO_FOLLOWUP_STEADY_STATE_IMAP_HOURS", config["steady_state_imap_hours"])
|
|
126
|
+
)
|
|
127
|
+
except (TypeError, ValueError):
|
|
128
|
+
config["steady_state_imap_hours"] = DEFAULT_STEADY_STATE_IMAP_HOURS
|
|
129
|
+
try:
|
|
130
|
+
config["duplicate_note_threshold"] = float(
|
|
131
|
+
os.environ.get("NEXO_FOLLOWUP_DUPLICATE_NOTE_THRESHOLD", config["duplicate_note_threshold"])
|
|
132
|
+
)
|
|
133
|
+
except (TypeError, ValueError):
|
|
134
|
+
config["duplicate_note_threshold"] = DEFAULT_DUPLICATE_NOTE_THRESHOLD
|
|
135
|
+
return config
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _normalise_note_text(value: str) -> str:
|
|
139
|
+
text = re.sub(r"\s+", " ", str(value or "").strip().lower())
|
|
140
|
+
text = re.sub(r"\b\d{4}-\d{2}-\d{2}(?:[t ][0-9:.+-z]+)?\b", "<date>", text)
|
|
141
|
+
text = re.sub(r"\b[0-9]{6,}(?:\.[0-9]+)?\b", "<num>", text)
|
|
142
|
+
return text
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _note_similarity(left: str, right: str) -> float:
|
|
146
|
+
left_norm = _normalise_note_text(left)
|
|
147
|
+
right_norm = _normalise_note_text(right)
|
|
148
|
+
if not left_norm or not right_norm:
|
|
149
|
+
return 0.0
|
|
150
|
+
return SequenceMatcher(None, left_norm, right_norm).ratio()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _latest_email_gap_hours() -> float | None:
|
|
154
|
+
email_db = NEXO_HOME / "runtime" / "nexo-email" / "nexo-email.db"
|
|
155
|
+
if not email_db.exists():
|
|
156
|
+
return None
|
|
157
|
+
try:
|
|
158
|
+
conn = sqlite3.connect(str(email_db))
|
|
159
|
+
row = conn.execute(
|
|
160
|
+
"SELECT received_at FROM emails WHERE received_at IS NOT NULL "
|
|
161
|
+
"ORDER BY received_at DESC LIMIT 1"
|
|
162
|
+
).fetchone()
|
|
163
|
+
conn.close()
|
|
164
|
+
except Exception:
|
|
165
|
+
return None
|
|
166
|
+
if not row or not row[0]:
|
|
167
|
+
return None
|
|
168
|
+
raw = str(row[0]).strip().replace("Z", "+00:00")
|
|
169
|
+
try:
|
|
170
|
+
received = datetime.fromisoformat(raw)
|
|
171
|
+
except ValueError:
|
|
172
|
+
try:
|
|
173
|
+
received = parsedate_to_datetime(raw)
|
|
174
|
+
except (TypeError, ValueError):
|
|
175
|
+
return None
|
|
176
|
+
if received.tzinfo is not None:
|
|
177
|
+
received = received.astimezone().replace(tzinfo=None)
|
|
178
|
+
return max(0.0, (datetime.now() - received).total_seconds() / 3600)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _db_change_marker() -> dict:
|
|
182
|
+
if not NEXO_DB.exists():
|
|
183
|
+
return {}
|
|
184
|
+
try:
|
|
185
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
186
|
+
row = conn.execute(
|
|
187
|
+
"SELECT COUNT(*), COALESCE(MAX(updated_at), 0) FROM followups "
|
|
188
|
+
"WHERE UPPER(status) NOT LIKE 'COMPLETED%' AND UPPER(status) != 'DELETED'"
|
|
189
|
+
).fetchone()
|
|
190
|
+
history = conn.execute(
|
|
191
|
+
"SELECT COALESCE(MAX(created_at), 0) FROM item_history "
|
|
192
|
+
"WHERE actor IS NULL OR actor != 'followup-runner'"
|
|
193
|
+
).fetchone()
|
|
194
|
+
conn.close()
|
|
195
|
+
except Exception:
|
|
196
|
+
return {}
|
|
197
|
+
return {
|
|
198
|
+
"active_followups": int(row[0] or 0) if row else 0,
|
|
199
|
+
"max_followup_updated_at": float(row[1] or 0) if row else 0,
|
|
200
|
+
"max_non_runner_history_at": float(history[0] or 0) if history else 0,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _recent_runner_notes(fu_id: str, *, limit: int = 4) -> list[str]:
|
|
205
|
+
try:
|
|
206
|
+
conn = sqlite3.connect(str(NEXO_DB))
|
|
207
|
+
rows = conn.execute(
|
|
208
|
+
"SELECT note FROM item_history "
|
|
209
|
+
"WHERE item_type='followup' AND item_id=? AND actor='followup-runner' "
|
|
210
|
+
"ORDER BY created_at DESC LIMIT ?",
|
|
211
|
+
(fu_id, limit),
|
|
212
|
+
).fetchall()
|
|
213
|
+
conn.close()
|
|
214
|
+
except Exception:
|
|
215
|
+
return []
|
|
216
|
+
return [str(row[0] or "") for row in rows if str(row[0] or "").strip()]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _is_duplicate_steady_state(followup: dict, *, threshold: float) -> bool:
|
|
220
|
+
notes = _recent_runner_notes(str(followup.get("id") or ""))
|
|
221
|
+
if not notes:
|
|
222
|
+
return False
|
|
223
|
+
current = "\n".join(
|
|
224
|
+
part
|
|
225
|
+
for part in (
|
|
226
|
+
str(followup.get("description") or "").strip(),
|
|
227
|
+
str(followup.get("verification") or "").strip(),
|
|
228
|
+
str(followup.get("reasoning") or "").strip(),
|
|
229
|
+
)
|
|
230
|
+
if part
|
|
231
|
+
)
|
|
232
|
+
if current and max(_note_similarity(current, note) for note in notes) >= threshold:
|
|
233
|
+
return True
|
|
234
|
+
if len(notes) >= 2 and _note_similarity(notes[0], notes[1]) >= threshold:
|
|
235
|
+
return True
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def split_steady_state_skips(actionable: list[dict], state: dict, config: dict) -> tuple[list[dict], list[dict]]:
|
|
240
|
+
"""Skip repeated no-op followups when email and DB state have not moved."""
|
|
241
|
+
marker = _db_change_marker()
|
|
242
|
+
previous_marker = (state.get("_meta") or {}).get("last_db_change_marker")
|
|
243
|
+
email_gap = _latest_email_gap_hours()
|
|
244
|
+
threshold = float(config.get("duplicate_note_threshold") or DEFAULT_DUPLICATE_NOTE_THRESHOLD)
|
|
245
|
+
imap_hours = float(config.get("steady_state_imap_hours") or DEFAULT_STEADY_STATE_IMAP_HOURS)
|
|
246
|
+
steady_state = bool(marker and previous_marker == marker and email_gap is not None and email_gap < imap_hours)
|
|
247
|
+
if not steady_state:
|
|
248
|
+
return actionable, []
|
|
249
|
+
|
|
250
|
+
kept = []
|
|
251
|
+
skipped = []
|
|
252
|
+
for followup in actionable:
|
|
253
|
+
if _is_duplicate_steady_state(followup, threshold=threshold):
|
|
254
|
+
skipped.append(followup)
|
|
255
|
+
else:
|
|
256
|
+
kept.append(followup)
|
|
257
|
+
if skipped:
|
|
258
|
+
ids = ", ".join(str(item.get("id") or "") for item in skipped)
|
|
259
|
+
log(f"Steady-state auto-skip: {ids} (IMAP gap {email_gap:.1f}h, DB marker unchanged)")
|
|
260
|
+
return kept, skipped
|
|
261
|
+
|
|
262
|
+
|
|
106
263
|
# ── DB access ───────────────────────────────────────────────────────────
|
|
107
264
|
def _parse_date(value: str) -> date | None:
|
|
108
265
|
try:
|
|
@@ -670,6 +827,8 @@ def release_lock():
|
|
|
670
827
|
def get_recent_activity(hours: int = 24) -> str:
|
|
671
828
|
"""Build a summary of what the runner did in the last N hours."""
|
|
672
829
|
lines = []
|
|
830
|
+
config = load_runner_config()
|
|
831
|
+
duplicate_threshold = float(config.get("duplicate_note_threshold") or DEFAULT_DUPLICATE_NOTE_THRESHOLD)
|
|
673
832
|
try:
|
|
674
833
|
conn = sqlite3.connect(str(NEXO_DB))
|
|
675
834
|
conn.row_factory = sqlite3.Row
|
|
@@ -698,13 +857,25 @@ def get_recent_activity(hours: int = 24) -> str:
|
|
|
698
857
|
if notes:
|
|
699
858
|
lines.append("\nFOLLOWUP NOTES WRITTEN (last 24h):")
|
|
700
859
|
seen = set()
|
|
860
|
+
compressed = 0
|
|
701
861
|
for n in notes:
|
|
702
862
|
fid = str(n["followup_id"] or "")
|
|
703
863
|
if fid in seen:
|
|
704
864
|
continue
|
|
705
865
|
seen.add(fid)
|
|
706
866
|
note_text = str(n["note"] or "")[:150]
|
|
867
|
+
previous = conn.execute(
|
|
868
|
+
"SELECT note FROM item_history "
|
|
869
|
+
"WHERE item_type='followup' AND item_id=? AND actor='followup-runner' "
|
|
870
|
+
"AND created_at < ? ORDER BY created_at DESC LIMIT 1",
|
|
871
|
+
(fid, n["created_at"]),
|
|
872
|
+
).fetchone()
|
|
873
|
+
if previous and _note_similarity(str(previous["note"] or ""), note_text) >= duplicate_threshold:
|
|
874
|
+
compressed += 1
|
|
875
|
+
note_text = f"{note_text} [nota similar compactada]"
|
|
707
876
|
lines.append(f" {fid}: {note_text}")
|
|
877
|
+
if compressed:
|
|
878
|
+
lines.append(f" ({compressed} notas duplicadas compactadas; umbral={duplicate_threshold:.2f})")
|
|
708
879
|
|
|
709
880
|
conn.close()
|
|
710
881
|
except Exception as e:
|
|
@@ -799,8 +970,10 @@ def main():
|
|
|
799
970
|
return
|
|
800
971
|
|
|
801
972
|
state = load_state()
|
|
973
|
+
runner_config = load_runner_config()
|
|
802
974
|
groups = get_all_active_followups(state)
|
|
803
975
|
all_actionable = list(groups["actionable"])
|
|
976
|
+
all_actionable, steady_skipped = split_steady_state_skips(all_actionable, state, runner_config)
|
|
804
977
|
cooled = groups.get("cooled_down", [])
|
|
805
978
|
stale_triage = groups.get("stale_triage", [])
|
|
806
979
|
|
|
@@ -835,6 +1008,23 @@ def main():
|
|
|
835
1008
|
record_attempt(state, fid, "stale_review")
|
|
836
1009
|
|
|
837
1010
|
results = []
|
|
1011
|
+
for fu in steady_skipped:
|
|
1012
|
+
fid = str(fu.get("id") or "")
|
|
1013
|
+
if not fid:
|
|
1014
|
+
continue
|
|
1015
|
+
record_attempt(state, fid, "steady_state")
|
|
1016
|
+
results.append(
|
|
1017
|
+
{
|
|
1018
|
+
"id": fid,
|
|
1019
|
+
"status": "checked",
|
|
1020
|
+
"summary": (
|
|
1021
|
+
"Auto-skip steady-state: marcador DB sin cambios desde el ciclo anterior, "
|
|
1022
|
+
f"último IMAP <{runner_config.get('steady_state_imap_hours')}h y nota previa similar."
|
|
1023
|
+
),
|
|
1024
|
+
"needs_attention": False,
|
|
1025
|
+
"options": None,
|
|
1026
|
+
}
|
|
1027
|
+
)
|
|
838
1028
|
|
|
839
1029
|
if all_actionable:
|
|
840
1030
|
# Clean previous results
|
|
@@ -940,10 +1130,14 @@ def main():
|
|
|
940
1130
|
else:
|
|
941
1131
|
log("No followups at all. Runner direct email path removed.")
|
|
942
1132
|
|
|
1133
|
+
if steady_skipped:
|
|
1134
|
+
RESULTS_FILE.write_text(json.dumps({"results": results}, indent=2, ensure_ascii=False))
|
|
1135
|
+
|
|
943
1136
|
# Save state with attempts + last run
|
|
944
1137
|
if "_meta" not in state:
|
|
945
1138
|
state["_meta"] = {}
|
|
946
1139
|
state["_meta"]["last_run"] = datetime.now().isoformat()
|
|
1140
|
+
state["_meta"]["last_db_change_marker"] = _db_change_marker()
|
|
947
1141
|
save_state(state)
|
|
948
1142
|
|
|
949
1143
|
log("Done.")
|
package/src/semantic_reasoner.py
CHANGED
|
@@ -83,12 +83,12 @@ def _collect_local_votes(
|
|
|
83
83
|
vote aggregator can still detect quorum problems.
|
|
84
84
|
"""
|
|
85
85
|
try:
|
|
86
|
-
from classifier_local import
|
|
86
|
+
from classifier_local import get_shared_zero_shot_classifier
|
|
87
87
|
except Exception as exc: # pragma: no cover
|
|
88
88
|
_logger.debug("semantic_reasoner: classifier_local unavailable (%s)", exc)
|
|
89
89
|
return []
|
|
90
90
|
|
|
91
|
-
clf =
|
|
91
|
+
clf = get_shared_zero_shot_classifier(confidence_floor=0.0)
|
|
92
92
|
votes: list[tuple[str, float, dict[str, float]]] = []
|
|
93
93
|
for template in _PROMPT_PERTURBATIONS:
|
|
94
94
|
prompt = template.format(q=question)
|
package/src/semantic_router.py
CHANGED
|
@@ -30,6 +30,7 @@ the decision carries. This is also what Desktop will consume via the
|
|
|
30
30
|
from __future__ import annotations
|
|
31
31
|
|
|
32
32
|
import logging
|
|
33
|
+
import os
|
|
33
34
|
from dataclasses import dataclass, field
|
|
34
35
|
from typing import Any
|
|
35
36
|
|
|
@@ -79,7 +80,7 @@ class RouterResult:
|
|
|
79
80
|
# Decision kinds + policy table
|
|
80
81
|
# ---------------------------------------------------------------------------
|
|
81
82
|
#
|
|
82
|
-
# The plan enumerates
|
|
83
|
+
# The plan enumerates the decision_kinds that need to route through here. They
|
|
83
84
|
# fall into two families:
|
|
84
85
|
#
|
|
85
86
|
# TEXTUAL — the first-line local classifier is good enough; the
|
|
@@ -111,6 +112,7 @@ TEXTUAL_KINDS: tuple[str, ...] = (
|
|
|
111
112
|
"reply_event_type",
|
|
112
113
|
"query_intent",
|
|
113
114
|
"sentiment_intent",
|
|
115
|
+
"pre_answer_intent",
|
|
114
116
|
)
|
|
115
117
|
|
|
116
118
|
|
|
@@ -136,10 +138,21 @@ _POLICY: dict[str, dict[str, Any]] = {
|
|
|
136
138
|
"reasoner_mode": "multipass_local",
|
|
137
139
|
"reasoner_threshold": 0.75,
|
|
138
140
|
"allow_remote_fallback": True,
|
|
141
|
+
"allow_cold_local_load": True,
|
|
139
142
|
}
|
|
140
143
|
for kind in TEXTUAL_KINDS
|
|
141
144
|
}
|
|
142
145
|
|
|
146
|
+
_POLICY["pre_answer_intent"].update(
|
|
147
|
+
{
|
|
148
|
+
# Pre-answer sits on the user-visible latency path. Use the local
|
|
149
|
+
# semantic classifier when it is already warm, but do not cold-load a
|
|
150
|
+
# heavy model before answering unless explicitly enabled.
|
|
151
|
+
"allow_remote_fallback": True,
|
|
152
|
+
"allow_cold_local_load": False,
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
|
|
143
156
|
_POLICY.update(
|
|
144
157
|
{
|
|
145
158
|
kind: {
|
|
@@ -148,6 +161,7 @@ _POLICY.update(
|
|
|
148
161
|
"reasoner_mode": "cached_llm",
|
|
149
162
|
"reasoner_threshold": 0.60,
|
|
150
163
|
"allow_remote_fallback": True,
|
|
164
|
+
"allow_cold_local_load": True,
|
|
151
165
|
}
|
|
152
166
|
for kind in CODE_AWARE_KINDS
|
|
153
167
|
}
|
|
@@ -176,6 +190,7 @@ def _run_fast_local(
|
|
|
176
190
|
context: str = "",
|
|
177
191
|
labels: tuple[str, ...],
|
|
178
192
|
confidence_floor: float,
|
|
193
|
+
allow_cold_load: bool = True,
|
|
179
194
|
) -> RouterResult | None:
|
|
180
195
|
"""Try ``LocalZeroShotClassifier``. Return None on unavailable or
|
|
181
196
|
below-threshold so the router advances.
|
|
@@ -187,12 +202,14 @@ def _run_fast_local(
|
|
|
187
202
|
when present, and fall back to question for simple direct callers.
|
|
188
203
|
"""
|
|
189
204
|
try:
|
|
190
|
-
from classifier_local import
|
|
205
|
+
from classifier_local import get_shared_zero_shot_classifier, is_local_classifier_warm
|
|
191
206
|
except Exception as exc: # pragma: no cover — install not ready
|
|
192
207
|
_logger.debug("semantic_router: classifier_local unavailable (%s)", exc)
|
|
193
208
|
return None
|
|
194
209
|
|
|
195
|
-
|
|
210
|
+
if not allow_cold_load and not is_local_classifier_warm():
|
|
211
|
+
return None
|
|
212
|
+
clf = get_shared_zero_shot_classifier(confidence_floor=confidence_floor)
|
|
196
213
|
classifier_input = (context or "").strip() or question
|
|
197
214
|
result = clf.classify(classifier_input, labels)
|
|
198
215
|
if result is None:
|
|
@@ -375,6 +392,28 @@ def _normalize_remote_answer(
|
|
|
375
392
|
# ---------------------------------------------------------------------------
|
|
376
393
|
|
|
377
394
|
|
|
395
|
+
def _env_bool(name: str, *, default: bool) -> bool:
|
|
396
|
+
raw = os.environ.get(name)
|
|
397
|
+
if raw is None:
|
|
398
|
+
return default
|
|
399
|
+
return str(raw or "").strip().lower() in {"1", "true", "yes", "on", "enabled"}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _allow_cold_local_load(decision_kind: str, policy: dict[str, Any]) -> bool:
|
|
403
|
+
default = bool(policy.get("allow_cold_local_load", True))
|
|
404
|
+
if decision_kind == "pre_answer_intent":
|
|
405
|
+
return _env_bool("NEXO_PRE_ANSWER_ALLOW_COLD_SEMANTIC_LOAD", default=default)
|
|
406
|
+
return default
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _local_classifier_warm() -> bool:
|
|
410
|
+
try:
|
|
411
|
+
from classifier_local import is_local_classifier_warm
|
|
412
|
+
except Exception:
|
|
413
|
+
return False
|
|
414
|
+
return is_local_classifier_warm()
|
|
415
|
+
|
|
416
|
+
|
|
378
417
|
def route(
|
|
379
418
|
*,
|
|
380
419
|
decision_kind: str,
|
|
@@ -407,6 +446,7 @@ def route(
|
|
|
407
446
|
labels_tuple: tuple[str, ...] | None = (
|
|
408
447
|
tuple(labels) if labels else None
|
|
409
448
|
)
|
|
449
|
+
allow_cold_load = _allow_cold_local_load(decision_kind, policy)
|
|
410
450
|
|
|
411
451
|
# Step 1 — fast_local for textual families only.
|
|
412
452
|
if policy["fast_local_threshold"] is not None and labels_tuple:
|
|
@@ -415,20 +455,27 @@ def route(
|
|
|
415
455
|
context=context,
|
|
416
456
|
labels=labels_tuple,
|
|
417
457
|
confidence_floor=float(policy["fast_local_threshold"]),
|
|
458
|
+
allow_cold_load=allow_cold_load,
|
|
418
459
|
)
|
|
419
460
|
if fast is not None:
|
|
420
461
|
fast.decision_kind = decision_kind
|
|
421
462
|
return fast
|
|
422
463
|
|
|
423
464
|
# Step 2 — semantic_reasoner (Mode A or B depending on policy).
|
|
424
|
-
reasoned =
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
465
|
+
reasoned = None
|
|
466
|
+
if not (
|
|
467
|
+
str(policy["reasoner_mode"]) == "multipass_local"
|
|
468
|
+
and not allow_cold_load
|
|
469
|
+
and not _local_classifier_warm()
|
|
470
|
+
):
|
|
471
|
+
reasoned = _run_semantic_reasoner(
|
|
472
|
+
decision_kind=decision_kind,
|
|
473
|
+
question=question,
|
|
474
|
+
labels=labels_tuple,
|
|
475
|
+
context=context,
|
|
476
|
+
mode=str(policy["reasoner_mode"]),
|
|
477
|
+
confidence_floor=float(policy["reasoner_threshold"]),
|
|
478
|
+
)
|
|
432
479
|
if reasoned is not None and reasoned.ok:
|
|
433
480
|
return reasoned
|
|
434
481
|
|
package/src/server.py
CHANGED
|
@@ -92,7 +92,7 @@ from tools_automation_sessions import (
|
|
|
92
92
|
handle_session_log_create,
|
|
93
93
|
handle_session_log_close,
|
|
94
94
|
)
|
|
95
|
-
from plugins.cortex import handle_cortex_check
|
|
95
|
+
from plugins.cortex import handle_cortex_check, handle_cortex_decide
|
|
96
96
|
from plugins.guard import handle_guard_check
|
|
97
97
|
from plugins.protocol import (
|
|
98
98
|
handle_confidence_check,
|
|
@@ -632,6 +632,42 @@ def nexo_cortex_check(
|
|
|
632
632
|
)
|
|
633
633
|
|
|
634
634
|
|
|
635
|
+
@mcp.tool
|
|
636
|
+
def nexo_cortex_decide(
|
|
637
|
+
goal: str,
|
|
638
|
+
alternatives: str,
|
|
639
|
+
task_type: str = "execute",
|
|
640
|
+
impact_level: str = "high",
|
|
641
|
+
context_hint: str = "",
|
|
642
|
+
area: str = "",
|
|
643
|
+
constraints: str = "[]",
|
|
644
|
+
evidence_refs: str = "[]",
|
|
645
|
+
session_id: str = "",
|
|
646
|
+
task_id: str = "",
|
|
647
|
+
linked_outcome_id: int = 0,
|
|
648
|
+
goal_profile_id: str = "",
|
|
649
|
+
goal_id: str = "",
|
|
650
|
+
auto_create_outcome: bool = False,
|
|
651
|
+
) -> str:
|
|
652
|
+
"""Evaluate concrete alternatives for a high-impact task and persist the recommendation."""
|
|
653
|
+
return handle_cortex_decide(
|
|
654
|
+
goal,
|
|
655
|
+
alternatives,
|
|
656
|
+
task_type,
|
|
657
|
+
impact_level,
|
|
658
|
+
context_hint,
|
|
659
|
+
area,
|
|
660
|
+
constraints,
|
|
661
|
+
evidence_refs,
|
|
662
|
+
session_id,
|
|
663
|
+
task_id,
|
|
664
|
+
linked_outcome_id,
|
|
665
|
+
goal_profile_id,
|
|
666
|
+
goal_id,
|
|
667
|
+
auto_create_outcome,
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
|
|
635
671
|
@mcp.tool
|
|
636
672
|
def nexo_guard_check(files: str = "", area: str = "") -> str:
|
|
637
673
|
"""Check learnings relevant to files/area before reading or editing code."""
|