Cherry-pick 6 bug fixes from PR #76 and update documentation

Code fixes (run_agent.py):
- Fix off-by-one in _flush_messages_to_session_db skipping one message per flush
- Add clear_interrupt() to 3 early-return paths preventing stale interrupt state
- Wrap handle_function_call in try/except so tool crashes don't kill the conversation
- Replace fragile `is` identity check with _flush_sentinel marker for memory flush cleanup
- Fix retry loop off-by-one (6 attempts not 7)
- Remove redundant inline `import re`
This commit is contained in:
teknium1 2026-02-27 03:21:49 -08:00
parent c104647450
commit c77f3da0ce
2 changed files with 69 additions and 37 deletions

View file

@ -34,12 +34,12 @@ python cli.py --gateway # Runs in foreground, useful for debugging
│ Hermes Gateway │ │ Hermes Gateway │
├─────────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────────┤
│ │ │ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ │ Telegram │ │ Discord │ │ WhatsApp │ │ │ Telegram │ │ Discord │ │ WhatsApp │ │ Slack │
│ │ Adapter │ │ Adapter │ │ Adapter │ │ │ Adapter │ │ Adapter │ │ Adapter │ │ Adapter │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │ │ │ │ │
└─────────────────┼─────────────────┘ │ └─────────────┼────────────┼─────────────
│ │ │ │ │ │
│ ┌────────▼────────┐ │ │ ┌────────▼────────┐ │
│ │ Session Store │ │ │ │ Session Store │ │
@ -134,29 +134,39 @@ pip install discord.py>=2.0
### WhatsApp ### WhatsApp
WhatsApp integration is more complex due to the lack of a simple bot API. WhatsApp uses a built-in bridge powered by [Baileys](https://github.com/WhiskeySockets/Baileys) that connects via WhatsApp Web. The agent links to your WhatsApp account and responds to incoming messages.
**Options:** **Setup:**
1. **WhatsApp Business API** (requires Meta verification)
2. **whatsapp-web.js** via Node.js bridge (for personal accounts)
**Bridge Setup:** ```bash
1. Install Node.js hermes whatsapp
2. Set up the bridge script (see `scripts/whatsapp-bridge/` for reference) ```
3. Configure in gateway:
```json This will:
{ - Enable WhatsApp in your `.env`
"platforms": { - Ask for your phone number (for the allowlist)
"whatsapp": { - Install bridge dependencies (Node.js required)
"enabled": true, - Display a QR code — scan it with your phone (WhatsApp → Settings → Linked Devices → Link a Device)
"extra": { - Exit automatically once paired
"bridge_script": "/path/to/bridge.js",
"bridge_port": 3000 Then start the gateway:
}
} ```bash
} hermes gateway
} ```
```
The gateway starts the WhatsApp bridge automatically using the saved session credentials in `~/.hermes/whatsapp/session/`.
**Environment variables:**
```bash
WHATSAPP_ENABLED=true
WHATSAPP_ALLOWED_USERS=15551234567 # Comma-separated phone numbers with country code
```
Agent responses are prefixed with "⚕ **Hermes Agent**" so you can distinguish them from your own messages when messaging yourself.
> **Re-pairing:** If WhatsApp Web sessions disconnect (protocol updates, phone reset), re-pair with `hermes whatsapp`.
## Configuration ## Configuration
@ -187,8 +197,17 @@ DISCORD_ALLOWED_USERS=123456789012345678 # Security: restrict to these user
DISCORD_HOME_CHANNEL=123456789012345678 DISCORD_HOME_CHANNEL=123456789012345678
DISCORD_HOME_CHANNEL_NAME="#bot-updates" DISCORD_HOME_CHANNEL_NAME="#bot-updates"
# WhatsApp - requires Node.js bridge setup # Slack - get from Slack API (api.slack.com/apps)
SLACK_BOT_TOKEN=xoxb-your-slack-bot-token
SLACK_APP_TOKEN=xapp-your-slack-app-token # Required for Socket Mode
SLACK_ALLOWED_USERS=U01234ABCDE # Security: restrict to these user IDs
# Optional: Default channel for cron job delivery
# SLACK_HOME_CHANNEL=C01234567890
# WhatsApp - pair via: hermes whatsapp
WHATSAPP_ENABLED=true WHATSAPP_ENABLED=true
WHATSAPP_ALLOWED_USERS=15551234567 # Phone numbers with country code
# ============================================================================= # =============================================================================
# AGENT SETTINGS # AGENT SETTINGS
@ -272,6 +291,7 @@ Each platform has its own toolset for security:
| Telegram | `hermes-telegram` | Full tools including terminal | | Telegram | `hermes-telegram` | Full tools including terminal |
| Discord | `hermes-discord` | Full tools including terminal | | Discord | `hermes-discord` | Full tools including terminal |
| WhatsApp | `hermes-whatsapp` | Full tools including terminal | | WhatsApp | `hermes-whatsapp` | Full tools including terminal |
| Slack | `hermes-slack` | Full tools including terminal |
## User Experience Features ## User Experience Features

View file

