Files

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": []})