# Conflicts: # AGENTS.md # acp_adapter/edit_approval.py # acp_adapter/server.py # agent/agent_init.py # agent/anthropic_adapter.py # agent/anthropic_credentials.py # agent/auxiliary_client.py # agent/azure_identity_adapter.py # agent/bedrock_adapter.py # agent/browser_registry.py # agent/chat_completion_helpers.py # agent/coding_context.py # agent/context_references.py # agent/conversation_loop.py # agent/copilot_acp_client.py # agent/credits_tracker.py # agent/curator.py # agent/curator_backup.py # agent/deadline.py # agent/display.py # agent/errors.py # agent/estop.py # agent/i18n.py # agent/image_gen_registry.py # agent/image_routing.py # agent/learning_graph.py # agent/learning_mutations.py # agent/lsp/servers.py # agent/model_metadata.py # agent/models_dev.py # agent/monitoring/gateway_health_export.py # agent/monitoring/otlp_exporter.py # agent/pet/store.py # agent/process_bootstrap.py # agent/prompt_builder.py # agent/proxy_sources/iron_proxy.py # agent/secret_sources/_cache.py # agent/secret_sources/bitwarden.py # agent/secret_sources/registry.py # agent/shell_hooks.py # agent/skill_bundles.py # agent/skill_commands.py # agent/skill_utils.py # agent/ssl_guard.py # agent/ssl_verify.py # agent/system_prompt.py # agent/terminal_env_registry.py # agent/trace_upload.py # agent/transcription_registry.py # agent/tts_registry.py # agent/verify/environment.py # agent/vertex_adapter.py # agent/video_gen_registry.py # agent/web_search_registry.py # cli.py # cron/jobs.py # cron/scheduler.py # gateway/agent_cache_pressure.py # gateway/cgroup_cleanup.py # gateway/channel_directory.py # gateway/config.py # gateway/control_socket.py # gateway/dead_targets.py # gateway/drain_control.py # gateway/hooks.py # gateway/kanban_watchers.py # gateway/lifecycle_ledger.py # gateway/mirror.py # gateway/pairing.py # gateway/platform_registry.py # gateway/platforms/helpers.py # gateway/platforms/weixin.py # gateway/readiness.py # gateway/restart_loop_guard.py # gateway/rich_sent_store.py # gateway/run.py # gateway/session.py # gateway/shutdown_flush.py # gateway/shutdown_forensics.py # gateway/slash_commands.py # gateway/status.py # gateway/sticker_cache.py # gateway/whatsapp_identity.py # hermes_bootstrap.py # hermes_cli/_early_recovery.py # hermes_cli/_install_repair.py # hermes_cli/_startup_fast.py # hermes_cli/_subprocess_compat.py # hermes_cli/agent_plugins.py # hermes_cli/auth.py # hermes_cli/backup.py # hermes_cli/banner.py # hermes_cli/browser_connect.py # hermes_cli/build_info.py # hermes_cli/cli_agent_setup_mixin.py # hermes_cli/cli_commands_mixin.py # hermes_cli/codex_models.py # hermes_cli/config.py # hermes_cli/config_defaults.py # hermes_cli/config_migrations.py # hermes_cli/container_boot.py # hermes_cli/dashboard_auth/registry.py # hermes_cli/debug.py # hermes_cli/dep_ensure.py # hermes_cli/doctor.py # hermes_cli/doctor_live.py # hermes_cli/dump.py # hermes_cli/env_loader.py # hermes_cli/foreign_sessions.py # hermes_cli/gateway.py # hermes_cli/gateway_windows.py # hermes_cli/gui_uninstall.py # hermes_cli/image_provenance.py # hermes_cli/install_identity.py # hermes_cli/kanban.py # hermes_cli/kanban_db.py # hermes_cli/linux_desktop_entry.py # hermes_cli/local_runtime/binaries.py # hermes_cli/local_runtime/endpoint.py # hermes_cli/local_runtime/growth.py # hermes_cli/local_runtime/supervisor.py # hermes_cli/logs.py # hermes_cli/macos_tcc_anchor.py # hermes_cli/main.py # hermes_cli/memory_setup.py # hermes_cli/model_catalog.py # hermes_cli/models.py # hermes_cli/nous_subscription.py # hermes_cli/npm_engine.py # hermes_cli/plugin_index.py # hermes_cli/plugins.py # hermes_cli/plugins_cmd.py # hermes_cli/profile_distribution.py # hermes_cli/profiles.py # hermes_cli/prompt_size.py # hermes_cli/psutil_android.py # hermes_cli/runtime_repair.py # hermes_cli/security_advisories.py # hermes_cli/security_audit.py # hermes_cli/security_audit_startup.py # hermes_cli/service_manager.py # hermes_cli/session_export_md.py # hermes_cli/setup.py # hermes_cli/skills_hub.py # hermes_cli/slack_cli.py # hermes_cli/status.py # hermes_cli/subcommands/gateway.py # hermes_cli/subcommands/uninstall.py # hermes_cli/tools_config.py # hermes_cli/uninstall.py # hermes_cli/update_cmd.py # hermes_cli/update_contract.py # hermes_cli/update_inventory.py # hermes_cli/update_lock.py # hermes_cli/update_receipt.py # hermes_cli/urllib_security.py # hermes_cli/web_routers/local_models.py # hermes_cli/web_routers/profiles.py # hermes_cli/web_routers/skills.py # hermes_cli/web_server.py # hermes_constants.py # hermes_state.py # plugins/disk-cleanup/__init__.py # plugins/disk-cleanup/disk_cleanup.py # plugins/google_meet/node/registry.py # plugins/google_meet/node/server.py # plugins/google_meet/process_manager.py # plugins/google_meet/realtime/openai_client.py # plugins/hermes-achievements/dashboard/plugin_api.py # plugins/memory/hindsight/__init__.py # plugins/memory/honcho/__init__.py # plugins/memory/honcho/cli.py # plugins/memory/honcho/client.py # plugins/memory/honcho/oauth.py # plugins/memory/honcho/session.py # plugins/memory/mem0/__init__.py # plugins/memory/mem0/_setup.py # plugins/memory/openviking/__init__.py # plugins/memory/retaindb/__init__.py # plugins/memory/supermemory/__init__.py # plugins/platforms/a2a/protocol.py # plugins/platforms/dingtalk/adapter.py # plugins/platforms/discord/adapter.py # plugins/platforms/feishu/adapter.py # plugins/platforms/google_chat/adapter.py # plugins/platforms/matrix/adapter.py # plugins/platforms/photon/adapter.py # plugins/platforms/photon/auth.py # plugins/platforms/photon/cli.py # plugins/platforms/slack/adapter.py # plugins/platforms/teams/adapter.py # plugins/platforms/telegram/adapter.py # plugins/platforms/wecom/callback_adapter.py # plugins/platforms/whatsapp/adapter.py # plugins/teams_pipeline/store.py # plugins/video_gen/fal/__init__.py # plugins/web/ddgs/provider.py # plugins/web/exa/provider.py # plugins/web/firecrawl/provider.py # plugins/web/parallel/provider.py # tests/agent/test_ssl_ca_guard.py # tests/hermes_cli/test_certifi_repair.py # tests/hermes_cli/test_cmd_update.py # tests/hermes_cli/test_cmd_update_apt.py # tests/hermes_cli/test_dashboard_unified_launch.py # tests/hermes_cli/test_dep_ensure.py # tests/hermes_cli/test_doctor.py # tests/hermes_cli/test_doctor_live.py # tests/hermes_cli/test_gui_command.py # tests/hermes_cli/test_kanban_boards.py # tests/hermes_cli/test_kanban_db.py # tests/hermes_cli/test_lazy_refresh_venv_repair.py # tests/hermes_cli/test_memory_setup_provider_arg.py # tests/hermes_cli/test_nous_subscription.py # tests/hermes_cli/test_pip_install_detection.py # tests/hermes_cli/test_profile_export_credentials.py # tests/hermes_cli/test_psutil_android_extract.py # tests/hermes_cli/test_status.py # tests/hermes_cli/test_tui_npm_install.py # tests/hermes_cli/test_update_fleet_restart_pending.py # tests/hermes_cli/test_update_head_moved_gate.py # tests/hermes_cli/test_update_interrupted_recovery.py # tests/hermes_cli/test_web_server.py # tests/hermes_cli/test_web_ui_build.py # tests/test_hermes_logging.py # tests/test_managed_runtime_resolution.py # tests/tools/test_browser_chromium_autoinstall.py # tests/tools/test_browser_chromium_check.py # tests/tools/test_browser_homebrew_paths.py # tests/tools/test_browser_lightpanda.py # tests/tools/test_browser_npx_warmup.py # tests/tools/test_browser_open_timeout.py # tests/tools/test_browser_orphan_reaper.py # tests/tools/test_browser_real_profile.py # tests/tools/test_browser_suspect_recycle.py # tests/tools/test_find_shell.py # tests/tools/test_local_env_blocklist.py # tests/tools/test_macos_protected_search.py # tests/tui_gateway/test_compute_host.py # tools/approval.py # tools/blueprints.py # tools/bot_mode_dm.py # tools/bot_mode_probe.py # tools/bot_relay.py # tools/browser_tool.py # tools/browser_use_cli.py # tools/checkpoint_manager.py # tools/code_execution_tool.py # tools/code_kernel.py # tools/computer_use/cua_backend.py # tools/cronjob_tools.py # tools/discord_tool.py # tools/environments/base.py # tools/environments/daytona.py # tools/environments/local.py # tools/environments/modal.py # tools/environments/vercel_sandbox.py # tools/fal_common.py # tools/file_operations.py # tools/lazy_deps.py # tools/mcp_tool.py # tools/neutts_synth.py # tools/process_registry.py # tools/read_extract.py # tools/registry.py # tools/skill_ledger.py # tools/skill_linter.py # tools/skill_manager_tool.py # tools/skill_usage.py # tools/skills_ast_audit.py # tools/skills_guard.py # tools/skills_hub.py # tools/skills_sync.py # tools/skills_sync_client.py # tools/skills_tool.py # tools/terminal_scope.py # tools/terminal_tool.py # tools/tirith_security.py # tools/transcription_tools.py # tools/tts_tool.py # tools/vision_tools.py # tools/voice_mode.py # tools/wake_word.py # tools/web_result_cache.py # tools/website_policy.py # tools/working_diff.py # tools/write_approval.py # tui_gateway/entry.py # tui_gateway/methods_tools.py # tui_gateway/server.py
265 lines
12 KiB
Python
265 lines
12 KiB
Python
"""Upload a Hermes session transcript to Hugging Face as an agent trace, re-emitted in the **Claude Code
|
|
JSONL** shape the HF Agent Trace Viewer auto-detects (https://huggingface.co/docs/hub/agent-traces).
|
|
Deterministic, zero LLM turns. Private by default: traces can carry prompts, tool output, local paths and
|
|
secrets, so the dataset is created private and every text body passes the secret redactor (``force=True``)
|
|
unless ``redact=False``. :func:`upload_session_trace` never raises (returns a user-facing status string);
|
|
programmatic callers use :func:`build_trace_jsonl` + :func:`_do_upload`."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import uuid
|
|
from contextlib import suppress
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_DATASET_NAME = "hermes-traces"
|
|
_HERMES_VERSION = "hermes-agent"
|
|
_REDACTION_BLOCKED_MESSAGE = (
|
|
"Trace upload blocked: secret redaction failed, so the transcript may "
|
|
"still contain credentials or other sensitive data. Fix the redactor or "
|
|
"rerun with --no-redact only after manually reviewing the transcript."
|
|
)
|
|
_NO_TOKEN_MESSAGE = (
|
|
"Can't upload — no Hugging Face token is available. To set it up:\n"
|
|
"\n"
|
|
"1. Create a token with WRITE access at https://huggingface.co/settings/tokens\n"
|
|
" (New token -> type \"Write\" -> copy it).\n"
|
|
"2. Add it to your environment as HF_TOKEN (e.g. in ~/.hermes/.env):\n"
|
|
" HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxx\n"
|
|
"3. Run /upload-trace again (or `hermes trace upload`)."
|
|
)
|
|
_TOKEN_ENV_VARS = ("HF_TOKEN", "HUGGINGFACE_HUB_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HUGGINGFACE_TOKEN")
|
|
|
|
|
|
class TraceRedactionError(RuntimeError):
|
|
"""Raised when a trace cannot be safely redacted before upload."""
|
|
|
|
|
|
# --- Conversion: Hermes OpenAI-format messages -> Claude Code JSONL ---
|
|
|
|
def _now_iso() -> str:
|
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
|
|
|
|
def _redact(text: Any, enabled: bool) -> Any:
|
|
"""Redact a string body when enabled (``force=True``: an upload scrubs even if log redaction is off)."""
|
|
if not enabled or not isinstance(text, str) or not text:
|
|
return text
|
|
try:
|
|
from agent.redact import redact_sensitive_text
|
|
return redact_sensitive_text(text, force=True)
|
|
except Exception as exc:
|
|
logger.warning("Trace upload redaction failed; refusing upload", exc_info=True)
|
|
raise TraceRedactionError(_REDACTION_BLOCKED_MESSAGE) from exc
|
|
|
|
|
|
def _text_block(text: Any, redact: bool) -> Dict[str, Any]:
|
|
return {"type": "text", "text": _redact(text, redact)}
|
|
|
|
|
|
def _part_to_block(part: Any, redact: bool) -> Dict[str, Any]:
|
|
if not isinstance(part, dict):
|
|
return _text_block(str(part), redact)
|
|
if part.get("type") == "text":
|
|
return _text_block(part.get("text", ""), redact)
|
|
if part.get("type") in ("image_url", "image"):
|
|
return {"type": "text", "text": "[image omitted]"} # the viewer renders text turns; no base64
|
|
return _text_block(json.dumps(part), redact)
|
|
|
|
|
|
def _content_to_blocks(content: Any, redact: bool) -> List[Dict[str, Any]]:
|
|
"""Normalize a message ``content`` field into Anthropic content blocks."""
|
|
if isinstance(content, list):
|
|
return [_part_to_block(part, redact) for part in content]
|
|
return [] if content is None else [_text_block(content if isinstance(content, str) else json.dumps(content), redact)]
|
|
|
|
|
|
def _parse_tool_args(raw_args: Any) -> Dict[str, Any]:
|
|
if not isinstance(raw_args, str):
|
|
return raw_args if isinstance(raw_args, dict) else {}
|
|
try:
|
|
return json.loads(raw_args) if raw_args.strip() else {}
|
|
except (json.JSONDecodeError, ValueError):
|
|
return {"_raw": raw_args}
|
|
|
|
|
|
def _tool_calls_to_blocks(tool_calls: Any, redact: bool) -> List[Dict[str, Any]]:
|
|
"""Convert OpenAI tool_calls into Anthropic ``tool_use`` content blocks."""
|
|
blocks: List[Dict[str, Any]] = []
|
|
for tc in tool_calls if isinstance(tool_calls, list) else ():
|
|
if not isinstance(tc, dict):
|
|
continue
|
|
fn = tc.get("function") or {}
|
|
parsed = _parse_tool_args(fn.get("arguments"))
|
|
if redact:
|
|
try:
|
|
parsed = json.loads(_redact(json.dumps(parsed), redact))
|
|
except (json.JSONDecodeError, ValueError):
|
|
logger.warning("Trace upload redacted tool arguments are not valid JSON; refusing upload")
|
|
raise TraceRedactionError(_REDACTION_BLOCKED_MESSAGE)
|
|
blocks.append({"type": "tool_use", "id": tc.get("id") or f"toolu_{uuid.uuid4().hex[:16]}",
|
|
"name": fn.get("name") or tc.get("name") or "tool", "input": parsed})
|
|
return blocks
|
|
|
|
|
|
def _git_branch(cwd: str) -> str:
|
|
if not cwd:
|
|
return ""
|
|
try:
|
|
import subprocess
|
|
r = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=3, cwd=cwd,
|
|
stdin=subprocess.DEVNULL)
|
|
except Exception:
|
|
return ""
|
|
return r.stdout.strip() if r.returncode == 0 else ""
|
|
|
|
|
|
def _assistant_message(msg: Dict[str, Any], model: str, redact: bool) -> Dict[str, Any]:
|
|
blocks = _content_to_blocks(msg.get("content"), redact) + _tool_calls_to_blocks(msg.get("tool_calls"), redact)
|
|
return {"role": "assistant", "model": model or "unknown", "content": blocks or [{"type": "text", "text": ""}]}
|
|
|
|
|
|
def _tool_result_message(msg: Dict[str, Any], model: str, redact: bool) -> Dict[str, Any]:
|
|
content = msg.get("content")
|
|
return {"role": "user", "content": [{
|
|
"type": "tool_result", "tool_use_id": msg.get("tool_call_id") or msg.get("tool_name") or "tool",
|
|
"content": _redact(content if isinstance(content, str) else json.dumps(content), redact),
|
|
}]}
|
|
|
|
|
|
def _user_message(msg: Dict[str, Any], model: str, redact: bool) -> Dict[str, Any]:
|
|
content = msg.get("content")
|
|
return {"role": "user", "content": _redact(content, redact) if isinstance(content, str) else _content_to_blocks(content, redact)}
|
|
|
|
|
|
# role -> (Claude Code line type, message builder). Unknown roles render as user.
|
|
_ROLE_RENDERERS: Dict[Any, Tuple[str, Any]] = {"assistant": ("assistant", _assistant_message), "tool": ("user", _tool_result_message)}
|
|
|
|
|
|
def build_trace_jsonl(messages: List[Dict[str, Any]], *, session_id: str, model: str = "", cwd: str = "", redact: bool = True) -> str:
|
|
"""One JSONL line per non-system message: ``user``/``tool`` -> type user (tool results ride on user turns as
|
|
``tool_result`` keyed by ``tool_call_id``), ``assistant`` -> text + ``tool_use`` blocks; turns link via ``parentUuid``."""
|
|
lines: List[str] = []
|
|
parent: Optional[str] = None
|
|
base_ts = _now_iso()
|
|
git_branch = _git_branch(cwd)
|
|
for msg in messages:
|
|
role = msg.get("role")
|
|
if role == "system":
|
|
continue
|
|
turn_uuid = str(uuid.uuid4())
|
|
line_type, render = _ROLE_RENDERERS.get(role, ("user", _user_message))
|
|
entry = { # key order is the wire order
|
|
"parentUuid": parent, "isSidechain": False, "userType": "external", "cwd": cwd or os.getcwd(),
|
|
"sessionId": session_id, "version": _HERMES_VERSION, "gitBranch": git_branch, "uuid": turn_uuid,
|
|
"timestamp": base_ts, "type": line_type, "message": render(msg, model, redact),
|
|
}
|
|
lines.append(json.dumps(entry, ensure_ascii=False))
|
|
parent = turn_uuid
|
|
return "\n".join(lines) + ("\n" if lines else "")
|
|
|
|
|
|
# --- Upload ---
|
|
|
|
def _resolve_hf_token() -> Optional[str]:
|
|
"""Return the user's Hugging Face token from the usual env vars."""
|
|
return next((val for var in _TOKEN_ENV_VARS if (val := (os.getenv(var) or "").strip())), None)
|
|
|
|
|
|
|
|
def _do_upload(
|
|
jsonl: str,
|
|
*,
|
|
token: str,
|
|
session_id: str,
|
|
dataset_name: str = DEFAULT_DATASET_NAME,
|
|
private: bool = True,
|
|
) -> str:
|
|
"""Create (idempotently) the private dataset and push the trace file.
|
|
|
|
Returns a user-facing status string. Never raises.
|
|
"""
|
|
try:
|
|
import pm
|
|
pm.ensure_import("trace-upload")
|
|
except Exception:
|
|
# lazy-install unavailable — fall through to the import, which
|
|
# surfaces the install hint below if the package is missing.
|
|
pass
|
|
try:
|
|
from huggingface_hub import HfApi
|
|
except ImportError:
|
|
return "Hugging Face upload needs the `huggingface_hub` package (`pip install huggingface_hub`)."
|
|
api = HfApi(token=token)
|
|
try:
|
|
who = api.whoami()
|
|
except Exception as e:
|
|
logger.warning("HF whoami failed: %s", e)
|
|
return "Your Hugging Face token was rejected (whoami failed). Make sure it has WRITE access and isn't expired."
|
|
user = who.get("name") if isinstance(who, dict) else None
|
|
if not user:
|
|
return "Could not resolve your Hugging Face username from the token."
|
|
repo_id = f"{user}/{dataset_name}"
|
|
try:
|
|
api.create_repo(repo_id=repo_id, repo_type="dataset", private=private, exist_ok=True)
|
|
except Exception as e:
|
|
logger.warning("HF create_repo failed for %s: %s", repo_id, e)
|
|
return f"Could not create/access dataset {repo_id}: {e}"
|
|
path_in_repo = f"sessions/{session_id}.jsonl"
|
|
try:
|
|
api.upload_file(path_or_fileobj=jsonl.encode("utf-8"), path_in_repo=path_in_repo, repo_id=repo_id,
|
|
repo_type="dataset", commit_message=f"add session trace {session_id}")
|
|
except Exception as e:
|
|
logger.warning("HF upload_file failed for %s: %s", repo_id, e)
|
|
return f"Upload to Hugging Face failed: {e}"
|
|
return (f"Uploaded -> https://huggingface.co/datasets/{repo_id}/blob/main/{path_in_repo}\n"
|
|
f"View in the trace viewer: https://huggingface.co/datasets/{repo_id}")
|
|
|
|
|
|
def load_session_messages(session_id: str, db_path=None) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
|
|
"""``(messages, meta)`` from SQLite; ``meta`` is ``{}`` when the session row is missing (a live, untitled
|
|
session may still have messages)."""
|
|
from hermes_state import SessionDB
|
|
db = SessionDB(db_path=db_path) if db_path else SessionDB()
|
|
try:
|
|
resolved = db.resolve_session_id(session_id) or session_id
|
|
meta = db.get_session(resolved) or {}
|
|
return db.get_messages_as_conversation(resolved), meta
|
|
finally:
|
|
try:
|
|
db.close()
|
|
except Exception:
|
|
logger.debug("Failed to close trace-upload SessionDB", exc_info=True)
|
|
|
|
|
|
def upload_session_trace(
|
|
session_id: str, *, model: str = "", cwd: str = "", redact: bool = True, private: bool = True,
|
|
dataset_name: str = DEFAULT_DATASET_NAME, db_path=None, token: Optional[str] = None,
|
|
) -> str:
|
|
"""CLI/gateway entry point: load, convert, upload to ``{user}/hermes-traces``. Status string, never raises."""
|
|
if not session_id:
|
|
return "No active session to upload."
|
|
token = token or _resolve_hf_token()
|
|
if not token:
|
|
return _NO_TOKEN_MESSAGE
|
|
try:
|
|
messages, meta = load_session_messages(session_id, db_path=db_path)
|
|
except Exception as e:
|
|
logger.warning("Failed to load session %s for trace upload: %s", session_id, e)
|
|
return f"Could not load session {session_id}: {e}"
|
|
if not messages:
|
|
return "No transcript to upload for this session yet."
|
|
try:
|
|
jsonl = build_trace_jsonl(messages, session_id=session_id, model=model or meta.get("model") or "", cwd=cwd, redact=redact)
|
|
except TraceRedactionError:
|
|
return _REDACTION_BLOCKED_MESSAGE
|
|
if not jsonl.strip():
|
|
return "No transcript content to upload for this session."
|
|
return _do_upload(jsonl, token=token, session_id=session_id, dataset_name=dataset_name, private=private)
|