| """``hermes debug`` — debug tools for Hermes Agent. |
| |
| Currently supports: |
| hermes debug share Upload debug report (system info + logs) to a |
| paste service and print a shareable URL. |
| """ |
|
|
| import io |
| import sys |
| import urllib.error |
| import urllib.parse |
| import urllib.request |
| from pathlib import Path |
| from typing import Optional |
|
|
| from hermes_constants import get_hermes_home |
|
|
|
|
| |
| |
| |
|
|
| _PASTE_RS_URL = "https://paste.rs/" |
| _DPASTE_COM_URL = "https://dpaste.com/api/" |
|
|
| |
| |
| _MAX_LOG_BYTES = 512_000 |
|
|
|
|
| def _upload_paste_rs(content: str) -> str: |
| """Upload to paste.rs. Returns the paste URL. |
| |
| paste.rs accepts a plain POST body and returns the URL directly. |
| """ |
| data = content.encode("utf-8") |
| req = urllib.request.Request( |
| _PASTE_RS_URL, data=data, method="POST", |
| headers={ |
| "Content-Type": "text/plain; charset=utf-8", |
| "User-Agent": "hermes-agent/debug-share", |
| }, |
| ) |
| with urllib.request.urlopen(req, timeout=30) as resp: |
| url = resp.read().decode("utf-8").strip() |
| if not url.startswith("http"): |
| raise ValueError(f"Unexpected response from paste.rs: {url[:200]}") |
| return url |
|
|
|
|
| def _upload_dpaste_com(content: str, expiry_days: int = 7) -> str: |
| """Upload to dpaste.com. Returns the paste URL. |
| |
| dpaste.com uses multipart form data. |
| """ |
| boundary = "----HermesDebugBoundary9f3c" |
|
|
| def _field(name: str, value: str) -> str: |
| return ( |
| f"--{boundary}\r\n" |
| f'Content-Disposition: form-data; name="{name}"\r\n' |
| f"\r\n" |
| f"{value}\r\n" |
| ) |
|
|
| body = ( |
| _field("content", content) |
| + _field("syntax", "text") |
| + _field("expiry_days", str(expiry_days)) |
| + f"--{boundary}--\r\n" |
| ).encode("utf-8") |
|
|
| req = urllib.request.Request( |
| _DPASTE_COM_URL, data=body, method="POST", |
| headers={ |
| "Content-Type": f"multipart/form-data; boundary={boundary}", |
| "User-Agent": "hermes-agent/debug-share", |
| }, |
| ) |
| with urllib.request.urlopen(req, timeout=30) as resp: |
| url = resp.read().decode("utf-8").strip() |
| if not url.startswith("http"): |
| raise ValueError(f"Unexpected response from dpaste.com: {url[:200]}") |
| return url |
|
|
|
|
| def upload_to_pastebin(content: str, expiry_days: int = 7) -> str: |
| """Upload *content* to a paste service, trying paste.rs then dpaste.com. |
| |
| Returns the paste URL on success, raises on total failure. |
| """ |
| errors: list[str] = [] |
|
|
| |
| try: |
| return _upload_paste_rs(content) |
| except Exception as exc: |
| errors.append(f"paste.rs: {exc}") |
|
|
| |
| try: |
| return _upload_dpaste_com(content, expiry_days=expiry_days) |
| except Exception as exc: |
| errors.append(f"dpaste.com: {exc}") |
|
|
| raise RuntimeError( |
| "Failed to upload to any paste service:\n " + "\n ".join(errors) |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def _resolve_log_path(log_name: str) -> Optional[Path]: |
| """Find the log file for *log_name*, falling back to the .1 rotation. |
| |
| Returns the path if found, or None. |
| """ |
| from hermes_cli.logs import LOG_FILES |
|
|
| filename = LOG_FILES.get(log_name) |
| if not filename: |
| return None |
|
|
| log_dir = get_hermes_home() / "logs" |
| primary = log_dir / filename |
| if primary.exists() and primary.stat().st_size > 0: |
| return primary |
|
|
| |
| rotated = log_dir / f"{filename}.1" |
| if rotated.exists() and rotated.stat().st_size > 0: |
| return rotated |
|
|
| return None |
|
|
|
|
| def _read_log_tail(log_name: str, num_lines: int) -> str: |
| """Read the last *num_lines* from a log file, or return a placeholder.""" |
| from hermes_cli.logs import _read_last_n_lines |
|
|
| log_path = _resolve_log_path(log_name) |
| if log_path is None: |
| return "(file not found)" |
|
|
| try: |
| lines = _read_last_n_lines(log_path, num_lines) |
| return "".join(lines).rstrip("\n") |
| except Exception as exc: |
| return f"(error reading: {exc})" |
|
|
|
|
| def _read_full_log(log_name: str, max_bytes: int = _MAX_LOG_BYTES) -> Optional[str]: |
| """Read a log file for standalone upload. |
| |
| Returns the file content (last *max_bytes* if truncated), or None if the |
| file doesn't exist or is empty. |
| """ |
| log_path = _resolve_log_path(log_name) |
| if log_path is None: |
| return None |
|
|
| try: |
| size = log_path.stat().st_size |
| if size == 0: |
| return None |
|
|
| if size <= max_bytes: |
| return log_path.read_text(encoding="utf-8", errors="replace") |
|
|
| |
| with open(log_path, "rb") as f: |
| f.seek(size - max_bytes) |
| |
| f.readline() |
| content = f.read().decode("utf-8", errors="replace") |
| return f"[... truncated — showing last ~{max_bytes // 1024}KB ...]\n{content}" |
| except Exception: |
| return None |
|
|
|
|
| |
| |
| |
|
|
| def _capture_dump() -> str: |
| """Run ``hermes dump`` and return its stdout as a string.""" |
| from hermes_cli.dump import run_dump |
|
|
| class _FakeArgs: |
| show_keys = False |
|
|
| old_stdout = sys.stdout |
| sys.stdout = capture = io.StringIO() |
| try: |
| run_dump(_FakeArgs()) |
| except SystemExit: |
| pass |
| finally: |
| sys.stdout = old_stdout |
|
|
| return capture.getvalue() |
|
|
|
|
| def collect_debug_report(*, log_lines: int = 200, dump_text: str = "") -> str: |
| """Build the summary debug report: system dump + log tails. |
| |
| Parameters |
| ---------- |
| log_lines |
| Number of recent lines to include per log file. |
| dump_text |
| Pre-captured dump output. If empty, ``hermes dump`` is run |
| internally. |
| |
| Returns the report as a plain-text string ready for upload. |
| """ |
| buf = io.StringIO() |
|
|
| if not dump_text: |
| dump_text = _capture_dump() |
| buf.write(dump_text) |
|
|
| |
| buf.write("\n\n") |
| buf.write(f"--- agent.log (last {log_lines} lines) ---\n") |
| buf.write(_read_log_tail("agent", log_lines)) |
| buf.write("\n\n") |
|
|
| errors_lines = min(log_lines, 100) |
| buf.write(f"--- errors.log (last {errors_lines} lines) ---\n") |
| buf.write(_read_log_tail("errors", errors_lines)) |
| buf.write("\n\n") |
|
|
| buf.write(f"--- gateway.log (last {errors_lines} lines) ---\n") |
| buf.write(_read_log_tail("gateway", errors_lines)) |
| buf.write("\n") |
|
|
| return buf.getvalue() |
|
|
|
|
| |
| |
| |
|
|
| def run_debug_share(args): |
| """Collect debug report + full logs, upload each, print URLs.""" |
| log_lines = getattr(args, "lines", 200) |
| expiry = getattr(args, "expire", 7) |
| local_only = getattr(args, "local", False) |
|
|
| print("Collecting debug report...") |
|
|
| |
| dump_text = _capture_dump() |
|
|
| report = collect_debug_report(log_lines=log_lines, dump_text=dump_text) |
| agent_log = _read_full_log("agent") |
| gateway_log = _read_full_log("gateway") |
|
|
| |
| if agent_log: |
| agent_log = dump_text + "\n\n--- full agent.log ---\n" + agent_log |
| if gateway_log: |
| gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log |
|
|
| if local_only: |
| print(report) |
| if agent_log: |
| print(f"\n\n{'=' * 60}") |
| print("FULL agent.log") |
| print(f"{'=' * 60}\n") |
| print(agent_log) |
| if gateway_log: |
| print(f"\n\n{'=' * 60}") |
| print("FULL gateway.log") |
| print(f"{'=' * 60}\n") |
| print(gateway_log) |
| return |
|
|
| print("Uploading...") |
| urls: dict[str, str] = {} |
| failures: list[str] = [] |
|
|
| |
| try: |
| urls["Report"] = upload_to_pastebin(report, expiry_days=expiry) |
| except RuntimeError as exc: |
| print(f"\nUpload failed: {exc}", file=sys.stderr) |
| print("\nFull report printed below — copy-paste it manually:\n") |
| print(report) |
| sys.exit(1) |
|
|
| |
| if agent_log: |
| try: |
| urls["agent.log"] = upload_to_pastebin(agent_log, expiry_days=expiry) |
| except Exception as exc: |
| failures.append(f"agent.log: {exc}") |
|
|
| |
| if gateway_log: |
| try: |
| urls["gateway.log"] = upload_to_pastebin(gateway_log, expiry_days=expiry) |
| except Exception as exc: |
| failures.append(f"gateway.log: {exc}") |
|
|
| |
| label_width = max(len(k) for k in urls) |
| print(f"\nDebug report uploaded:") |
| for label, url in urls.items(): |
| print(f" {label:<{label_width}} {url}") |
|
|
| if failures: |
| print(f"\n (failed to upload: {', '.join(failures)})") |
|
|
| print(f"\nShare these links with the Hermes team for support.") |
|
|
|
|
| def run_debug(args): |
| """Route debug subcommands.""" |
| subcmd = getattr(args, "debug_command", None) |
| if subcmd == "share": |
| run_debug_share(args) |
| else: |
| |
| print("Usage: hermes debug share [--lines N] [--expire N] [--local]") |
| print() |
| print("Commands:") |
| print(" share Upload debug report to a paste service and print URL") |
| print() |
| print("Options:") |
| print(" --lines N Number of log lines to include (default: 200)") |
| print(" --expire N Paste expiry in days (default: 7)") |
| print(" --local Print report locally instead of uploading") |
|
|