Files
hermes-agent/tui_gateway/methods_recall.py
T

150 lines
6.2 KiB
Python

"""Source-backed recall for an attached live session, never a supplied storage ID.
Wire identity: session_id is the live UI ID; source_session_id is a historical
storage ID. The authorized record supplies the current storage ID and profile.
"""
from .method_ctx import HandlerRegistry, bind_module
_registry = HandlerRegistry()
method = _registry.method
_RECALL_FIELDS = {
"search": {"query", "search_cursor", "limit", "window", "sort"},
"read": {"source_session_id", "around_message_id", "after_message_id",
"before_message_id", "content_offset", "content_length", "window"},
"status": set(),
}
_RECALL_INTS = {
"limit": (1, 10), "window": (1, 20), "around_message_id": (1, 2**63 - 1),
"after_message_id": (0, 2**63 - 1), "before_message_id": (1, 2**63 - 1),
"content_offset": (0, 2**63 - 1), "content_length": (1, 4000),
}
def _recall_arguments(params, mode):
allowed = _RECALL_FIELDS[mode]
if set(params) - allowed - {"session_id", "profile"}:
raise ValueError("unsupported recall parameter")
args = {k: v for k, v in params.items() if k in allowed and v is not None}
for name, value in args.items():
if name in _RECALL_INTS:
low, high = _RECALL_INTS[name]
if type(value) is not int or not low <= value <= high:
raise ValueError(f"invalid {name}")
elif not isinstance(value, str):
raise ValueError(f"invalid {name}")
if "query" in args and len(args["query"]) > 4000:
raise ValueError("query too long")
if "search_cursor" in args and len(args["search_cursor"]) > 2048:
raise ValueError("search_cursor too long")
if "sort" in args and args["sort"] not in {"relevance", "newest", "oldest"}:
raise ValueError("invalid sort")
if "search_cursor" in args and not args.get("query", "").strip():
raise ValueError("search_cursor requires query")
if mode == "read":
if (args.get("content_offset", 0) or args.get("content_length", 4000) != 4000) and "around_message_id" not in args:
raise ValueError("content slicing requires around_message_id")
if "before_message_id" in args and "around_message_id" not in args:
raise ValueError("backward scan requires around_message_id")
target = args.pop("source_session_id", None)
if not target or len(target) > 512:
raise ValueError("source_session_id required")
args["session_id"] = target
return args
def _recall_storage_id(owner):
return getattr(owner.get("agent"), "session_id", None) or owner.get("session_key")
def _recall_scoped_query(rid, params):
from hermes_state_registry import acquire
from tools.session_search_project import ProjectRecall, project_session_search
if get_hermes_home().resolve() != params["_owner_home"]:
return _err(rid, 4001, "profile does not own this session")
db = acquire(get_hermes_home() / "state.db")
try:
recall = ProjectRecall(db, params["_current_storage_id"])
if recall.scope["status"] != "ready":
result = recall.response(success=False, status=recall.scope["status"], results=[])
elif params["_mode"] == "status":
result = recall.response(session_count=len(recall.allowed))
else:
result = json.loads(project_session_search(
db, params["_current_storage_id"], **params["_args"]))
# Keep the initial scope gate valid even if the query resolved a new scope.
checked = recall.response(evidence=result)
if not checked["success"]:
result = checked
elif "scope" not in result:
result["scope"] = checked["scope"]
return result
finally:
db.close()
def _recall_rpc(rid, params, mode):
from hermes_cli.profiles import get_profile_dir, validate_profile_name
sid = params.get("session_id")
if not isinstance(sid, str):
return _err(rid, 4001, "session not found or not owned by this transport")
transport, owner = _current_session_steer_authority(sid)
if transport is None or owner is None:
return _err(rid, 4001, "session not found or not owned by this transport")
try:
args = _recall_arguments(params, mode)
except ValueError as exc:
return _err(rid, 4000, str(exc))
home = Path(owner.get("profile_home") or _hermes_home).resolve()
profile = params.get("profile")
if profile is not None:
try:
if not isinstance(profile, str):
raise ValueError("profile must be a string")
validate_profile_name(profile)
requested = Path(_profile_home(profile) or _hermes_home).resolve()
except (ValueError, OSError):
return _err(rid, 4000, "invalid profile")
if requested != home:
return _err(rid, 4001, "profile does not own this session")
profile = None
if home != Path(_hermes_home).resolve():
profile = "default" if home == get_profile_dir("default").resolve() else home.name
stored_id = _recall_storage_id(owner)
scoped = {"profile": profile, "_owner_home": home, "_current_storage_id": stored_id,
"_mode": mode, "_args": args}
try:
result = _profile_scoped(_recall_scoped_query)(rid, scoped)
except Exception:
logger.exception("Project recall store or scope unavailable")
return _err(rid, 5031, "project recall unavailable")
with _sessions_lock:
now_transport, now_owner = _current_session_steer_authority(sid)
if (now_transport is not transport or now_owner is not owner
or _recall_storage_id(owner) != stored_id
or Path(owner.get("profile_home") or _hermes_home).resolve() != home):
return _err(rid, 4001, "session authority changed during recall")
return result if "error" in result else _ok(rid, result)
@method("project.recall.search")
def _recall_search(rid, params):
return _recall_rpc(rid, params, "search")
@method("project.recall.read")
def _recall_read(rid, params):
return _recall_rpc(rid, params, "read")
@method("project.recall.status")
def _recall_status(rid, params):
return _recall_rpc(rid, params, "status")
def register(server):
bind_module(globals(), server)