Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 15 additions & 11 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
})
Expand All @@ -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.
Expand Down
29 changes: 29 additions & 0 deletions agent/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -620,13 +620,25 @@ 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),
tuple(sorted(str(t) for t in (available_tools or set()))),
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)
Expand Down Expand Up @@ -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]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This prunes every non-curated skill from the index. Current main intentionally preserves all names because models do not reliably rediscover omitted agent-created skills via skills_list (agent/prompt_builder.py:1622-1630; tests/agent/test_prompt_builder.py:429-455). Please retain names and compact descriptions instead.

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, "")
Expand All @@ -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 "
Expand All @@ -801,6 +829,7 @@ def build_skills_system_prompt(
"<available_skills>\n"
+ "\n".join(index_lines) + "\n"
"</available_skills>\n"
+ _extra_skills_note + "\n"
"\n"
"Only proceed without loading a skill if genuinely none are relevant to the task."
)
Expand Down
11 changes: 11 additions & 0 deletions gateway/platforms/whatsapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions model_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
82 changes: 78 additions & 4 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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", {})
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 6 additions & 5 deletions tests/agent/test_auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-preview0.6 (non-thinking lock).
k2-thinking / k2-thinking-turbo1.0 (thinking lock).
kimi-k2.5 / k2-thinking / k2-thinking-turbo1.0.
k2-turbo-preview / k2-0905-preview0.6.
kimi-k2-instruct* and every other model preserve the caller's temperature.
"""

Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions tools/tts_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down