feat: add user context injection and tool retry improvements

This commit is contained in:
yumoqing 2026-05-28 11:43:34 +08:00
parent 74e14831ec
commit cfe01cdd2f
2 changed files with 206 additions and 42 deletions

View File

@ -72,9 +72,11 @@ class HermesAgent:
def _get_current_user_id(self, context: Dict[str, Any]) -> str: def _get_current_user_id(self, context: Dict[str, Any]) -> str:
"""Get current user ID from request context""" """Get current user ID from request context"""
# In ahserver, user context is typically available in the request # In ahserver, user context is typically available in the request
user_id = context.get('user_id') or context.get('userid') user_id = None
if context:
user_id = context.get('user_id') or context.get('userid')
if not user_id: if not user_id:
raise ValueError("User ID not found in context. User must be authenticated.") return "anonymous"
return str(user_id) return str(user_id)
def _validate_skill_name(self, name: str) -> bool: def _validate_skill_name(self, name: str) -> bool:
@ -252,7 +254,8 @@ class HermesAgent:
] ]
async def _execute_tool_with_retry(self, tool_func: Callable, params: dict, async def _execute_tool_with_retry(self, tool_func: Callable, params: dict,
tool_name: str, user_id: str) -> Dict[str, Any]: tool_name: str, user_id: str,
context: Dict[str, Any] = None) -> Dict[str, Any]:
""" """
Execute a tool with retry logic and proper error handling Execute a tool with retry logic and proper error handling
@ -261,6 +264,7 @@ class HermesAgent:
params: Parameters for the tool params: Parameters for the tool
tool_name: Name of the tool (for logging) tool_name: Name of the tool (for logging)
user_id: User ID (for logging) user_id: User ID (for logging)
context: Request context (injected into tool params for user isolation)
Returns: Returns:
Result of the tool execution Result of the tool execution
@ -284,6 +288,16 @@ class HermesAgent:
# Add user context to parameters if needed # Add user context to parameters if needed
params_with_context = params.copy() params_with_context = params.copy()
# Inject context into tool params for user-isolated wrappers
# Tool wrappers that support context: memory, skill_manage, skill_view,
# skills_list, todo, execute_code
if context is not None:
# Only inject context if the function signature accepts it
import inspect
sig = inspect.signature(tool_func)
if 'context' in sig.parameters:
params_with_context['context'] = context
# Execute with timeout # Execute with timeout
result = await asyncio.wait_for( result = await asyncio.wait_for(
tool_func(**params_with_context), tool_func(**params_with_context),
@ -532,7 +546,8 @@ class HermesAgent:
tool_info['function'], tool_info['function'],
parameters, parameters,
tool_name, tool_name,
user_id user_id,
context=context
) )
return result return result
@ -753,7 +768,7 @@ class HermesAgent:
async with db.sqlorContext(dbname) as sor: async with db.sqlorContext(dbname) as sor:
if action == "view": if action == "view":
filters = {'user_id': user_id, 'name': name} filters = {'user_id': user_id, 'name': name}
skills = await sor.R('harnessed_skills', {'user_id': user_id, 'name': name}) skills = await sor.R('hermes_skills', {'user_id': user_id, 'name': name})
if skills: if skills:
return {"success": True, "skill": skills[0], "user_id": user_id} return {"success": True, "skill": skills[0], "user_id": user_id}
else: else:
@ -777,12 +792,12 @@ class HermesAgent:
'created_at': datetime.now(), 'created_at': datetime.now(),
'updated_at': datetime.now() 'updated_at': datetime.now()
} }
result = await sor.C('harnessed_skills', data) result = await sor.C('hermes_skills', data)
return {"success": True, "action": action, "id": skill_id, "user_id": user_id} return {"success": True, "action": action, "id": skill_id, "user_id": user_id}
elif action == "update": elif action == "update":
filters = {'user_id': user_id, 'name': name} filters = {'user_id': user_id, 'name': name}
skills = await sor.R('harnessed_skills', {'user_id': user_id, 'name': name}) skills = await sor.R('hermes_skills', {'user_id': user_id, 'name': name})
if not skills: if not skills:
return {"success": False, "error": "Skill not found", "user_id": user_id} return {"success": False, "error": "Skill not found", "user_id": user_id}
@ -802,16 +817,16 @@ class HermesAgent:
'content': updated_content, 'content': updated_content,
'updated_at': datetime.now() 'updated_at': datetime.now()
} }
result = await sor.U('harnessed_skills', data) result = await sor.U('hermes_skills', data)
return {"success": True, "action": action, "id": skill['id'], "user_id": user_id} return {"success": True, "action": action, "id": skill['id'], "user_id": user_id}
elif action == "delete": elif action == "delete":
filters = {'user_id': user_id, 'name': name} filters = {'user_id': user_id, 'name': name}
skills = await sor.R('harnessed_skills', {'user_id': user_id, 'name': name}) skills = await sor.R('hermes_skills', {'user_id': user_id, 'name': name})
if not skills: if not skills:
return {"success": False, "error": "Skill not found", "user_id": user_id} return {"success": False, "error": "Skill not found", "user_id": user_id}
result = await sor.D('harnessed_skills', {'id': skills[0]['id']}) result = await sor.D('hermes_skills', {'id': skills[0]['id']})
return {"success": True, "action": action, "id": skills[0]['id'], "user_id": user_id} return {"success": True, "action": action, "id": skills[0]['id'], "user_id": user_id}
except Exception as e: except Exception as e:

