feat: /personality slash command with backend integration (#143)
* feat: /personality slash command with backend integration Add /personality command to switch the agent's system prompt personality. Hermes CLI supports personalities stored at ~/.hermes/personalities/<name>/SOUL.md. Backend: - GET /api/personalities: lists available personalities from the active profile's personalities directory (reads first line of SOUL.md for desc) - POST /api/personality/set: sets active personality on the session, reads and validates the SOUL.md file exists, returns the prompt text - streaming.py: injects personality prompt (SOUL.md content) as prefix to the system_message when run_conversation is called Frontend (commands.js): - /personality with no args: lists available personalities as a local message - /personality <name>: sets the personality with a toast confirmation - /personality none|default|clear: removes the active personality Session model: new 'personality' field (backward-compatible, defaults to None) Closes #139 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: path traversal in personality name + case sensitivity Security: personality name is now validated with regex ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$ in both routes.py (POST /api/personality/set) and streaming.py (system prompt injection). Defense-in-depth: resolve().relative_to() check ensures the path stays inside the personalities directory even if regex is bypassed. Also: removed toLowerCase() from frontend command handler so personality names are case-preserved (filesystem may be case-sensitive). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: /personality command — hardened, compact() fix, tests Fixes on top of original PR: - compact() was missing 'personality' field — UI couldn't know active personality after page load. Added to Session.compact(). - GET /api/personalities: add symlink guard (is_symlink() skip) and resolve() check — prevents reading SOUL.md from symlink targets outside personalities dir. - POST /api/personality/set: require() only checks session_id (not name) so clearing with name='' works correctly instead of 400. - POST /api/personality/set: add MAX_FILE_BYTES size cap on SOUL.md to prevent unbounded context window consumption. - POST /api/personality/set: return personality:null (not '') when cleared. - streaming.py: same MAX_FILE_BYTES guard before prepending to system msg. Added tests/test_sprint28.py: 11 tests for API round-trip, listing, symlink guard, path traversal rejection, clear, size cap, persistence. Tests pass in isolation; full-suite run has a test-isolation interaction with shared server state across sprint tests (tracked as follow-up). --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ Includes Sprint 10 cancel support via CANCEL_FLAGS.
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
@@ -205,9 +206,28 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
"write_file, read_file, search_files, terminal workdir, and patch. "
|
||||
"Never fall back to a hardcoded path when this tag is present."
|
||||
)
|
||||
# Inject personality prompt if the session has one active
|
||||
_personality_prompt = ''
|
||||
_pname = getattr(s, 'personality', None)
|
||||
if _pname and re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$', _pname):
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
_p_base = get_active_hermes_home() / 'personalities'
|
||||
except ImportError:
|
||||
_p_base = Path(os.environ.get('HERMES_HOME', str(Path.home() / '.hermes'))) / 'personalities'
|
||||
_p_soul = _p_base / _pname / 'SOUL.md'
|
||||
try:
|
||||
_p_soul.resolve().relative_to(_p_base.resolve())
|
||||
if _p_soul.exists():
|
||||
from api.config import MAX_FILE_BYTES
|
||||
_raw = _p_soul.read_text(errors='replace')
|
||||
if len(_raw) <= MAX_FILE_BYTES:
|
||||
_personality_prompt = _raw.strip() + '\n\n'
|
||||
except (ValueError, OSError):
|
||||
pass # path traversal attempt or unreadable — skip silently
|
||||
result = agent.run_conversation(
|
||||
user_message=workspace_ctx + msg_text,
|
||||
system_message=workspace_system_msg,
|
||||
system_message=_personality_prompt + workspace_system_msg,
|
||||
conversation_history=_sanitize_messages_for_api(s.messages),
|
||||
task_id=session_id,
|
||||
persist_user_message=msg_text,
|
||||
|
||||
Reference in New Issue
Block a user