mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(gateway): live-stream /update output + interactive prompt buttons (#5180)
* feat(gateway): live-stream /update output + forward interactive prompts Adds real-time output streaming and interactive prompt forwarding for the gateway /update command, so users on Telegram/Discord/etc see the full update progress and can respond to prompts (stash restore, config migration) without needing terminal access. Changes: hermes_cli/main.py: - Add --gateway flag to 'hermes update' argparse - Add _gateway_prompt() file-based IPC function that writes .update_prompt.json and polls for .update_response - Modify _restore_stashed_changes() to accept optional input_fn parameter for gateway mode prompt forwarding - cmd_update() uses _gateway_prompt when --gateway is set, enabling interactive stash restore and config migration prompts gateway/run.py: - _handle_update_command: spawn with --gateway flag and PYTHONUNBUFFERED=1 for real-time output flushing - Store session_key in .update_pending.json for cross-restart session matching - Add _update_prompt_pending dict to track sessions awaiting update prompt responses - Replace _watch_for_update_completion with _watch_update_progress: streams output chunks every ~4s, detects .update_prompt.json and forwards prompts to the user, handles completion/failure/timeout - Add update prompt interception in _handle_message: when a prompt is pending, the user's next message is written to .update_response instead of being processed normally - Preserve _send_update_notification as legacy fallback for post-restart cases where adapter isn't available yet File-based IPC protocol: - .update_prompt.json: written by update process with prompt text, default value, and unique ID - .update_response: written by gateway with user's answer - .update_output.txt: existing, now streamed in real-time - .update_exit_code: existing completion marker Tests: 16 new tests covering _gateway_prompt IPC, output streaming, prompt detection/forwarding, message interception, and cleanup. * feat: interactive buttons for update prompts (Telegram + Discord) Telegram: Inline keyboard with ✓ Yes / ✗ No buttons. Clicking a button answers the callback query, edits the message to show the choice, and writes .update_response directly. CallbackQueryHandler registered on the update_prompt: prefix. Discord: UpdatePromptView (discord.ui.View) with green Yes / red No buttons. Follows the ExecApprovalView pattern — auth check, embed color update, disabled-after-click. Writes .update_response on click. All platforms: /approve and /deny (and /yes, /no) now work as shorthand for yes/no when an update prompt is pending. The text fallback message instructs users to use these commands. Raw message interception still works as a fallback for non-command responses. Gateway watcher checks adapter for send_update_prompt method (class-level check to avoid MagicMock false positives) and falls back to text prompt with /approve instructions when unavailable. * fix: block /update on non-messaging platforms (API, webhooks, ACP) Add _UPDATE_ALLOWED_PLATFORMS frozenset that explicitly lists messaging platforms where /update is permitted. API server, webhook, and ACP platforms get a clear error directing them to run hermes update from the terminal instead. ACP and API server already don't reach _handle_message (separate codepaths), and webhooks have distinct session keys that can't collide with messaging sessions. This guard is belt-and-suspenders.
This commit is contained in:
parent
441ec48802
commit
0c54da8aaf
6 changed files with 996 additions and 18 deletions
|
|
@ -2554,6 +2554,57 @@ def _clear_bytecode_cache(root: Path) -> int:
|
|||
return removed
|
||||
|
||||
|
||||
def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0) -> str:
|
||||
"""File-based IPC prompt for gateway mode.
|
||||
|
||||
Writes a prompt marker file so the gateway can forward the question to the
|
||||
user, then polls for a response file. Falls back to *default* on timeout.
|
||||
|
||||
Used by ``hermes update --gateway`` so interactive prompts (stash restore,
|
||||
config migration) are forwarded to the messenger instead of being silently
|
||||
skipped.
|
||||
"""
|
||||
import json as _json
|
||||
import uuid as _uuid
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
home = get_hermes_home()
|
||||
prompt_path = home / ".update_prompt.json"
|
||||
response_path = home / ".update_response"
|
||||
|
||||
# Clean any stale response file
|
||||
response_path.unlink(missing_ok=True)
|
||||
|
||||
payload = {
|
||||
"prompt": prompt_text,
|
||||
"default": default,
|
||||
"id": str(_uuid.uuid4()),
|
||||
}
|
||||
tmp = prompt_path.with_suffix(".tmp")
|
||||
tmp.write_text(_json.dumps(payload))
|
||||
tmp.replace(prompt_path)
|
||||
|
||||
# Poll for response
|
||||
import time as _time
|
||||
deadline = _time.monotonic() + timeout
|
||||
while _time.monotonic() < deadline:
|
||||
if response_path.exists():
|
||||
try:
|
||||
answer = response_path.read_text().strip()
|
||||
response_path.unlink(missing_ok=True)
|
||||
prompt_path.unlink(missing_ok=True)
|
||||
return answer if answer else default
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
_time.sleep(0.5)
|
||||
|
||||
# Timeout — clean up and use default
|
||||
prompt_path.unlink(missing_ok=True)
|
||||
response_path.unlink(missing_ok=True)
|
||||
print(f" (no response after {int(timeout)}s, using default: {default!r})")
|
||||
return default
|
||||
|
||||
|
||||
def _update_via_zip(args):
|
||||
"""Update Hermes Agent by downloading a ZIP archive.
|
||||
|
||||
|
|
@ -2747,6 +2798,7 @@ def _restore_stashed_changes(
|
|||
cwd: Path,
|
||||
stash_ref: str,
|
||||
prompt_user: bool = False,
|
||||
input_fn=None,
|
||||
) -> bool:
|
||||
if prompt_user:
|
||||
print()
|
||||
|
|
@ -2754,7 +2806,10 @@ def _restore_stashed_changes(
|
|||
print(" Restoring them may reapply local customizations onto the updated codebase.")
|
||||
print(" Review the result afterward if Hermes behaves unexpectedly.")
|
||||
print("Restore local changes now? [Y/n]")
|
||||
response = input().strip().lower()
|
||||
if input_fn is not None:
|
||||
response = input_fn("Restore local changes now? [Y/n]", "y")
|
||||
else:
|
||||
response = input().strip().lower()
|
||||
if response not in ("", "y", "yes"):
|
||||
print("Skipped restoring local changes.")
|
||||
print("Your changes are still preserved in git stash.")
|
||||
|
|
@ -3185,6 +3240,10 @@ def cmd_update(args):
|
|||
if is_managed():
|
||||
managed_error("update Hermes Agent")
|
||||
return
|
||||
|
||||
gateway_mode = getattr(args, "gateway", False)
|
||||
# In gateway mode, use file-based IPC for prompts instead of stdin
|
||||
gw_input_fn = (lambda prompt, default="": _gateway_prompt(prompt, default)) if gateway_mode else None
|
||||
|
||||
print("⚕ Updating Hermes Agent...")
|
||||
print()
|
||||
|
|
@ -3281,7 +3340,9 @@ def cmd_update(args):
|
|||
else:
|
||||
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
|
||||
|
||||
prompt_for_restore = auto_stash_ref is not None and sys.stdin.isatty() and sys.stdout.isatty()
|
||||
prompt_for_restore = auto_stash_ref is not None and (
|
||||
gateway_mode or (sys.stdin.isatty() and sys.stdout.isatty())
|
||||
)
|
||||
|
||||
# Check if there are updates
|
||||
result = subprocess.run(
|
||||
|
|
@ -3300,6 +3361,7 @@ def cmd_update(args):
|
|||
_restore_stashed_changes(
|
||||
git_cmd, PROJECT_ROOT, auto_stash_ref,
|
||||
prompt_user=prompt_for_restore,
|
||||
input_fn=gw_input_fn,
|
||||
)
|
||||
if current_branch not in ("main", "HEAD"):
|
||||
subprocess.run(
|
||||
|
|
@ -3351,6 +3413,7 @@ def cmd_update(args):
|
|||
PROJECT_ROOT,
|
||||
auto_stash_ref,
|
||||
prompt_user=prompt_for_restore,
|
||||
input_fn=gw_input_fn,
|
||||
)
|
||||
|
||||
_invalidate_update_cache()
|
||||
|
|
@ -3490,7 +3553,11 @@ def cmd_update(args):
|
|||
print(f" ℹ️ {len(missing_config)} new config option(s) available")
|
||||
|
||||
print()
|
||||
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
||||
if gateway_mode:
|
||||
response = _gateway_prompt(
|
||||
"Would you like to configure new options now? [Y/n]", "n"
|
||||
).strip().lower()
|
||||
elif not (sys.stdin.isatty() and sys.stdout.isatty()):
|
||||
print(" ℹ Non-interactive session — skipping config migration prompt.")
|
||||
print(" Run 'hermes config migrate' later to apply any new config/env options.")
|
||||
response = "n"
|
||||
|
|
@ -3502,11 +3569,15 @@ def cmd_update(args):
|
|||
|
||||
if response in ('', 'y', 'yes'):
|
||||
print()
|
||||
results = migrate_config(interactive=True, quiet=False)
|
||||
# In gateway mode, run auto-migrations only (no input() prompts
|
||||
# for API keys which would hang the detached process).
|
||||
results = migrate_config(interactive=not gateway_mode, quiet=False)
|
||||
|
||||
if results["env_added"] or results["config_added"]:
|
||||
print()
|
||||
print("✓ Configuration updated!")
|
||||
if gateway_mode and missing_env:
|
||||
print(" ℹ API keys require manual entry: hermes config migrate")
|
||||
else:
|
||||
print()
|
||||
print("Skipped. Run 'hermes config migrate' later to configure.")
|
||||
|
|
@ -5247,6 +5318,10 @@ For more help on a command:
|
|||
help="Update Hermes Agent to the latest version",
|
||||
description="Pull the latest changes from git and reinstall dependencies"
|
||||
)
|
||||
update_parser.add_argument(
|
||||
"--gateway", action="store_true", default=False,
|
||||
help="Gateway mode: use file-based IPC for prompts instead of stdin (used internally by /update)"
|
||||
)
|
||||
update_parser.set_defaults(func=cmd_update)
|
||||
|
||||
# =========================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue