Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 149 additions & 72 deletions huf/ai/memory_tools.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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()


Expand Down
10 changes: 10 additions & 0 deletions huf/ai/sdk_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down