@ -596,7 +596,7 @@ class AIAgent:
if not self._session_db: if not self._session_db:
return return
try: try:
start_idx = (len(conversation_history) if conversation_history else 0) + 1 start_idx = len(conversation_history) if conversation_history else 0
for msg in messages[start_idx:]: for msg in messages[start_idx:]:
role = msg.get("role", "unknown") role = msg.get("role", "unknown")
content = msg.get("content") content = msg.get("content")
@ -943,8 +943,6 @@ class AIAgent:
if not content: if not content:
return content return content
content = convert_scratchpad_to_think(content) content = convert_scratchpad_to_think(content)
# Strip extra newlines before/after think blocks
import re
content = re.sub(r'\n+(<think>)', r'\n\1', content) content = re.sub(r'\n+(<think>)', r'\n\1', content)
content = re.sub(r'(</think>)\n+', r'\1\n', content) content = re.sub(r'(</think>)\n+', r'\1\n', content)
return content.strip() return content.strip()
@ -1305,7 +1303,8 @@ class AIAgent:
"[System: The session is being compressed. " "[System: The session is being compressed. "
"Please save anything worth remembering to your memories.]" "Please save anything worth remembering to your memories.]"
) )
flush_msg = {"role": "user", "content": flush_content} _sentinel = f"__flush_{id(self)}_{time.monotonic()}"
flush_msg = {"role": "user", "content": flush_content, "_flush_sentinel": _sentinel}
messages.append(flush_msg) messages.append(flush_msg)
try: try:
@ -1367,10 +1366,13 @@ class AIAgent:
except Exception as e: except Exception as e:
logger.debug("Memory flush API call failed: %s", e) logger.debug("Memory flush API call failed: %s", e)
finally: finally:
# Strip flush artifacts: remove everything from the flush message onward # Strip flush artifacts: remove everything from the flush message onward.
while messages and messages[-1] is not flush_msg and len(messages) > 0: # Use sentinel marker instead of identity check for robustness.
while messages and messages[-1].get("_flush_sentinel") != _sentinel:
messages.pop() messages.pop()
if messages and messages[-1] is flush_msg: if not messages:
break
if messages and messages[-1].get("_flush_sentinel") == _sentinel:
messages.pop() messages.pop()
def _compress_context(self, messages: list, system_message: str, *, approx_tokens: int = None) -> tuple: def _compress_context(self, messages: list, system_message: str, *, approx_tokens: int = None) -> tuple:
@ -1565,12 +1567,19 @@ class AIAgent:
try: try:
function_result = handle_function_call(function_name, function_args, effective_task_id) function_result = handle_function_call(function_name, function_args, effective_task_id)
_spinner_result = function_result _spinner_result = function_result
except Exception as tool_error:
function_result = f"Error executing tool '{function_name}': {tool_error}"
logger.error("handle_function_call raised for %s: %s", function_name, tool_error)
finally: finally:
tool_duration = time.time() - tool_start_time tool_duration = time.time() - tool_start_time
cute_msg = _get_cute_tool_message_impl(function_name, function_args, tool_duration, result=_spinner_result) cute_msg = _get_cute_tool_message_impl(function_name, function_args, tool_duration, result=_spinner_result)
spinner.stop(cute_msg) spinner.stop(cute_msg)
else: else:
function_result = handle_function_call(function_name, function_args, effective_task_id) try:
function_result = handle_function_call(function_name, function_args, effective_task_id)
except Exception as tool_error:
function_result = f"Error executing tool '{function_name}': {tool_error}"
logger.error("handle_function_call raised for %s: %s", function_name, tool_error)
tool_duration = time.time() - tool_start_time tool_duration = time.time() - tool_start_time
result_preview = function_result[:200] if len(function_result) > 200 else function_result result_preview = function_result[:200] if len(function_result) > 200 else function_result
@ -1877,7 +1886,7 @@ class AIAgent:
retry_count = 0 retry_count = 0
max_retries = 6 # Increased to allow longer backoff periods max_retries = 6 # Increased to allow longer backoff periods
while retry_count <= max_retries: while retry_count < max_retries:
try: try:
api_kwargs = self._build_api_kwargs(api_messages) api_kwargs = self._build_api_kwargs(api_messages)
@ -1971,6 +1980,7 @@ class AIAgent:
if self._interrupt_requested: if self._interrupt_requested:
print(f"{self.log_prefix}⚡ Interrupt detected during retry wait, aborting.") print(f"{self.log_prefix}⚡ Interrupt detected during retry wait, aborting.")
self._persist_session(messages, conversation_history) self._persist_session(messages, conversation_history)
self.clear_interrupt()
return { return {
"final_response": "Operation interrupted.", "final_response": "Operation interrupted.",
"messages": messages, "messages": messages,
@ -2073,6 +2083,7 @@ class AIAgent:
if self._interrupt_requested: if self._interrupt_requested:
print(f"{self.log_prefix}⚡ Interrupt detected during error handling, aborting retries.") print(f"{self.log_prefix}⚡ Interrupt detected during error handling, aborting retries.")
self._persist_session(messages, conversation_history) self._persist_session(messages, conversation_history)
self.clear_interrupt()
return { return {
"final_response": "Operation interrupted.", "final_response": "Operation interrupted.",
"messages": messages, "messages": messages,
@ -2160,6 +2171,7 @@ class AIAgent:
if self._interrupt_requested: if self._interrupt_requested:
print(f"{self.log_prefix}⚡ Interrupt detected during retry wait, aborting.") print(f"{self.log_prefix}⚡ Interrupt detected during retry wait, aborting.")
self._persist_session(messages, conversation_history) self._persist_session(messages, conversation_history)
self.clear_interrupt()
return { return {
"final_response": "Operation interrupted.", "final_response": "Operation interrupted.",
"messages": messages, "messages": messages,