View File

@ -14,6 +14,50 @@ from datetime import datetime
# Base directory for memory and skills # Base directory for memory and skills
HERMES_DIR = os.path.expanduser("~/.hermes") HERMES_DIR = os.path.expanduser("~/.hermes")
def _get_user_dir(base_dir: str, context: Optional[Dict[str, Any]] = None) -> str:
"""Get user-isolated subdirectory. Falls back to global dir if no user context."""
user_id = None
if context:
user_id = context.get('user_id') or context.get('userid')
if user_id:
return os.path.join(base_dir, "users", str(user_id))
return base_dir
# Shared skills directory (owner org only can write, all can read)
SHARED_SKILLS_DIR = os.path.join(HERMES_DIR, "skills")
def _is_owner_org(context: Optional[Dict[str, Any]] = None) -> bool:
"""Check if current user belongs to the owner organization (org_id == '0').
Checks context first, then falls back to ServerEnv."""
# 1. Check context for org_id
if context:
org_id = context.get('org_id') or context.get('orgid')
if org_id is not None:
return str(org_id) == '0'
# 2. Try ServerEnv
try:
from ahserver.serverenv import ServerEnv
env = ServerEnv()
org_id = getattr(env, 'orgid', None) or getattr(env, 'org_id', None)
if org_id is not None:
return str(org_id) == '0'
except Exception:
pass
return False
def _get_shared_skill_path(name: str, file_path: Optional[str] = None) -> str:
"""Get path to a shared skill file."""
if file_path:
return os.path.join(SHARED_SKILLS_DIR, name, file_path)
return os.path.join(SHARED_SKILLS_DIR, name, "SKILL.md")
def _get_user_skill_path(user_dir: str, name: str, file_path: Optional[str] = None) -> str:
"""Get path to a user-specific skill file."""
skills_dir = os.path.join(user_dir, "skills")
if file_path:
return os.path.join(skills_dir, name, file_path)
return os.path.join(skills_dir, name, "SKILL.md")
async def wrapped_read_file(path: str, offset: int = 1, limit: int = 500) -> Dict[str, Any]: async def wrapped_read_file(path: str, offset: int = 1, limit: int = 500) -> Dict[str, Any]:
"""Actual implementation of read_file tool.""" """Actual implementation of read_file tool."""
try: try:
@ -170,11 +214,11 @@ async def wrapped_process(action: str, session_id: Optional[str] = None,
# For now, returns mock for management actions. # For now, returns mock for management actions.
return {"success": True, "action": action, "session_id": session_id, "note": "Process management state requires external tracking"} return {"success": True, "action": action, "session_id": session_id, "note": "Process management state requires external tracking"}
async def wrapped_execute_code(code: str) -> Dict[str, Any]: async def wrapped_execute_code(code: str, context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Actual implementation of execute_code tool.""" """Actual implementation of execute_code tool, user-isolated temp dir."""
try: try:
# Create a temporary file to run the code safely user_dir = _get_user_dir(HERMES_DIR, context)
temp_dir = os.path.join(HERMES_DIR, "tmp") temp_dir = os.path.join(user_dir, "tmp")
os.makedirs(temp_dir, exist_ok=True) os.makedirs(temp_dir, exist_ok=True)
temp_file = os.path.join(temp_dir, f"exec_{uuid.uuid4().hex[:8]}.py") temp_file = os.path.join(temp_dir, f"exec_{uuid.uuid4().hex[:8]}.py")
@ -215,11 +259,12 @@ async def wrapped_browser_navigate(url: str) -> Dict[str, Any]:
# --- Memory & Session tools --- # --- Memory & Session tools ---
async def wrapped_memory(action: str, target: str, content: str = "", async def wrapped_memory(action: str, target: str, content: str = "",
old_text: str = "") -> Dict[str, Any]: old_text: str = "", context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Actual implementation of memory tool using local JSON file.""" """Actual implementation of memory tool using local JSON file, user-isolated."""
try: try:
memory_file = os.path.join(HERMES_DIR, "memory.json") user_dir = _get_user_dir(HERMES_DIR, context)
os.makedirs(HERMES_DIR, exist_ok=True) memory_file = os.path.join(user_dir, "memory.json")
os.makedirs(user_dir, exist_ok=True)
if not os.path.exists(memory_file): if not os.path.exists(memory_file):
memory = {"user": [], "system": []} memory = {"user": [], "system": []}
@ -242,52 +287,156 @@ async def wrapped_memory(action: str, target: str, content: str = "",
async def wrapped_session_search(query: Optional[str] = None, limit: int = 3) -> Dict[str, Any]: async def wrapped_session_search(query: Optional[str] = None, limit: int = 3) -> Dict[str, Any]:
return {"success": True, "sessions": [], "note": "Session history tracking requires external indexing"} return {"success": True, "sessions": [], "note": "Session history tracking requires external indexing"}
async def wrapped_skill_view(name: str, file_path: Optional[str] = None) -> Dict[str, Any]: async def wrapped_skill_view(name: str, file_path: Optional[str] = None,
context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""View a skill. Checks user-specific dir first, then shared skills."""
try: try:
skills_dir = os.path.join(HERMES_DIR, "skills") user_dir = _get_user_dir(HERMES_DIR, context)
if file_path:
full_path = os.path.join(skills_dir, name, file_path) # 1. Check user-specific skill first
else: user_path = _get_user_skill_path(user_dir, name, file_path)
full_path = os.path.join(skills_dir, name, "SKILL.md") if os.path.exists(user_path):
with open(user_path, 'r') as f:
return {"success": True, "content": f.read(), "source": "user"}
# 2. Check shared skill
shared_path = _get_shared_skill_path(name, file_path)
if os.path.exists(shared_path):
with open(shared_path, 'r') as f:
return {"success": True, "content": f.read(), "source": "shared"}
if os.path.exists(full_path):
with open(full_path, 'r') as f:
return {"success": True, "content": f.read()}
return {"success": False, "error": "Skill not found"} return {"success": False, "error": "Skill not found"}
except Exception as e: except Exception as e:
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
async def wrapped_skills_list(category: Optional[str] = None) -> Dict[str, Any]: async def wrapped_skills_list(category: Optional[str] = None,
context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""List both user-specific skills and shared skills with source indicator."""
try: try:
skills_dir = os.path.join(HERMES_DIR, "skills") user_dir = _get_user_dir(HERMES_DIR, context)
if not os.path.exists(skills_dir): user_skills_dir = os.path.join(user_dir, "skills")
return {"success": True, "skills": []}
skills = [] skills = []
for d in os.listdir(skills_dir):
skill_path = os.path.join(skills_dir, d) # 1. List user-specific skills
if os.path.isdir(skill_path) and os.path.exists(os.path.join(skill_path, "SKILL.md")): if os.path.exists(user_skills_dir):
skills.append(d) for d in os.listdir(user_skills_dir):
skill_path = os.path.join(user_skills_dir, d)
if os.path.isdir(skill_path) and os.path.exists(os.path.join(skill_path, "SKILL.md")):
skills.append({"name": d, "source": "user"})
# 2. List shared skills
if os.path.exists(SHARED_SKILLS_DIR):
for d in os.listdir(SHARED_SKILLS_DIR):
skill_path = os.path.join(SHARED_SKILLS_DIR, d)
if os.path.isdir(skill_path) and os.path.exists(os.path.join(skill_path, "SKILL.md")):
# Don't duplicate if same name exists in user skills
if not any(s["name"] == d for s in skills):
skills.append({"name": d, "source": "shared"})
return {"success": True, "skills": skills} return {"success": True, "skills": skills}
except Exception as e: except Exception as e:
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
async def wrapped_skill_manage(action: str, name: str, **kwargs) -> Dict[str, Any]: async def wrapped_skill_manage(action: str, name: str, context: Optional[Dict[str, Any]] = None,
**kwargs) -> Dict[str, Any]:
"""Manage skills with owner-org permission check for shared skills.
Dual-layer architecture:
- User skills (~/.hermes/users/{user_id}/skills/): read/write for owner
- Shared skills (~/.hermes/skills/): read for all, write for owner org only
Operations target user skills by default. Use source='shared' kwarg to
target shared skills (requires owner org membership).
"""
try: try:
skills_dir = os.path.join(HERMES_DIR, "skills", name) user_dir = _get_user_dir(HERMES_DIR, context)
target = kwargs.pop('source', 'user') # 'user' (default) or 'shared'
owner = _is_owner_org(context)
if target == "shared":
# Shared skill operations require owner org membership
if not owner:
return {"success": False, "error": "共享技能仅允许所有者机构用户修改", "source": "shared"}
skills_dir = os.path.join(SHARED_SKILLS_DIR, name)
else:
# User skill operations
skills_dir = os.path.join(user_dir, "skills", name)
if action == "create": if action == "create":
if target == "shared" and os.path.exists(skills_dir):
return {"success": False, "error": "共享技能已存在"}
os.makedirs(skills_dir, exist_ok=True) os.makedirs(skills_dir, exist_ok=True)
if 'content' in kwargs: if 'content' in kwargs:
with open(os.path.join(skills_dir, "SKILL.md"), 'w') as f: with open(os.path.join(skills_dir, "SKILL.md"), 'w') as f:
f.write(kwargs['content']) f.write(kwargs['content'])
return {"success": True, "action": "create", "name": name} return {"success": True, "action": "create", "name": name, "source": target}
elif action == "patch":
skill_file = os.path.join(skills_dir, "SKILL.md")
if not os.path.exists(skill_file):
return {"success": False, "error": "Skill not found"}
with open(skill_file, 'r') as f:
content = f.read()
old_string = kwargs.get('old_string', '')
new_string = kwargs.get('new_string', '')
if old_string not in content:
return {"success": False, "error": "old_string not found in skill"}
new_content = content.replace(old_string, new_string, 1)
with open(skill_file, 'w') as f:
f.write(new_content)
return {"success": True, "action": "patch", "name": name, "source": target}
elif action == "edit":
os.makedirs(skills_dir, exist_ok=True)
if 'content' in kwargs:
with open(os.path.join(skills_dir, "SKILL.md"), 'w') as f:
f.write(kwargs['content'])
return {"success": True, "action": "edit", "name": name, "source": target}
elif action == "delete":
import shutil
if os.path.exists(skills_dir):
shutil.rmtree(skills_dir)
return {"success": True, "action": "delete", "name": name, "source": target}
elif action == "view":
skill_file = os.path.join(skills_dir, "SKILL.md")
if os.path.exists(skill_file):
with open(skill_file, 'r') as f:
return {"success": True, "content": f.read(), "source": target}
return {"success": False, "error": "Skill not found", "source": target}
elif action == "write_file":
file_path = kwargs.get('file_path', '')
file_content = kwargs.get('file_content', '')
if not file_path:
return {"success": False, "error": "file_path required"}
os.makedirs(skills_dir, exist_ok=True)
full_path = os.path.join(skills_dir, file_path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, 'w') as f:
f.write(file_content)
return {"success": True, "action": "write_file", "name": name, "source": target}
elif action == "remove_file":
file_path = kwargs.get('file_path', '')
if not file_path:
return {"success": False, "error": "file_path required"}
full_path = os.path.join(skills_dir, file_path)
if os.path.exists(full_path):
os.remove(full_path)
return {"success": True, "action": "remove_file", "name": name, "source": target}
return {"success": False, "error": f"Unsupported action: {action}"} return {"success": False, "error": f"Unsupported action: {action}"}
except Exception as e: except Exception as e:
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
async def wrapped_todo(todos: Optional[List[Dict[str, Any]]] = None, merge: bool = False) -> Dict[str, Any]: async def wrapped_todo(todos: Optional[List[Dict[str, Any]]] = None, merge: bool = False,
context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
try: try:
todo_file = os.path.join(HERMES_DIR, "todo.json") user_dir = _get_user_dir(HERMES_DIR, context)
todo_file = os.path.join(user_dir, "todo.json")
os.makedirs(user_dir, exist_ok=True)
if todos is not None: if todos is not None:
with open(todo_file, 'w') as f: with open(todo_file, 'w') as f:
json.dump(todos, f, indent=2) json.dump(todos, f, indent=2)