Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fddf78ca38 | |||
| 5e539b5eca |
@@ -1,85 +0,0 @@
|
||||
"""
|
||||
Crisis Detection Hook — Integrates 988 Lifeline into conversation loop.
|
||||
|
||||
When crisis is detected in user messages, returns 988 Lifeline
|
||||
information immediately instead of processing the original request.
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CrisisSeverity(Enum):
|
||||
NONE = 0
|
||||
MODERATE = 1
|
||||
HIGH = 2
|
||||
CRITICAL = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class CrisisDetection:
|
||||
detected: bool
|
||||
severity: CrisisSeverity
|
||||
matched_patterns: list
|
||||
confidence: float
|
||||
|
||||
|
||||
# Crisis detection patterns
|
||||
_CRISIS_PATTERNS = [
|
||||
(r"\b(i want to die|want to kill myself|end (my|it all))\b", CrisisSeverity.CRITICAL, 0.95),
|
||||
(r"\b(kill(ing)? myself|suicide|suicidal)\b", CrisisSeverity.CRITICAL, 0.90),
|
||||
(r"\b(no reason to live|don'?t want to (live|be here|exist))\b", CrisisSeverity.HIGH, 0.85),
|
||||
(r"\b(better off (dead|without me)|wish i was dead)\b", CrisisSeverity.HIGH, 0.85),
|
||||
(r"\b(can'?t go on|can'?t take (it|this) anymore)\b", CrisisSeverity.HIGH, 0.80),
|
||||
(r"\b(self[- ]?harm|cut(ting)? myself|hurt(ing)? myself)\b", CrisisSeverity.HIGH, 0.85),
|
||||
(r"\b(hopeless|no hope|give up on (life|everything))\b", CrisisSeverity.MODERATE, 0.70),
|
||||
]
|
||||
|
||||
_COMPILED = [(re.compile(p, re.IGNORECASE), s, c) for p, s, c in _CRISIS_PATTERNS]
|
||||
|
||||
|
||||
def detect_crisis(message: str) -> CrisisDetection:
|
||||
if not message or not isinstance(message, str):
|
||||
return CrisisDetection(False, CrisisSeverity.NONE, [], 0.0)
|
||||
|
||||
matched = []
|
||||
max_severity = CrisisSeverity.NONE
|
||||
max_confidence = 0.0
|
||||
|
||||
for pattern, severity, confidence in _COMPILED:
|
||||
if pattern.search(message):
|
||||
matched.append(pattern.pattern)
|
||||
if confidence > max_confidence:
|
||||
max_confidence = confidence
|
||||
if severity.value > max_severity.value:
|
||||
max_severity = severity
|
||||
|
||||
detected = len(matched) > 0
|
||||
return CrisisDetection(detected, max_severity, matched, max_confidence)
|
||||
|
||||
|
||||
def get_crisis_response(severity: str = "CRITICAL") -> str:
|
||||
return """I hear you, and you are not alone.
|
||||
|
||||
**Please reach out for help right now:**
|
||||
|
||||
📞 **Call 988** — Suicide & Crisis Lifeline (24/7)
|
||||
💬 **Text HOME to 988** — Crisis text line
|
||||
🌐 **Chat:** 988lifeline.org/chat
|
||||
🇪🇸 **Spanish:** 1-888-628-9454
|
||||
🎖️ **Veterans:** 988, then press 1
|
||||
|
||||
You don\'t have to face this alone. Please hold on."""
|
||||
|
||||
|
||||
def check_crisis(user_message: str) -> Optional[str]:
|
||||
detection = detect_crisis(user_message)
|
||||
if detection.detected:
|
||||
logger.warning("Crisis detected: severity=%s", detection.severity.name)
|
||||
return get_crisis_response(detection.severity.name)
|
||||
return None
|
||||
24
run_agent.py
24
run_agent.py
@@ -7792,30 +7792,6 @@ class AIAgent:
|
||||
if isinstance(persist_user_message, str):
|
||||
persist_user_message = _sanitize_surrogates(persist_user_message)
|
||||
|
||||
# Crisis detection — check user message for crisis signals (#695)
|
||||
# If crisis detected, return 988 Lifeline response immediately
|
||||
if isinstance(user_message, str) and user_message.strip():
|
||||
try:
|
||||
from agent.crisis_hook import check_crisis
|
||||
_crisis_response = check_crisis(user_message)
|
||||
if _crisis_response:
|
||||
logger.warning("Crisis detected in session %s", getattr(self, 'session_id', 'unknown'))
|
||||
return {
|
||||
"response": _crisis_response,
|
||||
"messages": self.messages + [
|
||||
{"role": "user", "content": user_message},
|
||||
{"role": "assistant", "content": _crisis_response},
|
||||
],
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
||||
"model": self.model,
|
||||
"crisis_detected": True,
|
||||
}
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as _e:
|
||||
logger.debug("Crisis detection error: %s", _e)
|
||||
|
||||
|
||||
# Store stream callback for _interruptible_api_call to pick up
|
||||
self._stream_callback = stream_callback
|
||||
self._persist_user_message_idx = None
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"""
|
||||
Tests for crisis hook integration (#695).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from agent.crisis_hook import detect_crisis, get_crisis_response, check_crisis, CrisisSeverity
|
||||
|
||||
|
||||
class TestCrisisDetection:
|
||||
def test_detects_direct_suicide(self):
|
||||
result = detect_crisis("I want to kill myself")
|
||||
assert result.detected is True
|
||||
assert result.severity == CrisisSeverity.CRITICAL
|
||||
|
||||
def test_no_crisis_on_normal(self):
|
||||
result = detect_crisis("Hello, how are you?")
|
||||
assert result.detected is False
|
||||
|
||||
def test_crisis_response_has_988(self):
|
||||
response = get_crisis_response("CRITICAL")
|
||||
assert "988" in response
|
||||
assert "988lifeline.org/chat" in response
|
||||
assert "1-888-628-9454" in response
|
||||
|
||||
def test_check_crisis_returns_response(self):
|
||||
response = check_crisis("I want to die")
|
||||
assert response is not None
|
||||
assert "988" in response
|
||||
|
||||
def test_check_crisis_returns_none_for_normal(self):
|
||||
response = check_crisis("Hello")
|
||||
assert response is None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
60
tests/test_mcp_pid_lock.py
Normal file
60
tests/test_mcp_pid_lock.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Tests for MCP PID file lock (#734)."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
# We test the functions by mocking _PID_DIR
|
||||
import tools.mcp_pid_lock as pid_mod
|
||||
|
||||
|
||||
class TestPidLock:
|
||||
def setup_method(self):
|
||||
self.tmp = tempfile.mkdtemp()
|
||||
pid_mod._PID_DIR = Path(self.tmp)
|
||||
|
||||
def teardown_method(self):
|
||||
import shutil
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def test_check_returns_none_when_no_file(self):
|
||||
result = pid_mod.check_pid_lock("test-server")
|
||||
assert result is None
|
||||
|
||||
def test_write_and_check_alive(self):
|
||||
my_pid = os.getpid()
|
||||
pid_mod.write_pid_lock("test-server", my_pid)
|
||||
result = pid_mod.check_pid_lock("test-server")
|
||||
assert result == my_pid
|
||||
|
||||
def test_stale_pid_cleaned(self):
|
||||
# Write a PID that doesn't exist
|
||||
pid_mod.write_pid_lock("test-server", 999999999)
|
||||
result = pid_mod.check_pid_lock("test-server")
|
||||
assert result is None
|
||||
# PID file should be cleaned up
|
||||
assert not pid_mod._pid_file("test-server").exists()
|
||||
|
||||
def test_corrupted_pid_cleaned(self):
|
||||
pf = pid_mod._pid_file("test-server")
|
||||
pf.write_text("not-a-number")
|
||||
result = pid_mod.check_pid_lock("test-server")
|
||||
assert result is None
|
||||
assert not pf.exists()
|
||||
|
||||
def test_release_removes_file(self):
|
||||
pid_mod.write_pid_lock("test-server", os.getpid())
|
||||
assert pid_mod._pid_file("test-server").exists()
|
||||
pid_mod.release_pid_lock("test-server")
|
||||
assert not pid_mod._pid_file("test-server").exists()
|
||||
|
||||
def test_release_noop_when_no_file(self):
|
||||
# Should not raise
|
||||
pid_mod.release_pid_lock("nonexistent")
|
||||
|
||||
def test_multiple_servers_independent(self):
|
||||
pid_mod.write_pid_lock("server-a", os.getpid())
|
||||
assert pid_mod.check_pid_lock("server-a") == os.getpid()
|
||||
assert pid_mod.check_pid_lock("server-b") is None
|
||||
87
tools/mcp_pid_lock.py
Normal file
87
tools/mcp_pid_lock.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
PID file lock for MCP server instances — prevents concurrent spawning.
|
||||
|
||||
Before spawning an MCP server, check for a PID file. If the process is
|
||||
alive, skip spawn. If stale, clean up. Write PID after spawn, remove
|
||||
on shutdown.
|
||||
|
||||
Related: #714 (zombie cleanup), #734 (preventive lock)
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PID_DIR = Path.home() / ".hermes" / "mcp"
|
||||
|
||||
|
||||
def _pid_file(server_name: str) -> Path:
|
||||
"""Return the PID file path for a named MCP server."""
|
||||
_PID_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return _PID_DIR / f"{server_name}.pid"
|
||||
|
||||
|
||||
def _is_process_alive(pid: int) -> bool:
|
||||
"""Check if a process with the given PID is running."""
|
||||
try:
|
||||
os.kill(pid, 0) # Signal 0 = check existence without killing
|
||||
return True
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def check_pid_lock(server_name: str) -> Optional[int]:
|
||||
"""Check if an MCP server instance is already running.
|
||||
|
||||
Returns the running PID if locked, None if safe to spawn.
|
||||
"""
|
||||
pf = _pid_file(server_name)
|
||||
if not pf.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
pid = int(pf.read_text().strip())
|
||||
except (ValueError, OSError):
|
||||
# Corrupted PID file — clean up
|
||||
logger.warning("MCP PID file %s corrupted, removing", pf)
|
||||
try:
|
||||
pf.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
if _is_process_alive(pid):
|
||||
logger.info("MCP server '%s' already running (PID %d), skipping spawn", server_name, pid)
|
||||
return pid
|
||||
|
||||
# Stale PID file — process is dead
|
||||
logger.info("MCP server '%s' PID %d is stale, cleaning up", server_name, pid)
|
||||
try:
|
||||
pf.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def write_pid_lock(server_name: str, pid: int) -> None:
|
||||
"""Write PID file after successful MCP server spawn."""
|
||||
pf = _pid_file(server_name)
|
||||
try:
|
||||
pf.write_text(str(pid))
|
||||
logger.debug("MCP server '%s' PID lock written: %d", server_name, pid)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to write PID lock for '%s': %s", server_name, e)
|
||||
|
||||
|
||||
def release_pid_lock(server_name: str) -> None:
|
||||
"""Remove PID file on MCP server shutdown."""
|
||||
pf = _pid_file(server_name)
|
||||
try:
|
||||
if pf.exists():
|
||||
pf.unlink()
|
||||
logger.debug("MCP server '%s' PID lock released", server_name)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to release PID lock for '%s': %s", server_name, e)
|
||||
Reference in New Issue
Block a user