diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 9156eaa26fed..2c2ac9138b52 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -97,17 +97,20 @@ def _normalize_aux_provider(provider: Optional[str]) -> str: _FIXED_TEMPERATURE_MODELS: Dict[str, float] = { "kimi-for-coding": 0.6, + # Kimi Coding Plan now rejects kimi-k2.5 unless temperature is exactly 1.0. + # Keep this explicit so it overrides the broader non-thinking-family default. + "kimi-k2.5": 1.0, + "moonshotai/kimi-k2.5": 1.0, } -# Moonshot's kimi-for-coding endpoint (api.kimi.com/coding) documents: -# "k2.5 model will use a fixed value 1.0, non-thinking mode will use a fixed -# value 0.6. Any other value will result in an error." The same lock applies -# to the other k2.* models served on that endpoint. Enumerated explicitly so -# non-coding siblings like `kimi-k2-instruct` (variable temperature, served on -# the standard chat API and third parties) are NOT clamped. +# Moonshot's kimi-for-coding endpoint (api.kimi.com/coding) documents fixed +# temperatures for the K2 family. In current live behavior, `kimi-k2.5` +# requires exactly 1.0, while the turbo/preview non-thinking siblings still use +# 0.6. Enumerated explicitly so non-coding siblings like `kimi-k2-instruct` +# (variable temperature, served on the standard chat API and third parties) are +# NOT clamped. # Source: https://platform.kimi.ai/docs/guide/kimi-k2-5-quickstart _KIMI_INSTANT_MODELS: frozenset = frozenset({ - "kimi-k2.5", "kimi-k2-turbo-preview", "kimi-k2-0905-preview", }) @@ -120,10 +123,11 @@ def _normalize_aux_provider(provider: Optional[str]) -> str: def _fixed_temperature_for_model(model: Optional[str]) -> Optional[float]: """Return a required temperature override for models with strict contracts. - Moonshot's kimi-for-coding endpoint rejects any non-approved temperature on - the k2.5 family. Non-thinking variants require exactly 0.6; thinking - variants require 1.0. An optional ``vendor/`` prefix (e.g. - ``moonshotai/kimi-k2.5``) is tolerated for aggregator routings. + Moonshot's K2 coding-family endpoints reject non-approved temperatures. + `kimi-k2.5` currently requires exactly 1.0, turbo/preview non-thinking + variants require 0.6, and thinking variants require 1.0. An optional + ``vendor/`` prefix (e.g. ``moonshotai/kimi-k2.5``) is tolerated for + aggregator routings. Returns ``None`` for every other model, including ``kimi-k2-instruct*`` which is the separate non-coding K2 family with variable temperature. diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 2a210434949b..05628ede7253 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -620,6 +620,17 @@ def build_skills_system_prompt( or "" ) disabled = get_disabled_skill_names() + + # Load curated skill filter early so it participates in caching + _sp_skills: "list[str]" = [] + try: + from hermes_cli.config import load_config + _cfg = load_config() + _raw = _cfg.get("skills", {}).get("system_prompt_skills") + if isinstance(_raw, list): + _sp_skills = [str(s).strip() for s in _raw if str(s).strip()] + except Exception: + pass cache_key = ( str(skills_dir.resolve()), tuple(str(d) for d in external_dirs), @@ -627,6 +638,7 @@ def build_skills_system_prompt( tuple(sorted(str(ts) for ts in (available_toolsets or set()))), _platform_hint, tuple(sorted(disabled)), + tuple(sorted(_sp_skills)), ) with _SKILLS_PROMPT_CACHE_LOCK: cached = _SKILLS_PROMPT_CACHE.get(cache_key) @@ -763,6 +775,16 @@ def build_skills_system_prompt( if not skills_by_category: result = "" else: + # Filter to curated system-prompt skills if configured + if _sp_skills: + _allowed = set(_sp_skills) + filtered: dict[str, list[tuple[str, str]]] = {} + for cat, items in skills_by_category.items(): + kept = [(n, d) for n, d in items if n in _allowed] + if kept: + filtered[cat] = kept + skills_by_category = filtered + index_lines = [] for category in sorted(skills_by_category.keys()): cat_desc = category_descriptions.get(category, "") @@ -781,6 +803,12 @@ def build_skills_system_prompt( else: index_lines.append(f" - {name}") + _extra_skills_note = "" + if _sp_skills: + _extra_skills_note = ( + "\nAdditional skills are available — call skills_list() to browse the full catalog." + ) + result = ( "## Skills (mandatory)\n" "Before replying, scan the skills below. If a skill matches or is even partially relevant " @@ -801,6 +829,7 @@ def build_skills_system_prompt( "\n" + "\n".join(index_lines) + "\n" "\n" + + _extra_skills_note + "\n" "\n" "Only proceed without loading a skill if genuinely none are relevant to the task." ) diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index d1de5b856870..39dcabbc3496 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -766,6 +766,17 @@ async def send_video( """Send a video natively via bridge — plays inline in WhatsApp.""" return await self._send_media_to_bridge(chat_id, video_path, "video", caption) + async def send_voice( + self, + chat_id: str, + audio_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + """Send an audio file as a voice note via bridge.""" + return await self._send_media_to_bridge(chat_id, audio_path, "audio", caption) + async def send_document( self, chat_id: str, diff --git a/gateway/run.py b/gateway/run.py index 60c57495b447..2b2e44339269 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6043,8 +6043,17 @@ def _should_send_voice_reply( # When streaming already delivered the text (already_sent=True), # the base adapter will receive None and can't run auto-TTS, # so the runner must take over. + # For platforms without voice-channel auto-TTS (e.g. WhatsApp), + # the runner must always handle voice replies. if is_voice_input and not already_sent: - return False + adapter = self.adapters.get(event.source.platform) + has_auto_tts = ( + adapter + and hasattr(adapter, "play_in_voice_channel") + and hasattr(adapter, "is_in_voice_channel") + ) + if has_auto_tts: + return False return True diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 9040eac0ba45..68f8bf797423 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -693,6 +693,11 @@ def _ensure_hermes_home_managed(home: Path): # always goes to ~/.hermes/skills/. "skills": { "external_dirs": [], # e.g. ["~/.agents/skills", "/shared/team-skills"] + # Curated list of skill names to show in the system prompt index. + # When set, only these skills appear in the auto-injected skills list, + # reducing context bloat. Other skills remain available via skills_list. + # Example: ["github-pr-workflow", "plan", "systematic-debugging"] + "system_prompt_skills": [], }, # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth. diff --git a/model_tools.py b/model_tools.py index 0e8bc877e2cd..68ba98953af1 100644 --- a/model_tools.py +++ b/model_tools.py @@ -412,6 +412,31 @@ def _coerce_value(value: str, expected_type): return _coerce_number(value, integer_only=(expected_type == "integer")) if expected_type == "boolean": return _coerce_boolean(value) + if expected_type == "array": + return _coerce_json(value, list) + if expected_type == "object": + return _coerce_json(value, dict) + return value + + +def _coerce_json(value: str, expected_python_type: type): + """Parse *value* as JSON when the schema expects an array or object. + + Handles model output drift where a complex oneOf/discriminated-union schema + causes the LLM to emit the array/object as a JSON string instead of a native + structure. Returns the original string if parsing fails or yields the wrong + Python type. + """ + try: + parsed = json.loads(value) + except (ValueError, TypeError): + return value + if isinstance(parsed, expected_python_type): + logger.debug( + "coerce_tool_args: coerced string to %s via json.loads", + expected_python_type.__name__, + ) + return parsed return value diff --git a/run_agent.py b/run_agent.py index 85eaad1b3753..4ddd0e9937e6 100644 --- a/run_agent.py +++ b/run_agent.py @@ -919,6 +919,13 @@ def __init__( self._current_tool: str | None = None self._api_call_count: int = 0 + # Tool call loop breaker — detects when the model repeatedly issues + # the *exact same* tool call (same name + same args) and forces a + # state change after N consecutive duplicates. This prevents the + # agent from getting stuck in infinite tool-call loops. + self._tool_call_history: list[tuple[str, str]] = [] # (tool_name, args_hash) + self._tool_call_breach_threshold: int = 3 # consecutive identical calls before breaking + # Rate limit tracking — updated from x-ratelimit-* response headers # after each API call. Accessed by /usage slash command. self._rate_limit_state: Optional["RateLimitState"] = None @@ -1312,6 +1319,12 @@ def __init__( self._memory_flush_min_turns = 6 self._turns_since_memory = 0 self._iters_since_skill = 0 + # Circuit breaker: detect and break infinite tool-call loops. + # Tracks the last (tool_name, args_hash) pair. When the same + # tool+args is called consecutively N times, we abort to force + # the LLM into a different strategy. + self._consecutive_tool_calls: list[tuple[str, str]] = [] + self._circuit_breaker_threshold: int = 3 if not skip_memory: try: mem_config = _agent_cfg.get("memory", {}) @@ -7856,6 +7869,35 @@ def _invoke_tool(self, function_name: str, function_args: dict, effective_task_i if block_message is not None: return json.dumps({"error": block_message}, ensure_ascii=False) + # ── Circuit breaker: detect infinite tool-call loops ───────────── + # Build a stable hash of (tool_name, args) to detect identical + # consecutive calls. When the same tool+args fires N times in a + # row, the LLM is likely stuck in a retry loop — abort and force + # it to try a different approach. + import hashlib as _hashlib + args_key = json.dumps(function_args, sort_keys=True, ensure_ascii=False) + call_sig = f"{function_name}:{_hashlib.md5(args_key.encode('utf-8')).hexdigest()[:16]}" + + # Always record the call (append new entry each time) + self._consecutive_tool_calls.append((function_name, call_sig)) + + # Check if the last N entries are all the same + n = len(self._consecutive_tool_calls) + if n >= self._circuit_breaker_threshold: + # Check if the last N entries all have the same signature + recent_sigs = [s for _, s in self._consecutive_tool_calls[-self._circuit_breaker_threshold:]] + if len(set(recent_sigs)) == 1: + # All same — circuit breaker triggered! + streak = len(self._consecutive_tool_calls) + msg = ( + f"[熔断器触发] 工具 '{function_name}' 连续 {streak} 次调用完全相同的参数," + f"已强制终止。请换用不同的工具或策略。" + ) + logger.warning("Circuit breaker triggered: %s (streak=%d)", function_name, streak) + # Reset streak so future calls can succeed + self._consecutive_tool_calls.clear() + return json.dumps({"error": msg}, ensure_ascii=False) + if function_name == "todo": from tools.todo_tool import todo_tool as _todo_tool return _todo_tool( @@ -8359,12 +8401,44 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe except Exception: pass # never block tool execution + # ── Circuit breaker: detect infinite tool-call loops ────────── + # Same logic as in _invoke_tool() — the sequential path has its + # own inline dispatch and must also be protected. + _cb_bypass = False + if _block_msg is None: + import hashlib as _hashlib + _cb_args_key = json.dumps(function_args, sort_keys=True, ensure_ascii=False) + _cb_call_sig = f"{function_name}:{_hashlib.md5(_cb_args_key.encode('utf-8')).hexdigest()[:16]}" + + # Always record the call + self._consecutive_tool_calls.append((function_name, _cb_call_sig)) + + # Check if the last N entries are all the same + _cb_n = len(self._consecutive_tool_calls) + if _cb_n >= self._circuit_breaker_threshold: + _cb_recent_sigs = [s for _, s in self._consecutive_tool_calls[-self._circuit_breaker_threshold:]] + if len(set(_cb_recent_sigs)) == 1: + _cb_streak = len(self._consecutive_tool_calls) + function_result = json.dumps({ + "error": ( + f"[熔断器触发] 工具 '{function_name}' 连续 {_cb_streak} 次调用完全相同的参数," + f"已强制终止。请换用不同的工具或策略。" + ) + }, ensure_ascii=False) + logger.warning("Circuit breaker triggered (sequential): %s (streak=%d)", function_name, _cb_streak) + self._consecutive_tool_calls.clear() + tool_duration = 0.0 + _cb_bypass = True + tool_start_time = time.time() - if _block_msg is not None: - # Tool blocked by plugin policy — return error without executing. - function_result = json.dumps({"error": _block_msg}, ensure_ascii=False) - tool_duration = 0.0 + if _block_msg is not None or _cb_bypass: + # Tool blocked by plugin policy or circuit breaker — return + # error without executing. (_cb_bypass: function_result was + # already set in the circuit breaker block above.) + if _block_msg is not None: + function_result = json.dumps({"error": _block_msg}, ensure_ascii=False) + tool_duration = 0.0 elif function_name == "todo": from tools.todo_tool import todo_tool as _todo_tool function_result = _todo_tool( diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index aea8152a53e5..9e1f844a83b1 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -699,8 +699,8 @@ def test_500_not_connection(self): class TestKimiForCodingTemperature: """Moonshot kimi-for-coding models require fixed temperatures. - k2.5 / k2-turbo-preview / k2-0905-preview → 0.6 (non-thinking lock). - k2-thinking / k2-thinking-turbo → 1.0 (thinking lock). + kimi-k2.5 / k2-thinking / k2-thinking-turbo → 1.0. + k2-turbo-preview / k2-0905-preview → 0.6. kimi-k2-instruct* and every other model preserve the caller's temperature. """ @@ -780,19 +780,20 @@ async def test_auto_routed_kimi_for_coding_async_call_uses_fixed_temperature(sel @pytest.mark.parametrize( "model,expected", [ - ("kimi-k2.5", 0.6), + ("kimi-k2.5", 1.0), ("kimi-k2-turbo-preview", 0.6), ("kimi-k2-0905-preview", 0.6), ("kimi-k2-thinking", 1.0), ("kimi-k2-thinking-turbo", 1.0), - ("moonshotai/kimi-k2.5", 0.6), + ("moonshotai/kimi-k2.5", 1.0), ("moonshotai/Kimi-K2-Thinking", 1.0), ], ) def test_kimi_k2_family_temperature_override(self, model, expected): """Moonshot kimi-k2.* models only accept fixed temperatures. - Non-thinking models → 0.6, thinking-mode models → 1.0. + kimi-k2.5 and thinking-mode models → 1.0. + turbo/preview non-thinking variants → 0.6. """ from agent.auxiliary_client import _build_call_kwargs diff --git a/tools/tts_tool.py b/tools/tts_tool.py index adc6524c46f7..bab216c83a5f 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -167,8 +167,8 @@ def _convert_to_opus(mp3_path: str) -> Optional[str]: ogg_path = mp3_path.rsplit(".", 1)[0] + ".ogg" try: result = subprocess.run( - ["ffmpeg", "-i", mp3_path, "-acodec", "libopus", - "-ac", "1", "-b:a", "64k", "-vbr", "off", ogg_path, "-y"], + ["ffmpeg", "-i", mp3_path, "-c:a", "libopus", + "-ar", "48000", "-ac", "1", "-b:a", "24k", "-application", "voip", ogg_path, "-y"], capture_output=True, timeout=30, ) if result.returncode != 0: