180 lines
9.0 KiB
Python
180 lines
9.0 KiB
Python
"""Explicit source-linked tools; no automatic retrieval or global-store fallback."""
|
|
import hashlib
|
|
import json
|
|
import re
|
|
|
|
from agent.redact import redact_sensitive_text
|
|
from hermes_constants import get_hermes_home
|
|
from hermes_state import SessionDB
|
|
from tools.session_search_project import ProjectRecall
|
|
|
|
from .project_store import ProjectMemoryStore
|
|
from .retrieval import FactRetriever
|
|
|
|
|
|
class ProjectDenied(ValueError):
|
|
def __init__(self, status):
|
|
self.status = status
|
|
super().__init__(status)
|
|
|
|
|
|
def _checked_sources(recall, sources):
|
|
if not isinstance(sources, list) or not sources:
|
|
raise ProjectDenied("sources_required")
|
|
checked = {}
|
|
for source in sources:
|
|
if not isinstance(source, dict):
|
|
raise ProjectDenied("invalid_source")
|
|
sid, mid = source.get("session_id"), source.get("message_id")
|
|
supplied_hash = source.get("content_hash")
|
|
if (not isinstance(sid, str) or not sid or type(mid) is not int or mid <= 0
|
|
or (supplied_hash is not None and
|
|
(not isinstance(supplied_hash, str) or not re.fullmatch(r"[0-9a-f]{64}", supplied_hash)))):
|
|
raise ProjectDenied("invalid_source")
|
|
row = next((r for r in recall.messages(sid, mid) if r["id"] == mid), None)
|
|
if row is None or (supplied_hash is not None and row["content_hash"] != supplied_hash):
|
|
raise ProjectDenied("source_unavailable")
|
|
checked[sid, mid] = {"session_id": sid, "id": mid, "message_id": mid,
|
|
"content_hash": row["content_hash"]}
|
|
return list(checked.values())
|
|
|
|
|
|
def _safe_candidate(recall, store, fact):
|
|
sources = store.sources(fact["fact_id"])
|
|
try:
|
|
sources = _checked_sources(recall, sources)
|
|
except ProjectDenied:
|
|
return None
|
|
return {**fact, "fact_ref": store.fact_ref(store.db_path.stem, fact["fact_id"]),
|
|
"status": "candidate", "assertion": "candidate_not_confirmed", "sources": sources}
|
|
|
|
|
|
def _principal(db, session_id):
|
|
with db._read_ctx() as conn:
|
|
row = conn.execute("SELECT user_id FROM sessions WHERE id=?", (session_id,)).fetchone()
|
|
if row is None:
|
|
raise ProjectDenied("scope_unresolved")
|
|
# Keep NULL distinct from both the empty string and any named principal.
|
|
return ["absent"] if row[0] is None else ["user_id", row[0]]
|
|
|
|
|
|
def _select_store(provider, scope, principal):
|
|
home = provider._project_home
|
|
key = hashlib.sha256(json.dumps([str(home), scope["project_key"], principal],
|
|
separators=(",", ":")).encode()).hexdigest()
|
|
path = home / "holographic" / "projects" / (key + ".db")
|
|
if provider._store is not None and provider._store.db_path != path:
|
|
provider._store.close()
|
|
provider._store = provider._retriever = None
|
|
if provider._store is None:
|
|
dim = int(provider._config.get("hrr_dim", 1024))
|
|
provider._store = ProjectMemoryStore(path, hrr_dim=dim,
|
|
default_trust=float(provider._config.get("default_trust", 0.5)))
|
|
provider._retriever = FactRetriever(provider._store, hrr_dim=dim,
|
|
hrr_weight=float(provider._config.get("hrr_weight", 0.3)),
|
|
temporal_decay_half_life=int(provider._config.get("temporal_decay_half_life", 0)))
|
|
|
|
|
|
def _operate(provider, recall, tool_name, args):
|
|
store = provider._store
|
|
action = args["action"]
|
|
if tool_name == "fact_feedback" and action not in ("helpful", "unhelpful"):
|
|
raise ProjectDenied("invalid_request")
|
|
if tool_name == "fact_store" and action == "add":
|
|
sources = _checked_sources(recall, args.get("sources"))
|
|
fact_id = store.add_fact(args["content"], category=args.get("category", "general"), tags=args.get("tags", ""))
|
|
store.set_sources(fact_id, sources)
|
|
return {"fact_id": fact_id, "fact_ref": store.fact_ref(store.db_path.stem, fact_id),
|
|
"status": "candidate", "sources": sources}
|
|
if tool_name == "fact_feedback" or action in ("update", "remove"):
|
|
if "fact_ref" not in args:
|
|
raise ProjectDenied("fact_ref_required")
|
|
try:
|
|
fact_id = store.resolve_fact_ref(store.db_path.stem, args["fact_ref"])
|
|
except (ValueError, TypeError):
|
|
raise ProjectDenied("invalid_fact_ref")
|
|
args = {**args, "fact_id": fact_id}
|
|
candidate = _safe_candidate(recall, store, {"fact_id": fact_id})
|
|
if candidate is None:
|
|
raise ProjectDenied("source_unavailable")
|
|
if action == "update" and ("content" in args or "sources" in args):
|
|
sources = _checked_sources(recall, args.get("sources"))
|
|
store.set_sources(fact_id, sources)
|
|
candidate["sources"] = sources
|
|
result = json.loads(provider._TOOL_HANDLERS[tool_name](provider, args))
|
|
return {**result, "fact_ref": (args["fact_ref"] if action == "remove" else
|
|
store.fact_ref(store.db_path.stem, fact_id)),
|
|
"status": "candidate", "sources": candidate["sources"]}
|
|
result = json.loads(provider._TOOL_HANDLERS[tool_name](provider, args))
|
|
for key in ("results", "facts"):
|
|
if key not in result:
|
|
continue
|
|
valid = []
|
|
for fact in result[key]:
|
|
if "fact_id" in fact:
|
|
candidate = _safe_candidate(recall, store, fact)
|
|
else:
|
|
# A contradiction pair is only meaningful if BOTH sources survive.
|
|
a = _safe_candidate(recall, store, fact["fact_a"])
|
|
b = _safe_candidate(recall, store, fact["fact_b"])
|
|
candidate = {**fact, "fact_a": a, "fact_b": b} if a and b else None
|
|
if candidate is not None:
|
|
valid.append(candidate)
|
|
result[key], result["count"] = valid, len(valid)
|
|
result.update(partial=True, coverage={"complete": False,
|
|
"policy": "candidate_limit_before_source_filter; no_exhaustive_recall"})
|
|
return result
|
|
|
|
|
|
def handle_project_tool(provider, tool_name, args):
|
|
identity = {"mode": "current_project", "current_session_id": provider._session_id,
|
|
"project_key": None, "profile_home": str(provider._project_home)}
|
|
try:
|
|
if get_hermes_home().resolve() != provider._project_home:
|
|
raise ProjectDenied("profile_changed")
|
|
if not provider._session_id or not (provider._project_home / "state.db").is_file():
|
|
raise ProjectDenied("scope_unresolved")
|
|
db = SessionDB(provider._project_home / "state.db")
|
|
try:
|
|
recall = ProjectRecall(db, provider._session_id)
|
|
if recall.scope.get("status") != "ready":
|
|
raise ProjectDenied(recall.scope.get("status", "scope_unresolved"))
|
|
principal = _principal(db, provider._session_id)
|
|
# The core groups absent/empty principals; this store uses exact DB identity.
|
|
with db._read_ctx() as conn:
|
|
exact = {row[0] for row in conn.execute("SELECT id FROM sessions WHERE user_id IS ?",
|
|
(None if principal == ["absent"] else principal[1],))}
|
|
recall.allowed = recall.allowed.intersection(exact)
|
|
recall.members_json = json.dumps(sorted(recall.allowed))
|
|
identity["project_key"] = recall.scope["project_key"]
|
|
for key in ("content", "tags"):
|
|
value = args.get(key)
|
|
if value is not None and (not isinstance(value, str) or redact_sensitive_text(value, force=True) != value):
|
|
raise ProjectDenied("sensitive_candidate")
|
|
_select_store(provider, recall.scope, principal)
|
|
with provider._store.transaction():
|
|
payload = _operate(provider, recall, tool_name, args)
|
|
if "error" in payload:
|
|
raise ProjectDenied("invalid_request")
|
|
result = recall.response(**payload)
|
|
if not result.get("success"):
|
|
raise ProjectDenied(result["status"])
|
|
result.update(identity=identity, assertion="candidate_not_confirmed",
|
|
trust_semantics="ranking_only_not_confirmation")
|
|
result["retrieval"]["holographic"] = "source_linked_candidates"
|
|
from hermes_project_scope import resolve_project_scope
|
|
now = resolve_project_scope(db, provider._session_id)
|
|
if (get_hermes_home().resolve() != provider._project_home
|
|
or now.get("revision") != recall.scope.get("revision")
|
|
or now.get("status") != "ready"
|
|
or _principal(db, provider._session_id) != principal):
|
|
raise ProjectDenied("scope_changed")
|
|
return json.dumps(result)
|
|
finally:
|
|
db.close()
|
|
except ProjectDenied as exc:
|
|
return json.dumps({"success": False, "status": exc.status, "identity": identity, "results": []})
|
|
except Exception:
|
|
# Do not echo SQLite statements, candidate text, or foreign source IDs.
|
|
return json.dumps({"success": False, "status": "project_memory_error", "identity": identity, "results": []})
|