From 89aa6b5d4a30097c01aa42fcb6dc4b312d8879c5 Mon Sep 17 00:00:00 2001 From: Safwan Erooth Date: Wed, 27 May 2026 09:26:16 +0400 Subject: [PATCH] fix: harden scoped memory tools and projection lifecycle --- huf/ai/memory_tools.py | 221 ++++++++++++------ huf/ai/sdk_tools.py | 10 + .../agent_tool_function.json | 2 +- 3 files changed, 160 insertions(+), 73 deletions(-) diff --git a/huf/ai/memory_tools.py b/huf/ai/memory_tools.py index 21242756..e399cbd9 100644 --- a/huf/ai/memory_tools.py +++ b/huf/ai/memory_tools.py @@ -1,14 +1,17 @@ import json + import frappe +from frappe import _ MANAGER_ROLES = {"System Manager", "Huf Manager"} +WRITE_BLOCKED_SCOPES_FOR_NON_MANAGER = {"Role", "Workspace", "Site", "Global"} -def is_manager(): +def _is_manager() -> bool: return bool(set(frappe.get_roles(frappe.session.user)) & MANAGER_ROLES) -def json_value(value): +def _json_value(value): if value in (None, ""): return None if isinstance(value, str): @@ -20,124 +23,198 @@ def json_value(value): return json.dumps(value, ensure_ascii=False, default=str) -def resolved_key(scope_type, scope_key=None, conversation_id=None, agent_name=None): - if scope_key: - return scope_key - return {"Conversation": conversation_id, "User": frappe.session.user, "Agent": agent_name, "Site": frappe.local.site, "Global": "global"}.get(scope_type) +def _resolve_scope_key(scope_type, provided_scope_key=None, conversation_id=None, agent_name=None): + if provided_scope_key: + return provided_scope_key + return { + "Conversation": conversation_id, + "User": frappe.session.user, + "Agent": agent_name, + "Site": frappe.local.site, + "Global": "global", + }.get(scope_type) -def can_read(row, conversation_id=None, agent_name=None): - if is_manager(): +def _can_read_memory(row, conversation_id=None, agent_name=None) -> bool: + if _is_manager(): return True - get = row.get if isinstance(row, dict) else lambda k, d=None: getattr(row, k, d) - scope_type = get("scope_type") - scope_key = get("scope_key") - visibility = get("visibility") or "Private" - if scope_type == "Conversation": - return conversation_id and scope_key == conversation_id - if scope_type == "User": - return scope_key == frappe.session.user - if scope_type == "Role": - return visibility == "Shared with Role" and scope_key in frappe.get_roles(frappe.session.user) - if scope_type == "Agent": - return agent_name and scope_key == agent_name and visibility in {"Private", "Shared with Agent"} - if scope_type == "Site": - return visibility == "Site" and scope_key == frappe.local.site - if scope_type == "Global": - return visibility == "Global" and scope_key == "global" + + if frappe.session.user == "Guest": + return False + + getter = row.get if isinstance(row, dict) else lambda k, d=None: getattr(row, k, d) + row_scope_type = getter("scope_type") + row_scope_key = getter("scope_key") + row_visibility = getter("visibility") or "Private" + + if row_scope_type == "Conversation": + return bool(conversation_id and row_scope_key == conversation_id) + if row_scope_type == "User": + return row_scope_key == frappe.session.user + if row_scope_type == "Role": + return row_visibility == "Shared with Role" and row_scope_key in frappe.get_roles(frappe.session.user) + if row_scope_type == "Agent": + return bool(agent_name and row_scope_key == agent_name and row_visibility in {"Private", "Shared with Agent"}) + if row_scope_type == "Site": + return row_visibility == "Site" and row_scope_key == frappe.local.site + if row_scope_type == "Global": + return row_visibility == "Global" and row_scope_key == "global" return False -def can_write(scope_type, scope_key=None, agent_name=None): - if is_manager(): +def _can_write_memory(scope_type, scope_key_value=None, agent_name=None) -> bool: + if _is_manager(): return True - if scope_type in {"Role", "Workspace", "Site", "Global"}: + if frappe.session.user == "Guest": + return False + if scope_type in WRITE_BLOCKED_SCOPES_FOR_NON_MANAGER: return False if scope_type == "User": - return scope_key == frappe.session.user + return scope_key_value == frappe.session.user if scope_type == "Agent": - return agent_name and scope_key == agent_name + return bool(agent_name and scope_key_value == agent_name) if scope_type == "Conversation": return True return False @frappe.whitelist() -def save_memory_record(title, summary_text, record_type="Fact", scope_type="Conversation", scope_key=None, data_json=None, status="Draft", visibility="Private", tags=None, confidence=0, importance_score=0, source_type="Manual", conversation_id=None, agent_run_id=None, agent_name=None, promote_to_knowledge=False, knowledge_source=None, raw_context_excerpt=None, **kwargs): - key = resolved_key(scope_type, scope_key, conversation_id, agent_name) - if not key or not can_write(scope_type, key, agent_name): - frappe.throw("Memory write blocked") - if promote_to_knowledge and not is_manager(): - frappe.throw("Knowledge promotion blocked") +def save_memory_record( + title, + summary_text, + record_type="Fact", + scope_type="Conversation", + scope_key=None, + data_json=None, + status="Draft", + visibility="Private", + tags=None, + confidence=0, + importance_score=0, + source_type="Manual", + conversation_id=None, + agent_run_id=None, + agent_name=None, + promote_to_knowledge=False, + knowledge_source=None, + raw_context_excerpt=None, + **kwargs, +): + if conversation_id and not scope_type: + scope_type = "Conversation" + + resolved_scope_key = _resolve_scope_key(scope_type, scope_key, conversation_id, agent_name) + if not resolved_scope_key or not _can_write_memory(scope_type, resolved_scope_key, agent_name): + frappe.throw(_("Memory write blocked")) + + if promote_to_knowledge and not _is_manager(): + frappe.throw(_("Knowledge promotion blocked")) + tag_text = ", ".join(tags) if isinstance(tags, list) else (tags or "") - doc = frappe.get_doc({"doctype": "Memory Record", "title": title, "summary_text": summary_text, "record_type": record_type, "scope_type": scope_type, "scope_key": key, "status": status, "visibility": visibility, "tags": tag_text, "confidence": float(confidence or 0), "importance_score": float(importance_score or 0), "source_type": source_type, "conversation": conversation_id if scope_type == "Conversation" else None, "run": agent_run_id, "agent": agent_name, "data_json": json_value(data_json), "raw_context_excerpt": raw_context_excerpt, "promote_to_knowledge": 1 if promote_to_knowledge else 0, "knowledge_source": knowledge_source}) - doc.insert() + doc = frappe.get_doc( + { + "doctype": "Memory Record", + "title": title, + "summary_text": summary_text, + "record_type": record_type, + "scope_type": scope_type, + "scope_key": resolved_scope_key, + "status": status, + "visibility": visibility, + "tags": tag_text, + "confidence": float(confidence or 0), + "importance_score": float(importance_score or 0), + "source_type": source_type, + "conversation": conversation_id if scope_type == "Conversation" else None, + "run": agent_run_id, + "agent": agent_name, + "data_json": _json_value(data_json), + "raw_context_excerpt": raw_context_excerpt, + "promote_to_knowledge": 1 if promote_to_knowledge else 0, + "knowledge_source": knowledge_source, + } + ) + doc.insert(ignore_permissions=False) + if promote_to_knowledge and knowledge_source and doc.status == "Active": doc.queue_knowledge_projection() - return {"success": True, "memory_record": doc.name, "status": doc.status, "scope_type": doc.scope_type, "scope_key": doc.scope_key, "projection_status": doc.projection_status} + + return { + "success": True, + "memory_record": doc.name, + "status": doc.status, + "scope_type": doc.scope_type, + "scope_key": doc.scope_key, + "projection_status": doc.projection_status, + } @frappe.whitelist() def get_memory_record(memory_record, conversation_id=None, agent_name=None, **kwargs): doc = frappe.get_doc("Memory Record", memory_record) - if not can_read(doc, conversation_id, agent_name): - frappe.throw("Memory read blocked") + if not _can_read_memory(doc, conversation_id, agent_name): + frappe.throw(_("Memory read blocked")) return doc.as_dict() @frappe.whitelist() def search_memory_records(query=None, record_type=None, scope_type=None, status="Active", limit=10, conversation_id=None, agent_name=None, **kwargs): - scopes = [] - if conversation_id: - scopes.append({"scope_type": "Conversation", "scope_key": conversation_id}) - if frappe.session.user != "Guest": - scopes.append({"scope_type": "User", "scope_key": frappe.session.user}) - scopes += [{"scope_type": "Role", "scope_key": r} for r in frappe.get_roles(frappe.session.user)] - if agent_name: - scopes.append({"scope_type": "Agent", "scope_key": agent_name}) - scopes += [{"scope_type": "Site", "scope_key": frappe.local.site}, {"scope_type": "Global", "scope_key": "global"}] - if scope_type: - scopes = [s for s in scopes if s["scope_type"] == scope_type] - results, seen, max_rows = [], set(), min(int(limit or 10), 50) - base = {"status": status} if status else {} + max_rows = min(max(int(limit or 10), 1), 50) + filters = {} + if status: + filters["status"] = status if record_type: - base["record_type"] = record_type - for scope in scopes: - filters = dict(base) - filters.update(scope) - rows = frappe.get_all("Memory Record", filters=filters, fields=["name", "title", "record_type", "scope_type", "scope_key", "visibility", "status", "summary_text", "confidence", "importance_score", "tags", "agent", "conversation", "knowledge_source", "projection_status", "modified"], order_by="importance_score desc, modified desc", limit_page_length=max_rows) - for row in rows: - text = " ".join(str(row.get(f) or "") for f in ["title", "summary_text", "record_type", "tags"]) - if row.name in seen or (query and query.lower() not in text.lower()) or not can_read(row, conversation_id, agent_name): - continue - seen.add(row.name) - results.append(row) - if len(results) >= max_rows: - return {"success": True, "results": results} + filters["record_type"] = record_type + if scope_type: + filters["scope_type"] = scope_type + + rows = frappe.get_all( + "Memory Record", + filters=filters, + fields=["name", "title", "record_type", "scope_type", "scope_key", "visibility", "status", "summary_text", "confidence", "importance_score", "tags", "agent", "conversation", "knowledge_source", "projection_status", "modified"], + order_by="importance_score desc, modified desc", + limit_page_length=max_rows * 4, + ) + + query_lower = (query or "").strip().lower() + results = [] + for row in rows: + if not _can_read_memory(row, conversation_id, agent_name): + continue + haystack = " ".join(str(row.get(field) or "") for field in ["title", "summary_text", "record_type", "tags"]).lower() + if query_lower and query_lower not in haystack: + continue + results.append(row) + if len(results) >= max_rows: + break + return {"success": True, "results": results} @frappe.whitelist() def archive_memory_record(memory_record, conversation_id=None, agent_name=None, **kwargs): doc = frappe.get_doc("Memory Record", memory_record) - if not can_write(doc.scope_type, doc.scope_key, agent_name) or not can_read(doc, conversation_id, agent_name): - frappe.throw("Memory archive blocked") + if not _can_read_memory(doc, conversation_id, agent_name): + frappe.throw(_("Memory archive blocked")) + if not _can_write_memory(doc.scope_type, doc.scope_key, agent_name): + frappe.throw(_("Memory archive blocked")) doc.status = "Archived" - doc.save() + doc.save(ignore_permissions=False) return {"success": True, "memory_record": doc.name, "status": doc.status} @frappe.whitelist() def promote_memory_to_knowledge(memory_record, knowledge_source=None, **kwargs): - if not is_manager(): - frappe.throw("Knowledge promotion blocked") + if not _is_manager(): + frappe.throw(_("Knowledge promotion blocked")) + doc = frappe.get_doc("Memory Record", memory_record) if knowledge_source: doc.knowledge_source = knowledge_source doc.promote_to_knowledge = 1 if doc.status != "Active": doc.status = "Active" - doc.save() + doc.save(ignore_permissions=False) return doc.queue_knowledge_projection() diff --git a/huf/ai/sdk_tools.py b/huf/ai/sdk_tools.py index 53e281ed..53092c61 100644 --- a/huf/ai/sdk_tools.py +++ b/huf/ai/sdk_tools.py @@ -138,6 +138,16 @@ def create_agent_tools(agent) -> list[FunctionTool]: function_path = "huf.ai.sdk_tools.handle_set_conversation_data" elif function_doc.types == "Load Conversation Data": function_path = "huf.ai.sdk_tools.handle_load_conversation_data" + elif function_doc.types == "Save Memory Record": + function_path = "huf.ai.memory_tools.handle_save_memory_record" + elif function_doc.types == "Search Memory Records": + function_path = "huf.ai.memory_tools.handle_search_memory_records" + elif function_doc.types == "Get Memory Record": + function_path = "huf.ai.memory_tools.handle_get_memory_record" + elif function_doc.types == "Archive Memory Record": + function_path = "huf.ai.memory_tools.handle_archive_memory_record" + elif function_doc.types == "Promote Memory to Knowledge": + function_path = "huf.ai.memory_tools.handle_promote_memory_to_knowledge" else: continue diff --git a/huf/huf/doctype/agent_tool_function/agent_tool_function.json b/huf/huf/doctype/agent_tool_function/agent_tool_function.json index 3081c585..3c9c5a07 100644 --- a/huf/huf/doctype/agent_tool_function/agent_tool_function.json +++ b/huf/huf/doctype/agent_tool_function/agent_tool_function.json @@ -52,7 +52,7 @@ "fieldname": "types", "fieldtype": "Select", "label": "Types", - "options": "\nGet Document\nGet Multiple Documents\nGet List\nCreate Document\nCreate Multiple Documents\nUpdate Document\nUpdate Multiple Documents\nDelete Document\nDelete Multiple Documents\nSubmit Document\nCancel Document\nGet Amended Document\nCustom Function\nApp Provided\nAttach File to Document\nGet Report Result\nGet Value\nSet Value\nGET\nPOST\nRun Agent\nClient Side Tool\nGet Conversation Data\nSet Conversation Data\nLoad Conversation Data" + "options": "\nGet Document\nGet Multiple Documents\nGet List\nCreate Document\nCreate Multiple Documents\nUpdate Document\nUpdate Multiple Documents\nDelete Document\nDelete Multiple Documents\nSubmit Document\nCancel Document\nGet Amended Document\nCustom Function\nApp Provided\nAttach File to Document\nGet Report Result\nGet Value\nSet Value\nGET\nPOST\nRun Agent\nClient Side Tool\nGet Conversation Data\nSet Conversation Data\nLoad Conversation Data\nSave Memory Record\nSearch Memory Records\nGet Memory Record\nArchive Memory Record\nPromote Memory to Knowledge" }, { "depends_on": "eval: [\n 'Get Document',\n 'Get Multiple Documents',\n 'Get List',\n 'Create Document',\n 'Create Multiple Documents',\n 'Update Document',\n 'Update Multiple Documents',\n 'Delete Document',\n 'Delete Multiple Documents',\n 'Submit Document',\n 'Cancel Document',\n 'Get Amended Document',\n 'Attach File to Document',\n 'Get Report Result',\n 'Get Value',\n 'Set Value'\n].includes(doc.types)",