diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 6b7bf1966896..382e14403d5d 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1703,6 +1703,25 @@ def _finalize(resolved_provider: str, sync_client: Any, default_model: Optional[ ) return _finalize( main_provider, rpc_client, rpc_model or vision_model) + # Exotic provider (DeepSeek, Alibaba, named custom, etc.) + # Skip providers that don't use OpenAI-compatible endpoints + # (e.g. Bedrock uses Converse API via boto3, not OpenAI SDK). + if main_provider in ("bedrock",): + logger.debug( + "Vision auto-detect: skipping non-OpenAI provider %s, " + "falling back to aggregators", + main_provider, + ) + else: + rpc_client, rpc_model = resolve_provider_client( + main_provider, main_model) + if rpc_client is not None: + logger.info( + "Vision auto-detect: using active provider %s (%s)", + main_provider, rpc_model or main_model, + ) + return _finalize( + main_provider, rpc_client, rpc_model or main_model) # Fall back through aggregators. for candidate in _VISION_AUTO_PROVIDER_ORDER: diff --git a/agent/bedrock_adapter.py b/agent/bedrock_adapter.py new file mode 100644 index 000000000000..12b18b54fb99 --- /dev/null +++ b/agent/bedrock_adapter.py @@ -0,0 +1,432 @@ +""" +Amazon Bedrock Converse API adapter for Hermes Agent. + +Translates between Hermes' internal OpenAI-format messages and the +Bedrock Converse API, using boto3 with Bearer token authentication +(``AWS_BEARER_TOKEN_BEDROCK``). + +Architecture: + - ``build_bedrock_client()`` → boto3 bedrock-runtime client + - ``build_converse_kwargs()`` → Converse API request dict + - ``normalize_converse_response()``→ SimpleNamespace matching AIAgent expectations + - ``convert_tools_to_converse()`` → tool schema translation +""" + +from __future__ import annotations + +import json +import logging +import os +from types import SimpleNamespace +from typing import Any, Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Client construction +# --------------------------------------------------------------------------- + +def build_bedrock_client(region: str = "", base_url: str = ""): + """Build a boto3 bedrock-runtime client. + + Authentication uses ``AWS_BEARER_TOKEN_BEDROCK`` (Bearer token) which + boto3 picks up automatically. Falls back to standard AWS credential + chain (access key, instance role, etc.) if the env var is unset. + + Args: + region: AWS region (default from ``AWS_BEDROCK_REGION`` or ``us-east-1``). + base_url: Full endpoint URL override (extracted from resolved base_url). + """ + try: + import boto3 + except ImportError: + raise ImportError( + "boto3 is required for Amazon Bedrock support. " + "Install it with: pip install boto3" + ) + + region = ( + region + or os.getenv("AWS_BEDROCK_REGION", "").strip() + ) + if not region: + try: + from hermes_cli.config import get_env_value + region = (get_env_value("AWS_BEDROCK_REGION") or "").strip() + except Exception: + pass + if not region: + region = "us-east-1" + + kwargs: dict[str, Any] = { + "service_name": "bedrock-runtime", + "region_name": region, + } + + # If user provided a full endpoint, extract region from it and use + # the root domain as endpoint_url (boto3 expects the bare endpoint, + # not /openai/v1). + if base_url: + # base_url is like https://bedrock-runtime.eu-central-1.amazonaws.com/openai/v1 + # boto3 needs https://bedrock-runtime.eu-central-1.amazonaws.com + import re + match = re.search(r"(https://bedrock-runtime\.[^/]+)", base_url) + if match: + kwargs["endpoint_url"] = match.group(1) + # Also extract region from URL + region_match = re.search(r"bedrock-runtime\.([^.]+)\.", base_url) + if region_match: + kwargs["region_name"] = region_match.group(1) + + return boto3.client(**kwargs) + + +# --------------------------------------------------------------------------- +# Message conversion: OpenAI → Converse +# --------------------------------------------------------------------------- + +# Converse-supported image formats +_MIME_TO_FORMAT = { + "image/jpeg": "jpeg", + "image/jpg": "jpeg", + "image/png": "png", + "image/gif": "gif", + "image/webp": "webp", +} + + +def _fetch_image_as_bytes(url: str, timeout: float = 10.0) -> Optional[Tuple[str, bytes]]: + """Fetch an image URL and return ``(format, raw_bytes)``. + + Returns *None* on any failure (network error, unsupported format, too large). + Bedrock Converse accepts jpeg, png, gif, webp up to ~20 MB. + """ + import logging + try: + import urllib.request + req = urllib.request.Request(url, headers={"User-Agent": "hermes-agent/1.0"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + content_type = resp.headers.get("Content-Type", "").lower().split(";")[0].strip() + data = resp.read(25 * 1024 * 1024) # cap at 25 MB + + # Determine format from Content-Type first, then file extension + fmt = _MIME_TO_FORMAT.get(content_type) + if not fmt: + # Try extension + from urllib.parse import urlparse + path = urlparse(url).path.lower() + for ext, f in {"jpg": "jpeg", "jpeg": "jpeg", "png": "png", "gif": "gif", "webp": "webp"}.items(): + if path.endswith(f".{ext}"): + fmt = f + break + if not fmt: + logging.getLogger(__name__).debug( + "Unsupported image type %r from %s", content_type, url[:120], + ) + return None + return fmt, data + except Exception as exc: + logging.getLogger(__name__).debug("Failed to fetch image %s: %s", url[:120], exc) + return None + + +def _convert_content_to_converse(content: Any) -> List[Dict[str, Any]]: + """Convert OpenAI message content to Converse content blocks.""" + if content is None: + return [] + + if isinstance(content, str): + return [{"text": content}] if content else [] + + if isinstance(content, list): + blocks = [] + for part in content: + if isinstance(part, str): + blocks.append({"text": part}) + elif isinstance(part, dict): + if part.get("type") == "text": + text = part.get("text", "") + if text: + blocks.append({"text": text}) + elif part.get("type") == "image_url": + image_url = part.get("image_url", {}) + url = image_url.get("url", "") if isinstance(image_url, dict) else "" + if url.startswith("data:"): + # Base64 inline image: data:image/png;base64, + import re + match = re.match(r"data:(image/\w+);base64,(.+)", url, re.DOTALL) + if match: + media_type = match.group(1) + data = match.group(2) + fmt = media_type.split("/")[1] + if fmt == "jpg": + fmt = "jpeg" + import base64 + blocks.append({ + "image": { + "format": fmt, + "source": { + "bytes": base64.b64decode(data), + }, + }, + }) + elif url.startswith(("http://", "https://")): + # Converse doesn't accept image URLs — fetch and inline. + fetched = _fetch_image_as_bytes(url) + if fetched: + fmt, img_bytes = fetched + blocks.append({ + "image": { + "format": fmt, + "source": {"bytes": img_bytes}, + }, + }) + else: + logging.getLogger(__name__).warning( + "Bedrock Converse: could not fetch image from %s — skipping", + url[:120], + ) + return blocks + + return [{"text": str(content)}] + + +def _convert_tool_calls_to_converse(tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Convert OpenAI tool_calls to Converse toolUse blocks.""" + blocks = [] + for tc in tool_calls: + func = tc.get("function", {}) + name = func.get("name", "") + args_str = func.get("arguments", "{}") + try: + args = json.loads(args_str) if isinstance(args_str, str) else args_str + except json.JSONDecodeError: + args = {} + blocks.append({ + "toolUse": { + "toolUseId": tc.get("id", ""), + "name": name, + "input": args, + } + }) + return blocks + + +def convert_messages_to_converse( + messages: List[Dict[str, Any]], +) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Convert OpenAI-format messages to Converse API format. + + Returns ``(system_blocks, converse_messages)`` where: + - system_blocks: list of ``{"text": "..."}`` for the system prompt + - converse_messages: list of ``{"role": ..., "content": [...]}`` + """ + system_blocks: List[Dict[str, Any]] = [] + converse_messages: List[Dict[str, Any]] = [] + + for msg in messages: + role = msg.get("role", "") + content = msg.get("content") + + if role == "system": + text = content if isinstance(content, str) else "" + if isinstance(content, list): + text = " ".join( + p.get("text", "") if isinstance(p, dict) else str(p) + for p in content + ) + if text: + system_blocks.append({"text": text}) + continue + + if role == "assistant": + blocks = _convert_content_to_converse(content) + tool_calls = msg.get("tool_calls") + if tool_calls: + blocks.extend(_convert_tool_calls_to_converse(tool_calls)) + if blocks: + converse_messages.append({"role": "assistant", "content": blocks}) + continue + + if role == "tool": + tool_call_id = msg.get("tool_call_id", "") + result_content = content if isinstance(content, str) else json.dumps(content) + tool_result = { + "toolResult": { + "toolUseId": tool_call_id, + "content": [{"text": result_content}], + } + } + # Converse requires tool results in a "user" role message. + # Merge consecutive tool results into one user message. + if ( + converse_messages + and converse_messages[-1]["role"] == "user" + and any("toolResult" in b for b in converse_messages[-1]["content"]) + ): + converse_messages[-1]["content"].append(tool_result) + else: + converse_messages.append({"role": "user", "content": [tool_result]}) + continue + + if role == "user": + blocks = _convert_content_to_converse(content) + if blocks: + converse_messages.append({"role": "user", "content": blocks}) + continue + + # Converse requires alternating user/assistant roles. + # Merge consecutive same-role messages. + merged: List[Dict[str, Any]] = [] + for msg in converse_messages: + if merged and merged[-1]["role"] == msg["role"]: + merged[-1]["content"].extend(msg["content"]) + else: + merged.append(msg) + + return system_blocks, merged + + +# --------------------------------------------------------------------------- +# Tool schema conversion: OpenAI → Converse +# --------------------------------------------------------------------------- + +def convert_tools_to_converse(tools: List[Dict[str, Any]]) -> Dict[str, Any]: + """Convert OpenAI tool definitions to Converse toolConfig format. + + Input: [{"type": "function", "function": {"name": ..., "description": ..., "parameters": ...}}] + Output: {"tools": [{"toolSpec": {"name": ..., "description": ..., "inputSchema": {"json": ...}}}]} + """ + if not tools: + return {} + + converse_tools = [] + for tool in tools: + func = tool.get("function", tool) + name = func.get("name", "") + description = func.get("description", "") + parameters = func.get("parameters", {"type": "object", "properties": {}}) + + converse_tools.append({ + "toolSpec": { + "name": name, + "description": description or name, + "inputSchema": { + "json": parameters, + }, + } + }) + + return {"tools": converse_tools} + + +# --------------------------------------------------------------------------- +# Build Converse kwargs +# --------------------------------------------------------------------------- + +def build_converse_kwargs( + model: str, + messages: List[Dict[str, Any]], + tools: Optional[List[Dict[str, Any]]], + max_tokens: Optional[int] = None, +) -> Dict[str, Any]: + """Build kwargs dict for ``bedrock_client.converse(**kwargs)``. + + Args: + model: Bedrock model ID (e.g. ``anthropic.claude-opus-4-6-v1``). + messages: OpenAI-format messages list. + tools: OpenAI-format tool definitions (or None). + max_tokens: Output token limit. + """ + system_blocks, converse_messages = convert_messages_to_converse(messages) + + kwargs: Dict[str, Any] = { + "modelId": model, + "messages": converse_messages, + } + + if system_blocks: + kwargs["system"] = system_blocks + + inference_config: Dict[str, Any] = {} + if max_tokens: + inference_config["maxTokens"] = max_tokens + if inference_config: + kwargs["inferenceConfig"] = inference_config + + if tools: + kwargs["toolConfig"] = convert_tools_to_converse(tools) + + return kwargs + + +# --------------------------------------------------------------------------- +# Response normalization: Converse → OpenAI-like SimpleNamespace +# --------------------------------------------------------------------------- + +def normalize_converse_response( + response: Dict[str, Any], +) -> Tuple[SimpleNamespace, str]: + """Normalize a Converse API response to the shape AIAgent expects. + + Returns ``(assistant_message, finish_reason)`` where assistant_message has: + - ``.content`` — text string or None + - ``.tool_calls`` — list of tool call SimpleNamespaces or None + - ``.reasoning`` — None (Converse doesn't expose reasoning) + - ``.reasoning_content`` — None + - ``.reasoning_details`` — None + """ + output = response.get("output", {}) + message = output.get("message", {}) + content_blocks = message.get("content", []) + + text_parts: List[str] = [] + tool_calls: List[SimpleNamespace] = [] + + for block in content_blocks: + if "text" in block: + text_parts.append(block["text"]) + elif "toolUse" in block: + tu = block["toolUse"] + tool_calls.append( + SimpleNamespace( + id=tu.get("toolUseId", ""), + type="function", + function=SimpleNamespace( + name=tu.get("name", ""), + arguments=json.dumps(tu.get("input", {})), + ), + ) + ) + + # Map Converse stopReason to OpenAI finish_reason + stop_reason = response.get("stopReason", "end_turn") + stop_map = { + "end_turn": "stop", + "tool_use": "tool_calls", + "max_tokens": "length", + "stop_sequence": "stop", + "content_filtered": "content_filter", + "guardrail_intervened": "content_filter", + } + finish_reason = stop_map.get(stop_reason, "stop") + + # Extract usage info + usage = response.get("usage", {}) + usage_ns = SimpleNamespace( + prompt_tokens=usage.get("inputTokens", 0), + completion_tokens=usage.get("outputTokens", 0), + total_tokens=usage.get("totalTokens", 0), + ) + + return ( + SimpleNamespace( + content="\n".join(text_parts) if text_parts else None, + tool_calls=tool_calls or None, + reasoning=None, + reasoning_content=None, + reasoning_details=None, + ), + finish_reason, + ) diff --git a/agent/models_dev.py b/agent/models_dev.py index f9eb49dbf26e..6125f0e53778 100644 --- a/agent/models_dev.py +++ b/agent/models_dev.py @@ -158,6 +158,7 @@ class ProviderInfo: "kilocode": "kilo", "fireworks": "fireworks-ai", "huggingface": "huggingface", + "bedrock": "bedrock", "gemini": "google", "google": "google", "xai": "xai", diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 97ba92b735a8..bb5c46957e87 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -228,9 +228,24 @@ def get_all_skills_dirs() -> List[Path]: """Return all skill directories: local ``~/.hermes/skills/`` first, then external. The local dir is always first (and always included even if it doesn't exist - yet — callers handle that). External dirs follow in config order. + yet — callers handle that). If ``skills.create_dir`` is configured and + differs from the default, it is inserted second so created skills are found. + External dirs follow in config order. """ - dirs = [get_skills_dir()] + default = get_skills_dir() + dirs: List[Path] = [default] + + # Include create_dir in the read path if it differs from the default + try: + from hermes_cli.config import load_config + raw = load_config().get("skills", {}).get("create_dir", "") + if raw: + create_dir = Path(raw).expanduser().resolve() + if create_dir != default.resolve() and create_dir not in dirs: + dirs.insert(1, create_dir) + except Exception: + pass + dirs.extend(get_external_skills_dirs()) return dirs diff --git a/cli.py b/cli.py index c0313fd24203..311f81cece5c 100644 --- a/cli.py +++ b/cli.py @@ -3470,6 +3470,43 @@ def _preprocess_images_with_vision(self, text: str, images: list, *, announce: b return f"{prefix}\n\n{user_text}" if user_text else prefix return user_text or "What do you see in this image?" + def _build_multimodal_message(self, text: str, images: list) -> list: + """Build a multimodal content list with inline base64 images. + + Used for providers that natively support images (e.g. Bedrock Converse) + so the image goes directly to the main model without pre-analysis. + Returns an OpenAI-format content list. + """ + import base64 as _b64 + + parts = [] + if text: + parts.append({"type": "text", "text": text}) + + for img_path in images: + if not img_path.exists(): + continue + size_kb = img_path.stat().st_size // 1024 + _cprint(f" {_DIM}\U0001f4ce sending {img_path.name} ({size_kb}KB) to model...{_RST}") + + # Detect MIME type + suffix = img_path.suffix.lower() + mime_map = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".png": "image/png", ".gif": "image/gif", + ".webp": "image/webp"} + mime = mime_map.get(suffix, "image/png") + + data = _b64.b64encode(img_path.read_bytes()).decode() + parts.append({ + "type": "image_url", + "image_url": {"url": f"data:{mime};base64,{data}"}, + }) + + if not parts: + return text or "What do you see in this image?" + return parts + + def _show_tool_availability_warnings(self): """Show warnings about disabled tools due to missing API keys.""" try: @@ -7118,10 +7155,17 @@ def chat(self, message, images: list = None) -> Optional[str]: # Pre-process images through the vision tool (Gemini Flash) so the # main model receives text descriptions instead of raw base64 image # content — works with any model, not just vision-capable ones. + # Exception: Bedrock Converse API natively supports images, so send + # them inline as multimodal content blocks instead of pre-analyzing. if images: - message = self._preprocess_images_with_vision( - message if isinstance(message, str) else "", images - ) + if getattr(self, 'api_mode', '') == 'bedrock_converse': + message = self._build_multimodal_message( + message if isinstance(message, str) else "", images + ) + else: + message = self._preprocess_images_with_vision( + message if isinstance(message, str) else "", images + ) # Expand @ context references (e.g. @file:main.py, @diff, @folder:src/) if isinstance(message, str) and "@" in message: diff --git a/gateway/run.py b/gateway/run.py index 1ba7fc84709f..64f3877ea713 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2808,6 +2808,7 @@ async def _prepare_inbound_message_text( if _is_shared_thread and source.user_name: message_text = f"[{source.user_name}] {message_text}" + self._pending_voice_transcript = None if event.media_urls: image_paths = [] audio_paths = [] @@ -3628,6 +3629,8 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str): ) return None + if response and getattr(self, "_pending_voice_transcript", None): + response = "🎙 「" + self._pending_voice_transcript + "」\n\n" + response return response except Exception as e: @@ -6751,6 +6754,7 @@ async def _enrich_message_with_transcription( result = await asyncio.to_thread(transcribe_audio, path) if result["success"]: transcript = result["transcript"] + self._pending_voice_transcript = transcript enriched_parts.append( f'[The user sent a voice message~ ' f'Here\'s what they said: "{transcript}"]' @@ -7449,6 +7453,14 @@ def run_sync(): ) _stream_delta_cb = _stream_consumer.on_delta stream_consumer_holder[0] = _stream_consumer + # Seed voice transcript prefix so streaming messages also + # show 🎙 「…」 at the top. The non-streaming path adds + # this at line 3632; in the streaming path we inject it + # as the first delta so it becomes the start of the + # first edit. + _vt = getattr(self, "_pending_voice_transcript", None) + if _vt: + _stream_consumer.on_delta(f"🎙 「{_vt}」\n\n") except Exception as _sc_err: logger.debug("Could not set up stream consumer: %s", _sc_err) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 56b9fb63c2e8..4309a09d52d0 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -258,6 +258,14 @@ class ProviderConfig: api_key_env_vars=("XIAOMI_API_KEY",), base_url_env_var="XIAOMI_BASE_URL", ), + "bedrock": ProviderConfig( + id="bedrock", + name="Amazon Bedrock", + auth_type="api_key", + inference_base_url="https://bedrock-runtime.us-east-1.amazonaws.com", + api_key_env_vars=("AWS_BEARER_TOKEN_BEDROCK",), + base_url_env_var="AWS_BEDROCK_BASE_URL", + ), } @@ -398,6 +406,33 @@ def _resolve_api_key_provider_secret( return "", "" +# ============================================================================= +# Amazon Bedrock Endpoint Detection +# ============================================================================= + +DEFAULT_BEDROCK_REGION = "us-east-1" + + +def _resolve_bedrock_base_url(default_url: str, env_override: str) -> str: + """Return the correct Amazon Bedrock base URL. + + If the user has explicitly set AWS_BEDROCK_BASE_URL, that always wins. + Otherwise, constructs the URL from AWS_BEDROCK_REGION (default: us-east-1). + + Returns the bare bedrock-runtime endpoint (no path suffix) since the + Converse API is called via boto3, not the OpenAI-compatible endpoint. + """ + if env_override: + # Strip /openai/v1 suffix if present — that's for Chat Completions, + # not the Converse API. + url = env_override.rstrip("/") + if url.endswith("/openai/v1"): + url = url[:-len("/openai/v1")] + return url + region = os.getenv("AWS_BEDROCK_REGION", "").strip() or DEFAULT_BEDROCK_REGION + return f"https://bedrock-runtime.{region}.amazonaws.com" + + # ============================================================================= # Z.AI Endpoint Detection # ============================================================================= @@ -939,6 +974,7 @@ def resolve_provider( "qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth", "hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface", "mimo": "xiaomi", "xiaomi-mimo": "xiaomi", + "bedrock": "bedrock", "aws-bedrock": "bedrock", "aws": "bedrock", "amazon-bedrock": "bedrock", "go": "opencode-go", "opencode-go-sub": "opencode-go", "kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode", # Local server aliases — route through the generic custom provider @@ -2407,6 +2443,8 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url) elif provider_id == "zai": base_url = _resolve_zai_base_url(api_key, pconfig.inference_base_url, env_url) + elif provider_id == "bedrock": + base_url = _resolve_bedrock_base_url(pconfig.inference_base_url, env_url) elif env_url: base_url = env_url.rstrip("/") else: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f551a195d002..5548e5c9fbd3 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -560,9 +560,10 @@ def _ensure_hermes_home_managed(home: Path): # Skills — external skill directories for sharing skills across tools/agents. # Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation - # always goes to ~/.hermes/skills/. + # goes to ``skills.create_dir`` (default: ``~/.hermes/skills/``). "skills": { "external_dirs": [], # e.g. ["~/.agents/skills", "/shared/team-skills"] + "create_dir": "", # where new skills are written; empty = ~/.hermes/skills/ }, # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth. @@ -885,6 +886,21 @@ def _ensure_hermes_home_managed(home: Path): "category": "provider", "advanced": True, }, + "AWS_BEARER_TOKEN_BEDROCK": { + "description": "Amazon Bedrock API key (Bearer token for OpenAI-compatible endpoint)", + "prompt": "Amazon Bedrock API Key", + "url": "https://console.aws.amazon.com/bedrock/", + "password": True, + "category": "provider", + }, + "AWS_BEDROCK_REGION": { + "description": "AWS region for Bedrock (default: us-east-1)", + "prompt": "AWS Bedrock Region (leave empty for us-east-1)", + "url": None, + "password": False, + "category": "provider", + "advanced": True, + }, # ── Tool API keys ── "EXA_API_KEY": { diff --git a/hermes_cli/main.py b/hermes_cli/main.py index c74b7945e258..e8069314986e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -935,6 +935,7 @@ def select_provider_and_model(args=None): "alibaba": "Alibaba Cloud (DashScope)", "huggingface": "Hugging Face", "xiaomi": "Xiaomi MiMo", + "bedrock": "Amazon Bedrock", "custom": "Custom endpoint", } active_label = provider_labels.get(active, active) if active else "none" @@ -968,6 +969,7 @@ def select_provider_and_model(args=None): ("ai-gateway", "AI Gateway (Vercel — 200+ models, pay-per-use)"), ("alibaba", "Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"), ("xiaomi", "Xiaomi MiMo (MiMo-V2 models — pro, omni, flash)"), + ("bedrock", "Amazon Bedrock (Claude, Nova, DeepSeek, Llama — API key auth)"), ] def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]: @@ -1079,6 +1081,8 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]: _model_flow_anthropic(config, current_model) elif selected_provider == "kimi-coding": _model_flow_kimi(config, current_model) + elif selected_provider == "bedrock": + _model_flow_bedrock(config, current_model) elif selected_provider in ("gemini", "zai", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "ai-gateway", "alibaba", "huggingface", "xiaomi"): _model_flow_api_key_provider(config, selected_provider, current_model) @@ -2322,6 +2326,243 @@ def _model_flow_kimi(config, current_model=""): print("No change.") +def _model_flow_bedrock(config, current_model=""): + """Amazon Bedrock model selection with API key and region setup. + + Supports three authentication methods: + 1. Bedrock API key (AWS_BEARER_TOKEN_BEDROCK) — simplest + 2. AWS profile (~/.aws/credentials) — for existing AWS users + 3. AWS access key pair (AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY) + + Also prompts for region selection. + """ + from hermes_cli.auth import ( + PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice, + deactivate_provider, + ) + from hermes_cli.config import get_env_value, save_env_value, load_config, save_config + + provider_id = "bedrock" + pconfig = PROVIDER_REGISTRY[provider_id] + + # ── Step 1: Authentication ── + existing_bearer = get_env_value("AWS_BEARER_TOKEN_BEDROCK") or os.getenv("AWS_BEARER_TOKEN_BEDROCK", "") + existing_access_key = os.getenv("AWS_ACCESS_KEY_ID", "") + aws_profiles = [] + try: + import boto3.session + aws_profiles = boto3.session.Session().available_profiles + except Exception: + pass + + has_bearer = bool(existing_bearer and len(existing_bearer) > 8) + has_access_key = bool(existing_access_key and len(existing_access_key) > 8) + has_profiles = bool(aws_profiles) + + # Show current auth status + if has_bearer: + print(f" Bedrock API key: {existing_bearer[:12]}... ✓") + if has_access_key: + print(f" AWS access key: {existing_access_key[:8]}... ✓") + if has_profiles: + print(f" AWS profiles: {', '.join(aws_profiles)}") + if has_bearer or has_access_key or has_profiles: + print() + + # Build auth options + auth_choices = [] + if has_bearer: + auth_choices.append(("keep_bearer", f"Use existing Bedrock API key ({existing_bearer[:12]}...)")) + auth_choices.append(("new_bearer", "Enter a Bedrock API key (from AWS console)")) + if has_profiles: + for profile in aws_profiles: + auth_choices.append((f"profile:{profile}", f"Use AWS profile: {profile}")) + if has_access_key: + auth_choices.append(("keep_access_key", f"Use existing AWS access key ({existing_access_key[:8]}...)")) + auth_choices.append(("new_access_key", "Enter AWS access key + secret key")) + auth_choices.append(("cancel", "Cancel")) + + auth_idx = _prompt_provider_choice( + [label for _, label in auth_choices], default=0, + ) + if auth_idx is None or auth_choices[auth_idx][0] == "cancel": + print("No change.") + return + + auth_method = auth_choices[auth_idx][0] + selected_profile = "" + + if auth_method == "new_bearer": + print() + print(" Generate an API key at: https://console.aws.amazon.com/bedrock/") + print(" → Navigate to API keys in the left panel") + print() + try: + import getpass + new_key = getpass.getpass(" Bedrock API key (or Enter to cancel): ").strip() + except (KeyboardInterrupt, EOFError): + print() + return + if not new_key: + print("Cancelled.") + return + save_env_value("AWS_BEARER_TOKEN_BEDROCK", new_key) + existing_bearer = new_key + print(" API key saved. ✓") + print() + elif auth_method == "new_access_key": + print() + try: + access_key = input(" AWS Access Key ID: ").strip() + if not access_key: + print("Cancelled.") + return + import getpass + secret_key = getpass.getpass(" AWS Secret Access Key: ").strip() + if not secret_key: + print("Cancelled.") + return + except (KeyboardInterrupt, EOFError): + print() + return + save_env_value("AWS_ACCESS_KEY_ID", access_key) + save_env_value("AWS_SECRET_ACCESS_KEY", secret_key) + # Clear bearer token to avoid conflicts + if get_env_value("AWS_BEARER_TOKEN_BEDROCK"): + save_env_value("AWS_BEARER_TOKEN_BEDROCK", "") + print(" AWS credentials saved. ✓") + print() + elif auth_method.startswith("profile:"): + selected_profile = auth_method.split(":", 1)[1] + save_env_value("AWS_PROFILE", selected_profile) + # Clear bearer token to avoid conflicts + if get_env_value("AWS_BEARER_TOKEN_BEDROCK"): + save_env_value("AWS_BEARER_TOKEN_BEDROCK", "") + print(f" Using AWS profile: {selected_profile} ✓") + print() + elif auth_method == "keep_bearer": + print(f" Using existing Bedrock API key. ✓") + print() + elif auth_method == "keep_access_key": + print(f" Using existing AWS access key. ✓") + print() + + # ── Step 2: Region selection ── + current_region = ( + get_env_value("AWS_BEDROCK_REGION") + or os.getenv("AWS_BEDROCK_REGION", "") + or os.getenv("AWS_DEFAULT_REGION", "") + ) + + # Try to get region from AWS profile + if not current_region and selected_profile: + try: + import boto3.session + s = boto3.session.Session(profile_name=selected_profile) + current_region = s.region_name or "" + except Exception: + pass + if not current_region: + try: + import boto3.session + current_region = boto3.session.Session().region_name or "" + except Exception: + pass + + common_regions = [ + "us-east-1", + "us-west-2", + "eu-central-1", + "eu-west-1", + "eu-west-2", + "ap-northeast-1", + "ap-southeast-1", + "ap-southeast-2", + ] + + region_choices = [] + default_region_idx = 0 + for i, r in enumerate(common_regions): + label = r + if r == current_region: + label += " ← current" + default_region_idx = i + region_choices.append((r, label)) + region_choices.append(("custom", "Enter a different region")) + region_choices.append(("cancel", "Cancel")) + + # If current region isn't in the common list, add it at the top + if current_region and current_region not in common_regions: + region_choices.insert(0, (current_region, f"{current_region} ← current")) + default_region_idx = 0 + + print(" Select AWS region:") + region_idx = _prompt_provider_choice( + [label for _, label in region_choices], default=default_region_idx, + ) + if region_idx is None or region_choices[region_idx][0] == "cancel": + print("No change.") + return + + if region_choices[region_idx][0] == "custom": + try: + chosen_region = input(" Region (e.g. eu-central-1): ").strip() + except (KeyboardInterrupt, EOFError): + print() + return + if not chosen_region: + print("Cancelled.") + return + else: + chosen_region = region_choices[region_idx][0] + + save_env_value("AWS_BEDROCK_REGION", chosen_region) + effective_base = f"https://bedrock-runtime.{chosen_region}.amazonaws.com" + print(f" Region: {chosen_region} ✓") + print() + + # ── Step 3: Model selection ── + # Try live model list from Bedrock APIs first + model_list: list = [] + try: + from hermes_cli.models import _fetch_bedrock_models + live = _fetch_bedrock_models() + if live: + model_list = live + except Exception: + pass + + if not model_list: + # Fallback to static curated list + model_list = list(_PROVIDER_MODELS.get(provider_id, [])) + if model_list: + print(f" Showing {len(model_list)} models — use \"Enter custom model name\" for others.") + selected = _prompt_model_selection(model_list, current_model=current_model) + else: + try: + selected = input(" Model name (e.g. eu.anthropic.claude-opus-4-6-v1): ").strip() + except (KeyboardInterrupt, EOFError): + selected = None + + if selected: + _save_model_choice(selected) + + cfg = load_config() + model = cfg.get("model") + if not isinstance(model, dict): + model = {"default": model} if model else {} + cfg["model"] = model + model["provider"] = provider_id + model["base_url"] = effective_base + model["api_mode"] = "bedrock_converse" + save_config(cfg) + deactivate_provider() + + print(f" Default model set to: {selected} (via Amazon Bedrock, {chosen_region})") + else: + print("No change.") + + def _model_flow_api_key_provider(config, provider_id, current_model=""): """Generic flow for API-key providers (z.ai, MiniMax, OpenCode, etc.).""" from hermes_cli.auth import ( @@ -2362,10 +2603,21 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): print() # Optional base URL override + # Use resolve_api_key_provider_credentials() to get the properly resolved + # base URL (handles region-aware URLs like Bedrock, endpoint probing like + # Z.AI, and prefix detection like Kimi). current_base = "" if base_url_env: current_base = get_env_value(base_url_env) or os.getenv(base_url_env, "") - effective_base = current_base or pconfig.inference_base_url + if current_base: + effective_base = current_base + else: + try: + from hermes_cli.auth import resolve_api_key_provider_credentials as _resolve_creds + _resolved = _resolve_creds(provider_id) + effective_base = _resolved.get("base_url", "") or pconfig.inference_base_url + except Exception: + effective_base = pconfig.inference_base_url try: override = input(f"Base URL [{effective_base}]: ").strip() @@ -2438,6 +2690,8 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): model["base_url"] = effective_base if provider_id in {"opencode-zen", "opencode-go"}: model["api_mode"] = opencode_model_api_mode(provider_id, selected) + elif provider_id == "bedrock": + model["api_mode"] = "bedrock_converse" else: model.pop("api_mode", None) save_config(cfg) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 17c1072dbe8a..6ee73d79a951 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -286,6 +286,25 @@ def _codex_curated_models() -> list[str]: "XiaomiMiMo/MiMo-V2-Flash", "moonshotai/Kimi-K2-Thinking", ], + # Amazon Bedrock — Converse API via API key (Bearer token). + # This is a FALLBACK list — the live model catalog is fetched at runtime + # via _fetch_bedrock_models() (ListInferenceProfiles + ListFoundationModels). + # Cross-region inference profile prefixes: + # global. — works from any region + # eu./us./ap. — regional routing + # bare — single-region direct access + "bedrock": [ + "global.anthropic.claude-opus-4-6-v1", + "global.anthropic.claude-sonnet-4-6", + "global.anthropic.claude-haiku-4-5-20251001-v1:0", + "anthropic.claude-opus-4-6-v1", + "anthropic.claude-sonnet-4-6", + "anthropic.claude-haiku-4-5-20251001-v1:0", + "deepseek.v3.2", + "amazon.nova-pro-v1:0", + "amazon.nova-lite-v1:0", + "meta.llama4-maverick-17b-instruct-v1:0", + ], } # --------------------------------------------------------------------------- @@ -499,6 +518,7 @@ def check_nous_free_tier() -> bool: "qwen-oauth": "Qwen OAuth (Portal)", "huggingface": "Hugging Face", "xiaomi": "Xiaomi MiMo", + "bedrock": "Amazon Bedrock", "custom": "Custom endpoint", } @@ -543,6 +563,10 @@ def check_nous_free_tier() -> bool: "huggingface-hub": "huggingface", "mimo": "xiaomi", "xiaomi-mimo": "xiaomi", + "bedrock": "bedrock", + "aws-bedrock": "bedrock", + "aws": "bedrock", + "amazon-bedrock": "bedrock", } @@ -825,7 +849,7 @@ def list_available_providers() -> list[dict[str, str]]: # Canonical providers in display order _PROVIDER_ORDER = [ "openrouter", "nous", "openai-codex", "copilot", "copilot-acp", - "gemini", "huggingface", + "gemini", "huggingface", "bedrock", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic", "alibaba", "qwen-oauth", "xiaomi", "opencode-zen", "opencode-go", @@ -1216,6 +1240,10 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) live = fetch_api_models(api_key, base_url) if live: return live + if normalized == "bedrock": + live = _fetch_bedrock_models() + if live: + return live return list(_PROVIDER_MODELS.get(normalized, [])) @@ -1263,6 +1291,115 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]: return None +def _fetch_bedrock_models() -> Optional[list[str]]: + """Fetch available models from Amazon Bedrock. + + Queries both ListInferenceProfiles (cross-region profiles like eu.*, us.*, + global.*) and ListFoundationModels (bare model IDs) to build a combined + list of text-generating models available in the configured region. + + Returns sorted model IDs with inference profiles first, or None on failure. + """ + try: + import boto3 + except ImportError: + return None + + region = os.getenv("AWS_BEDROCK_REGION", "").strip() + if not region: + try: + from hermes_cli.config import get_env_value + region = (get_env_value("AWS_BEDROCK_REGION") or "").strip() + except Exception: + pass + if not region: + region = "us-east-1" + + try: + client = boto3.client("bedrock", region_name=region) + except Exception: + return None + + # --- Inference profiles (eu.*, us.*, global.*) --- + profiles: list[str] = [] + try: + kwargs: dict[str, Any] = {"maxResults": 100} + while True: + resp = client.list_inference_profiles(**kwargs) + for p in resp.get("inferenceProfileSummaries", []): + if p.get("status") == "ACTIVE": + pid = p.get("inferenceProfileId", "") + if pid: + profiles.append(pid) + token = resp.get("nextToken") + if not token: + break + kwargs["nextToken"] = token + except Exception as e: + import logging + logging.getLogger(__name__).debug("Bedrock ListInferenceProfiles failed: %s", e) + + # --- Foundation models (bare IDs) --- + bare_models: list[str] = [] + try: + resp = client.list_foundation_models() + for m in resp.get("modelSummaries", []): + # Only text-output models that support on-demand inference + output = m.get("outputModalities", []) + inference_types = m.get("inferenceTypesSupported", []) + if "TEXT" in output and inference_types: + mid = m.get("modelId", "") + if mid: + bare_models.append(mid) + except Exception as e: + import logging + logging.getLogger(__name__).debug("Bedrock ListFoundationModels failed: %s", e) + + if not profiles and not bare_models: + return None + + # Filter out embedding, image, and non-chat models by provider heuristics + _EXCLUDED_PREFIXES = ( + "amazon.titan-embed", "amazon.titan-image", "amazon.nova-canvas", + "amazon.nova-reel", "amazon.nova-sonic", "cohere.embed", + "cohere.rerank", "stability.", "twelvelabs.", + ) + + def _is_chat_model(model_id: str) -> bool: + lower = model_id.lower() + # Strip inference profile prefix for checking + for prefix in ("global.", "eu.", "us.", "ap.", "al."): + if lower.startswith(prefix): + lower = lower[len(prefix):] + break + return not any(lower.startswith(ex) for ex in _EXCLUDED_PREFIXES) + + # Combine: profiles first (preferred), then bare models not already covered + profile_set = set(profiles) + # Extract bare model IDs that profiles already cover + covered_bare = set() + for pid in profiles: + for prefix in ("global.", "eu.", "us.", "ap.", "al."): + if pid.startswith(prefix): + covered_bare.add(pid[len(prefix):]) + break + + combined: list[str] = [] + seen: set[str] = set() + # Add profiles first + for pid in profiles: + if _is_chat_model(pid) and pid not in seen: + combined.append(pid) + seen.add(pid) + # Add bare models not covered by profiles + for mid in bare_models: + if _is_chat_model(mid) and mid not in seen and mid not in covered_bare: + combined.append(mid) + seen.add(mid) + + return combined if combined else None + + def _payload_items(payload: Any) -> list[dict[str, Any]]: if isinstance(payload, list): return [item for item in payload if isinstance(item, dict)] diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index a9976349834a..13bf1fc5bdbc 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -136,6 +136,11 @@ class HermesOverlay: transport="openai_chat", base_url_env_var="XIAOMI_BASE_URL", ), + "bedrock": HermesOverlay( + transport="bedrock_converse", + extra_env_vars=("AWS_BEARER_TOKEN_BEDROCK",), + base_url_env_var="AWS_BEDROCK_BASE_URL", + ), } @@ -229,6 +234,11 @@ class ProviderDef: # xiaomi "mimo": "xiaomi", "xiaomi-mimo": "xiaomi", + # bedrock + "aws-bedrock": "bedrock", + "aws": "bedrock", + "amazon-bedrock": "bedrock", + "amazon": "bedrock", # Local server aliases → virtual "local" concept (resolved via user config) "lmstudio": "lmstudio", @@ -251,6 +261,7 @@ class ProviderDef: "openai-codex": "OpenAI Codex", "copilot-acp": "GitHub Copilot ACP", "xiaomi": "Xiaomi MiMo", + "bedrock": "Amazon Bedrock", "local": "Local endpoint", } @@ -261,6 +272,7 @@ class ProviderDef: "openai_chat": "chat_completions", "anthropic_messages": "anthropic_messages", "codex_responses": "codex_responses", + "bedrock_converse": "bedrock_converse", } diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index cd0b66722579..2d42a6d24573 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -124,7 +124,7 @@ def _copilot_runtime_api_mode(model_cfg: Dict[str, Any], api_key: str) -> str: return "chat_completions" -_VALID_API_MODES = {"chat_completions", "codex_responses", "anthropic_messages"} +_VALID_API_MODES = {"chat_completions", "codex_responses", "anthropic_messages", "bedrock_converse"} def _parse_api_mode(raw: Any) -> Optional[str]: diff --git a/pyproject.toml b/pyproject.toml index 28a4a300a772..2fcba3a1be4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "edge-tts>=7.2.7,<8", # Skills Hub (GitHub App JWT auth — optional, only needed for bot identity) "PyJWT[crypto]>=2.12.0,<3", # CVE-2026-32597 + "boto3>=1.42.87", ] [project.optional-dependencies] diff --git a/run_agent.py b/run_agent.py index cc93594d68ad..80229cba2e5a 100644 --- a/run_agent.py +++ b/run_agent.py @@ -671,7 +671,7 @@ def __init__( self.provider = provider_name or "" self.acp_command = acp_command or command self.acp_args = list(acp_args or args or []) - if api_mode in {"chat_completions", "codex_responses", "anthropic_messages"}: + if api_mode in {"chat_completions", "codex_responses", "anthropic_messages", "bedrock_converse"}: self.api_mode = api_mode elif self.provider == "openai-codex": self.api_mode = "codex_responses" @@ -686,6 +686,9 @@ def __init__( # use a URL convention ending in /anthropic. Auto-detect these so the # Anthropic Messages API adapter is used instead of chat completions. self.api_mode = "anthropic_messages" + elif self.provider == "bedrock" or (provider_name is None and "bedrock-runtime" in self._base_url_lower and "amazonaws.com" in self._base_url_lower): + self.api_mode = "bedrock_converse" + self.provider = self.provider or "bedrock" else: self.api_mode = "chat_completions" @@ -871,6 +874,14 @@ def __init__( print(f"🤖 AI Agent initialized with model: {self.model} (Anthropic native)") if effective_key and len(effective_key) > 12: print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}") + elif self.api_mode == "bedrock_converse": + from agent.bedrock_adapter import build_bedrock_client + self._bedrock_client = build_bedrock_client(base_url=base_url) + self.api_key = api_key or "" + self.client = None + self._client_kwargs = {} + if not self.quiet_mode: + print(f"🤖 AI Agent initialized with model: {self.model} (Amazon Bedrock Converse)") else: if api_key and base_url: # Explicit credentials from CLI/gateway — construct directly. @@ -1535,6 +1546,11 @@ def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mod self._is_anthropic_oauth = _is_oauth_token(effective_key) self.client = None self._client_kwargs = {} + elif api_mode == "bedrock_converse": + from agent.bedrock_adapter import build_bedrock_client + self._bedrock_client = build_bedrock_client(base_url=base_url or self.base_url) + self.client = None + self._client_kwargs = {} else: effective_key = api_key or self.api_key effective_base = base_url or self.base_url @@ -4690,6 +4706,8 @@ def _call(): ) elif self.api_mode == "anthropic_messages": result["response"] = self._anthropic_messages_create(api_kwargs) + elif self.api_mode == "bedrock_converse": + result["response"] = self._bedrock_client.converse(**api_kwargs) else: request_client_holder["client"] = self._create_request_openai_client(reason="chat_completion_request") result["response"] = request_client_holder["client"].chat.completions.create(**api_kwargs) @@ -5941,6 +5959,15 @@ def _build_api_kwargs(self, api_messages: list) -> dict: return kwargs + if self.api_mode == "bedrock_converse": + from agent.bedrock_adapter import build_converse_kwargs + return build_converse_kwargs( + model=self.model, + messages=api_messages, + tools=self.tools, + max_tokens=self.max_tokens, + ) + sanitized_messages = api_messages needs_sanitization = False for msg in api_messages: @@ -6474,6 +6501,11 @@ def flush_memories(self, messages: list = None, min_turns: int = None): _flush_msg, _ = _nar_flush(response, strip_tool_prefix=self._is_anthropic_oauth) if _flush_msg and _flush_msg.tool_calls: tool_calls = _flush_msg.tool_calls + elif self.api_mode == "bedrock_converse" and not _aux_available: + from agent.bedrock_adapter import normalize_converse_response as _ncr_flush + _flush_msg, _ = _ncr_flush(response) + if _flush_msg and _flush_msg.tool_calls: + tool_calls = _flush_msg.tool_calls elif hasattr(response, "choices") and response.choices: assistant_message = response.choices[0].message if assistant_message.tool_calls: @@ -7598,8 +7630,11 @@ def run_conversation( self.iteration_budget = IterationBudget(self.max_iterations) # Log conversation turn start for debugging/observability - _msg_preview = (user_message[:80] + "...") if len(user_message) > 80 else user_message - _msg_preview = _msg_preview.replace("\n", " ") + if isinstance(user_message, str): + _msg_preview = (user_message[:80] + "...") if len(user_message) > 80 else user_message + _msg_preview = _msg_preview.replace("\n", " ") + else: + _msg_preview = f"[multimodal: {len(user_message)} parts]" logger.info( "conversation turn: session=%s model=%s provider=%s platform=%s history=%d msg=%r", self.session_id or "none", self.model, self.provider or "unknown", @@ -7654,8 +7689,10 @@ def run_conversation( self._persist_user_message_idx = current_turn_user_idx if not self.quiet_mode: - self._safe_print(f"💬 Starting conversation: '{user_message[:60]}{'...' if len(user_message) > 60 else ''}'") - + if isinstance(user_message, str): + self._safe_print(f"💬 Starting conversation: '{user_message[:60]}{'...' if len(user_message) > 60 else ''}'") + else: + self._safe_print(f"💬 Starting conversation: [multimodal message with {len(user_message)} parts]") # ── System prompt (cached per session for prefix caching) ── # Built once on first call, reused for all subsequent calls. # Only rebuilt after context compression events (which invalidate @@ -8155,6 +8192,16 @@ def _stop_spinner(): elif not content_blocks: response_invalid = True error_details.append("response.content is empty") + elif self.api_mode == "bedrock_converse": + if response is None: + response_invalid = True + error_details.append("response is None") + elif not isinstance(response, dict): + response_invalid = True + error_details.append("response is not a dict") + elif not response.get("output", {}).get("message", {}).get("content"): + response_invalid = True + error_details.append("response.output.message.content is empty") else: if response is None or not hasattr(response, 'choices') or response.choices is None or not response.choices: response_invalid = True @@ -8207,7 +8254,7 @@ def _stop_spinner(): # Check for x-openrouter-provider or similar metadata if provider_name == "Unknown" and response: # Log all response attributes for debugging - resp_attrs = {k: str(v)[:100] for k, v in vars(response).items() if not k.startswith('_')} + resp_attrs = {k: str(v)[:100] for k, v in (vars(response) if hasattr(response, '__dict__') else (response if isinstance(response, dict) else {})).items() if not k.startswith('_')} if self.verbose_logging: logging.debug(f"Response attributes for invalid response: {resp_attrs}") @@ -8275,6 +8322,9 @@ def _stop_spinner(): elif self.api_mode == "anthropic_messages": stop_reason_map = {"end_turn": "stop", "tool_use": "tool_calls", "max_tokens": "length", "stop_sequence": "stop"} finish_reason = stop_reason_map.get(response.stop_reason, "stop") + elif self.api_mode == "bedrock_converse": + stop_reason_map = {"end_turn": "stop", "tool_use": "tool_calls", "max_tokens": "length", "stop_sequence": "stop", "content_filtered": "content_filter", "guardrail_intervened": "content_filter"} + finish_reason = stop_reason_map.get(response.get("stopReason", "end_turn"), "stop") else: finish_reason = response.choices[0].finish_reason @@ -9304,6 +9354,9 @@ def _stop_spinner(): assistant_message, finish_reason = normalize_anthropic_response( response, strip_tool_prefix=self._is_anthropic_oauth ) + elif self.api_mode == "bedrock_converse": + from agent.bedrock_adapter import normalize_converse_response + assistant_message, finish_reason = normalize_converse_response(response) else: assistant_message = response.choices[0].message @@ -10173,8 +10226,15 @@ def _stop_spinner(): # injected skill content that bloats / breaks provider queries. if self._memory_manager and final_response and original_user_message: try: - self._memory_manager.sync_all(original_user_message, final_response) - self._memory_manager.queue_prefetch_all(original_user_message) + _mem_msg = original_user_message + if not isinstance(_mem_msg, str): + # Extract text from multimodal content list + _mem_msg = " ".join( + p.get("text", "") for p in _mem_msg + if isinstance(p, dict) and p.get("type") == "text" + ) or "[image]" + self._memory_manager.sync_all(_mem_msg, final_response) + self._memory_manager.queue_prefetch_all(_mem_msg) except Exception: pass diff --git a/tests/gateway/test_stream_consumer.py b/tests/gateway/test_stream_consumer.py index 5cebb20eee69..51438e80ad61 100644 --- a/tests/gateway/test_stream_consumer.py +++ b/tests/gateway/test_stream_consumer.py @@ -505,3 +505,68 @@ async def test_fallback_final_splits_long_continuation_without_dropping_text(sel assert len(sent_texts) == 3 assert sent_texts[0].startswith(prefix) assert sum(len(t) for t in sent_texts[1:]) == len(tail) + + +# ── Voice transcript prefix in streaming mode ──────────────────────────── + + +class TestVoiceTranscriptPrefix: + """Verify that a voice transcript seeded before the stream starts + appears at the top of the first streamed message. + + This covers the fix for the bug where streaming mode silently + dropped the 🎙 「…」 prefix that non-streaming mode always prepended. + """ + + @pytest.mark.asyncio + async def test_transcript_prefix_in_first_message(self): + """🎙 prefix seeded via on_delta is the start of the first sent message.""" + adapter = MagicMock() + send_result = SimpleNamespace(success=True, message_id="msg_1") + edit_result = SimpleNamespace(success=True) + adapter.send = AsyncMock(return_value=send_result) + adapter.edit_message = AsyncMock(return_value=edit_result) + adapter.MAX_MESSAGE_LENGTH = 4096 + + config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5) + consumer = GatewayStreamConsumer(adapter, "chat_123", config) + + # Gateway seeds transcript before agent starts (run.py fix) + consumer.on_delta("🎙 「hello from voice」\n\n") + consumer.on_delta("The answer is 42.") + consumer.finish() + + await consumer.run() + + all_texts = ( + [c[1].get("content", "") for c in adapter.send.call_args_list] + + [c[1].get("content", "") for c in adapter.edit_message.call_args_list] + ) + assert all_texts, "Expected at least one send/edit" + # The first call must start with the voice transcript header + assert all_texts[0].startswith("🎙 「hello from voice」"), ( + f"First message did not start with transcript prefix: {all_texts[0]!r}" + ) + # The response text must also appear somewhere + assert any("42" in t for t in all_texts) + + @pytest.mark.asyncio + async def test_no_prefix_for_text_messages(self): + """Without a seeded transcript, the first message starts with agent text.""" + adapter = MagicMock() + send_result = SimpleNamespace(success=True, message_id="msg_1") + adapter.send = AsyncMock(return_value=send_result) + adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True)) + adapter.MAX_MESSAGE_LENGTH = 4096 + + config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5) + consumer = GatewayStreamConsumer(adapter, "chat_123", config) + + # No transcript seeded — plain text message + consumer.on_delta("Sure, here is the answer.") + consumer.finish() + + await consumer.run() + + first_text = adapter.send.call_args_list[0][1]["content"] + assert "🎙" not in first_text diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index 2b2625fa0d4c..25a547e1bc4a 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -76,9 +76,28 @@ def _security_scan_skill(skill_dir: Path) -> Optional[str]: import yaml -# All skills live in ~/.hermes/skills/ (single source of truth) +# Default write location for new skills. Override via skills.create_dir in config.yaml. HERMES_HOME = get_hermes_home() -SKILLS_DIR = HERMES_HOME / "skills" +_DEFAULT_SKILLS_DIR = HERMES_HOME / "skills" + + +def _get_skills_create_dir() -> Path: + """Return the directory where new skills are written. + + Reads ``skills.create_dir`` from config.yaml. Falls back to the default + ``~/.hermes/skills/`` when unset, so existing setups are unaffected. + """ + try: + from hermes_cli.config import load_config + raw = load_config().get("skills", {}).get("create_dir", "") + if raw: + return Path(raw).expanduser().resolve() + except Exception: + pass + return _DEFAULT_SKILLS_DIR + + +SKILLS_DIR = _get_skills_create_dir() MAX_NAME_LENGTH = 64 MAX_DESCRIPTION_LENGTH = 1024 diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index 3d3473a3956e..7fd81a33c855 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -88,6 +88,34 @@ def _safe_find_spec(module_name: str) -> bool: _local_model: Optional[object] = None _local_model_name: Optional[str] = None +# --------------------------------------------------------------------------- +# Hotwords / initial_prompt helpers +# --------------------------------------------------------------------------- + +_HOTWORDS_PATH = Path.home() / ".hermes" / "stt_hotwords.txt" + + +def _load_initial_prompt() -> Optional[str]: + """Load hotwords from ~/.hermes/stt_hotwords.txt and return as a comma-separated + string suitable for Whisper's ``initial_prompt`` / ``prompt`` parameter. + + Lines starting with ``#`` and blank lines are ignored. + Returns ``None`` if the file is missing or empty. + """ + try: + if not _HOTWORDS_PATH.exists(): + return None + words = [ + line.strip() + for line in _HOTWORDS_PATH.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.strip().startswith("#") + ] + if not words: + return None + return ", ".join(words) + except Exception: + return None + # --------------------------------------------------------------------------- # Config helpers # --------------------------------------------------------------------------- @@ -334,6 +362,9 @@ def _transcribe_local(file_path: str, model_name: str) -> Dict[str, Any]: transcribe_kwargs = {"beam_size": 5} if _forced_lang: transcribe_kwargs["language"] = _forced_lang + _initial_prompt = _load_initial_prompt() + if _initial_prompt: + transcribe_kwargs["initial_prompt"] = _initial_prompt segments, info = _local_model.transcribe(file_path, **transcribe_kwargs) transcript = " ".join(segment.text.strip() for segment in segments) @@ -460,11 +491,13 @@ def _transcribe_groq(file_path: str, model_name: str) -> Dict[str, Any]: from openai import OpenAI, APIError, APIConnectionError, APITimeoutError client = OpenAI(api_key=api_key, base_url=GROQ_BASE_URL, timeout=30, max_retries=0) try: + _prompt = _load_initial_prompt() with open(file_path, "rb") as audio_file: transcription = client.audio.transcriptions.create( model=model_name, file=audio_file, response_format="text", + **({"prompt": _prompt} if _prompt else {}), ) transcript_text = str(transcription).strip() @@ -517,11 +550,13 @@ def _transcribe_openai(file_path: str, model_name: str) -> Dict[str, Any]: from openai import OpenAI, APIError, APIConnectionError, APITimeoutError client = OpenAI(api_key=api_key, base_url=base_url, timeout=30, max_retries=0) try: + _prompt = _load_initial_prompt() with open(file_path, "rb") as audio_file: transcription = client.audio.transcriptions.create( model=model_name, file=audio_file, response_format="text" if model_name == "whisper-1" else "json", + **({"prompt": _prompt} if _prompt else {}), ) transcript_text = _extract_transcript_text(transcription) diff --git a/uv.lock b/uv.lock index c70d3e77ef44..ffd4d27d92a7 100644 --- a/uv.lock +++ b/uv.lock @@ -375,6 +375,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, ] +[[package]] +name = "boto3" +version = "1.42.87" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/f6/3f908a1313a8c8d5bf19f0cae3c372dd757569d75e8e3eee4c57fcd4e286/boto3-1.42.87.tar.gz", hash = "sha256:b5b86a826f8f12c7d38679f35bd0135807a6867a21eb8be6dea7c27aeb14ec14", size = 112793, upload-time = "2026-04-09T19:39:50.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/52/69ebde82ce8b47cdee52b2450b2cbf1b3b107e192a3223bf88a567373d85/boto3-1.42.87-py3-none-any.whl", hash = "sha256:15cc1404b3ccbcfe17bd5834d467b1b28d53d9aca44e3798dc44876ac57362e6", size = 140557, upload-time = "2026-04-09T19:39:47.669Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.87" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/59/606c6cb6d42bf8ca3f422c6345401134e56d671385dc2b80fb4b09c51e67/botocore-1.42.87.tar.gz", hash = "sha256:1c6cc9555c1feec50b290a42de70ba6f04826c009562c2c12bb9990d0258482d", size = 15193113, upload-time = "2026-04-09T19:39:37.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/1f/94e1b020fd87b52f092fdd111d9800f2411dc113a0957d0d85b7d2c7f247/botocore-1.42.87-py3-none-any.whl", hash = "sha256:32832df27c039cc1a518289afe0f6006296d3df26603b5f66e911457e67f0146", size = 14871982, upload-time = "2026-04-09T19:39:32.847Z" }, +] + [[package]] name = "cachetools" version = "5.5.2" @@ -1646,6 +1674,7 @@ version = "0.8.0" source = { editable = "." } dependencies = [ { name = "anthropic" }, + { name = "boto3" }, { name = "edge-tts" }, { name = "exa-py" }, { name = "fal-client" }, @@ -1793,6 +1822,7 @@ requires-dist = [ { name = "aiohttp", marker = "extra == 'sms'", specifier = ">=3.9.0,<4" }, { name = "anthropic", specifier = ">=0.39.0,<1" }, { name = "atroposlib", marker = "extra == 'rl'", git = "https://github.com/NousResearch/atropos.git" }, + { name = "boto3", specifier = ">=1.42.87" }, { name = "croniter", marker = "extra == 'cron'", specifier = ">=6.0.0,<7" }, { name = "daytona", marker = "extra == 'daytona'", specifier = ">=0.148.0,<1" }, { name = "debugpy", marker = "extra == 'dev'", specifier = ">=1.8.0,<2" }, @@ -2196,6 +2226,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "joblib" version = "1.5.3" @@ -4363,6 +4402,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, +] + [[package]] name = "safetensors" version = "0.7.0"