Merge remote-tracking branch 'origin/main' into bb/contrib-areas

# Conflicts:
#	apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts
This commit is contained in:
Brooklyn Nicholson 2026-07-15 13:22:08 -04:00
commit 5c03e27ce2
88 changed files with 4481 additions and 575 deletions

View file

@ -102,7 +102,6 @@ OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance
from agent.credential_pool import load_pool
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH, get_model_context_length
from agent.process_bootstrap import build_keepalive_http_client
from hermes_cli.config import get_hermes_home
from hermes_constants import OPENROUTER_BASE_URL
from utils import base_url_host_matches, base_url_hostname, env_float, model_forces_max_completion_tokens, normalize_proxy_env_vars
@ -156,22 +155,47 @@ def _resolve_aux_verify(base_url: Optional[str]) -> Any:
return True
_WARNED_KEEPALIVE_IMPORT_SKEW = False
def _openai_http_client_kwargs(
base_url: Optional[str],
*,
async_mode: bool = False,
) -> Dict[str, Any]:
"""Inject keepalive httpx client with env-only proxy (not macOS system proxy)."""
client = build_keepalive_http_client(
str(base_url or ""),
async_mode=async_mode,
verify=_resolve_aux_verify(base_url),
)
try:
from agent.process_bootstrap import build_keepalive_http_client
client = build_keepalive_http_client(
str(base_url or ""),
async_mode=async_mode,
verify=_resolve_aux_verify(base_url),
)
except (ImportError, AttributeError):
# Version-skewed installs (#64333): a process whose sys.path resolves
# an older agent/process_bootstrap.py without this helper — seen when
# the Desktop app's bundled runtime lags a git-installed source tree
# that newer callers (cron scheduler) were written against. Every cron
# job died on this ImportError before any agent logic ran. Degrade
# gracefully to the OpenAI SDK's default httpx client (respects macOS
# system proxy, no pool-level keepalive expiry) instead of failing the
# whole job, and say so once — silent version skew is how this bug
# went unnoticed until jobs were already dead on arrival.
global _WARNED_KEEPALIVE_IMPORT_SKEW
if not _WARNED_KEEPALIVE_IMPORT_SKEW:
_WARNED_KEEPALIVE_IMPORT_SKEW = True
logger.warning(
"agent.process_bootstrap.build_keepalive_http_client is "
"unavailable — mixed/stale install detected (#64333). Falling "
"back to the SDK default HTTP client. Run `hermes update` (or "
"reinstall the Desktop app) to resync the runtime."
)
client = None
if client is None:
return {}
return {"http_client": client}
def _create_openai_client(*, api_key: str, base_url: str, **kwargs: Any) -> Any:
kwargs = {**_openai_http_client_kwargs(base_url), **kwargs}
# Hermes owns auxiliary retry + provider/model fallback policy (the
@ -1337,6 +1361,31 @@ class _AnthropicCompletionsAdapter:
if not _forbids_sampling_params(model):
anthropic_kwargs["temperature"] = temperature
# Pass through caller-supplied extra_body so providers behind
# Anthropic-compatible gateways receive their per-vendor request
# fields (thinking control, metadata, portal tags, ...). The dict
# form is the documented Anthropic SDK passthrough for non-standard
# request body keys; merge on top of whatever build_anthropic_kwargs
# already produced (e.g. fast-mode ``speed``) so call-time settings
# survive. Two exclusions:
# - ``reasoning``: the OpenAI-shaped config dict is TRANSLATED into
# the native ``thinking`` field above (build_anthropic_kwargs);
# forwarding the raw field alongside would double-specify
# reasoning and 400 on strict gateways.
# - ``_``-prefixed keys: private Hermes plumbing (_reasoning_config
# et al.), never wire fields.
caller_extra_body = kwargs.get("extra_body")
if caller_extra_body and isinstance(caller_extra_body, dict):
passthrough = {
k: v for k, v in caller_extra_body.items()
if k != "reasoning" and not str(k).startswith("_")
}
if passthrough:
existing = anthropic_kwargs.get("extra_body") or {}
if not isinstance(existing, dict):
existing = {}
anthropic_kwargs["extra_body"] = {**existing, **passthrough}
response = create_anthropic_message(self._client, anthropic_kwargs)
_transport = get_transport("anthropic_messages")
_nr = _transport.normalize_response(

View file

@ -528,10 +528,19 @@ def _convert_content_to_converse(content) -> List[Dict]:
mime_part = header[5:].split(";")[0]
if mime_part:
media_type = mime_part
# Decode base64 to raw bytes — boto3 re-encodes at the
# wire layer, so passing the base64 string directly
# results in double-encoding and Bedrock rejects it with
# "Failed to sanitize image". Ref: #33317.
import base64
try:
raw_bytes = base64.b64decode(data)
except Exception:
raw_bytes = data.encode("utf-8")
blocks.append({
"image": {
"format": media_type.split("/")[-1] if "/" in media_type else "jpeg",
"source": {"bytes": data},
"source": {"bytes": raw_bytes},
}
})
else:

View file

@ -504,6 +504,29 @@ def interruptible_api_call(agent, api_kwargs: dict):
if _codex_floor:
_stale_timeout = max(_stale_timeout, _codex_floor)
# ── Codex absolute hard ceiling (#64507) ──────────────────────────
# ``openai_codex_stale_timeout_floor`` *raises* the stale timeout (up to
# 1200s at >100k tokens) so healthy gateway-scale payloads aren't aborted.
# The scaled no-byte TTFB watchdog catches dead streams that never emit a
# first byte, but a request that emits SOME bytes and then wedges (the
# issue-64507 symptom: vision-inflated request, worker idle, no ended_at)
# is only reclaimed at the (high) stale floor. Add a flat, finite hard
# ceiling on total request time that ALWAYS applies to openai-codex
# requests regardless of the TTFB/stale interaction, so a stalled request
# is recovered (retry loop / visible failure) instead of hanging
# indefinitely. The default sits ABOVE the maximum stale floor (1200s) so
# it never clamps an intentionally-raised timeout for healthy large
# requests — it is a backstop against unbounded growth, not a tighter
# limit. Tunable via HERMES_CODEX_HARD_TIMEOUT_SECONDS (set to 0 to
# disable the ceiling entirely; that restores the pre-fix behavior).
_codex_hard_timeout = _env_float("HERMES_CODEX_HARD_TIMEOUT_SECONDS", 1500.0)
if (
_codex_watchdog_enabled
and _openai_codex_backend
and _codex_hard_timeout > 0
):
_stale_timeout = min(_stale_timeout, _codex_hard_timeout)
if _est_tokens_for_codex_watchdog > 100_000:
_codex_idle_timeout_default = 180.0
elif _est_tokens_for_codex_watchdog > 50_000:

View file

@ -1118,6 +1118,22 @@ def _normalize_codex_response(
differs from the one that minted the encrypted_content blob and drop
the item instead of triggering HTTP 400 invalid_encrypted_content.
"""
response_status = getattr(response, "status", None)
if isinstance(response_status, str):
response_status = response_status.strip().lower()
else:
response_status = None
incomplete_details = getattr(response, "incomplete_details", None)
incomplete_reason = ""
if isinstance(incomplete_details, dict):
incomplete_reason = str(incomplete_details.get("reason") or "").strip().lower()
elif incomplete_details is not None:
incomplete_reason = str(getattr(incomplete_details, "reason", "") or "").strip().lower()
response_incomplete_content_filter = (
response_status == "incomplete" and incomplete_reason == "content_filter"
)
output = getattr(response, "output", None)
if not isinstance(output, list) or not output:
# The Codex backend can return empty output when the answer was
@ -1134,15 +1150,18 @@ def _normalize_codex_response(
content=[SimpleNamespace(type="output_text", text=out_text.strip())],
)]
response.output = output
elif response_incomplete_content_filter:
# This is a deterministic provider safety block, not a partial
# answer. Synthesize an empty message so finish_reason below becomes
# content_filter and the conversation loop can fallback/surface it
# instead of burning three continuation attempts.
output = [SimpleNamespace(
type="message", role="assistant", status="completed", content=[]
)]
response.output = output
else:
raise RuntimeError("Responses API returned no output items")
response_status = getattr(response, "status", None)
if isinstance(response_status, str):
response_status = response_status.strip().lower()
else:
response_status = None
if response_status in {"failed", "cancelled"}:
error_obj = getattr(response, "error", None)
error_msg = _format_responses_error(error_obj, response_status)
@ -1411,6 +1430,8 @@ def _normalize_codex_response(
if tool_calls:
finish_reason = "tool_calls"
elif response_incomplete_content_filter:
finish_reason = "content_filter"
elif leaked_tool_call_text:
finish_reason = "incomplete"
elif saw_streaming_or_item_incomplete:

View file

@ -586,6 +586,27 @@ def _strip_historical_media(messages: List[Dict[str, Any]]) -> List[Dict[str, An
return result if changed else messages
def _image_part_label(part: Dict[str, Any]) -> str:
"""Render a multimodal image part as a short text label for the summarizer.
Keeps a real, referenceable URL when the image lives at an http(s)
address the summary can then preserve the handle so the agent (or a
later vision_analyze call) can still reach the image after compaction.
Base64 ``data:`` URLs carry no reusable reference and would flood the
summarizer input, so they collapse to ``[image]``.
"""
url = ""
if isinstance(part.get("image_url"), dict):
url = str(part["image_url"].get("url") or "")
elif isinstance(part.get("image_url"), str):
url = part["image_url"]
elif isinstance(part.get("url"), str):
url = part["url"]
if url.startswith(("http://", "https://")):
return f"[image: {url}]"
return "[image]"
def _str_arg(args: dict, key: str, default: str = "") -> str:
"""Safely get a string argument from parsed tool args.
@ -686,7 +707,16 @@ def _summarize_tool_result_unguarded(tool_name: str, tool_args: str, tool_conten
if tool_name == "web_extract":
urls = args.get("urls", [])
url_desc = urls[0] if isinstance(urls, list) and urls else "?"
first = urls[0] if isinstance(urls, list) and urls else "?"
# web_search results are dicts ({"url"/"href": ...}) and models often
# forward them straight into web_extract. Unwrap to the URL string so
# the summary stays readable and the ``+=`` below never hits the
# ``dict + str`` TypeError that would abort pre-compression pruning.
if isinstance(first, dict):
first = first.get("url") or first.get("href") or "?"
elif not isinstance(first, str):
first = "?"
url_desc = first
if isinstance(urls, list) and len(urls) > 1:
url_desc += f" (+{len(urls) - 1} more)"
return f"[web_extract] {url_desc} ({content_len:,} chars)"
@ -1655,7 +1685,24 @@ class ContextCompressor(ContextEngine):
parts = []
for msg in turns:
role = msg.get("role", "unknown")
content = redact_sensitive_text(msg.get("content") or "")
content = msg.get("content")
if isinstance(content, list):
text_parts: list[str] = []
for part in content:
if isinstance(part, dict):
ptype = part.get("type")
if ptype == "text":
text_parts.append(part.get("text", ""))
elif ptype in {"image", "image_url", "input_image"}:
text_parts.append(_image_part_label(part))
else:
# Unknown part type — keep a marker so the
# summarizer knows content existed here.
text_parts.append(f"[{ptype or 'attachment'}]")
elif isinstance(part, str):
text_parts.append(part)
content = "\n".join(text_parts)
content = redact_sensitive_text(content or "")
content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content)
# Strip inline reasoning blocks (<think>, <reasoning>, etc.) from
# assistant content before it reaches the summarizer. Reasoning

View file

@ -1642,12 +1642,16 @@ def run_conversation(
# Check finish_reason before proceeding
if agent.api_mode == "codex_responses":
status = getattr(response, "status", None)
if isinstance(status, str):
status = status.strip().lower()
incomplete_details = getattr(response, "incomplete_details", None)
incomplete_reason = None
if isinstance(incomplete_details, dict):
incomplete_reason = incomplete_details.get("reason")
else:
incomplete_reason = getattr(incomplete_details, "reason", None)
if incomplete_reason is not None:
incomplete_reason = str(incomplete_reason).strip().lower()
if status == "incomplete" and incomplete_reason in {"max_output_tokens", "length"}:
# Responses API max-output exhaustion is a normal
# Codex incomplete turn. Let the Codex-specific
@ -1657,6 +1661,8 @@ def run_conversation(
# emits "Response truncated due to output length
# limit" and stops gateway turns.
finish_reason = "incomplete"
elif status == "incomplete" and incomplete_reason == "content_filter":
finish_reason = "content_filter"
else:
finish_reason = "stop"
elif agent.api_mode == "anthropic_messages":
@ -2619,6 +2625,31 @@ def run_conversation(
)
continue
# ── Bedrock AnthropicBedrock SDK streaming failure ──
# The Anthropic SDK's stream accumulator raises RuntimeError
# "Unexpected event order" when Bedrock returns an error event
# before message_start (throttling, overload, validation).
# Fall back to the native Converse API path for the rest of
# this session — it handles these errors gracefully. Ref: #28156.
if (
isinstance(api_error, RuntimeError)
and "unexpected event order" in str(api_error).lower()
and getattr(agent, "provider", "") == "bedrock"
and agent.api_mode == "anthropic_messages"
and not getattr(agent, "_bedrock_converse_fallback_attempted", False)
):
agent._bedrock_converse_fallback_attempted = True
agent.api_mode = "bedrock_converse"
agent._bedrock_region = getattr(agent, "_bedrock_region", None) or "us-east-1"
agent.client = None # Drop the AnthropicBedrock client
agent._client_kwargs = {}
agent._vprint(
f"{agent.log_prefix}⚠️ AnthropicBedrock SDK streaming failed — "
f"falling back to native Converse API for this session.",
force=True,
)
continue
status_code = getattr(api_error, "status_code", None)
error_context = agent._extract_api_error_context(api_error)
@ -4459,27 +4490,28 @@ def run_conversation(
or interim_has_codex_message_items
):
last_msg = messages[-1] if messages else None
# Duplicate detection: two consecutive incomplete assistant
# messages with identical content AND reasoning are collapsed.
# For provider-state-only changes (encrypted reasoning
# items or replayable message ids/phases/statuses differ
# while visible content/reasoning are unchanged), compare
# those opaque payloads too so we don't silently drop the
# newer continuation state.
last_codex_items = last_msg.get("codex_reasoning_items") if isinstance(last_msg, dict) else None
interim_codex_items = interim_msg.get("codex_reasoning_items")
last_codex_message_items = last_msg.get("codex_message_items") if isinstance(last_msg, dict) else None
interim_codex_message_items = interim_msg.get("codex_message_items")
duplicate_interim = (
# Duplicate detection: compare only visible content
# (content + reasoning). Opaque provider state
# (encrypted reasoning items, message item ids/phases)
# drifts per continuation even when the visible output
# is identical, so including it in the comparison defeats
# dedup and causes message storms (#52711).
visible_duplicate = (
isinstance(last_msg, dict)
and last_msg.get("role") == "assistant"
and last_msg.get("finish_reason") == "incomplete"
and (last_msg.get("content") or "") == (interim_msg.get("content") or "")
and (last_msg.get("reasoning") or "") == (interim_msg.get("reasoning") or "")
and last_codex_items == interim_codex_items
and last_codex_message_items == interim_codex_message_items
)
if not duplicate_interim:
if visible_duplicate:
# Update opaque state in-place so the latest
# provider payload is preserved without emitting
# a duplicate visible message.
for _key in ("codex_reasoning_items", "codex_message_items"):
_new_val = interim_msg.get(_key)
if _new_val is not None:
last_msg[_key] = _new_val
else:
messages.append(interim_msg)
agent._emit_interim_assistant_message(interim_msg)

View file

@ -435,15 +435,27 @@ class ResponsesApiTransport(ProviderTransport):
def validate_response(self, response: Any) -> bool:
"""Check Codex Responses API response has valid output structure.
Returns True only if response.output is a non-empty list.
Does NOT check output_text fallback the caller handles that
with diagnostic logging for stream backfill recovery.
Returns True only if response.output is a non-empty list. Also treats
terminal content-filter incomplete responses as valid: the Responses API
may return status=incomplete with incomplete_details.reason='content_filter'
and no output items. That is a provider refusal signal, not a malformed
response, and must reach normalization so the agent loop can use the
content-policy / fallback path instead of invalid-response retries.
Does NOT check output_text fallback the caller handles that with
diagnostic logging for stream backfill recovery.
"""
if response is None:
return False
output = getattr(response, "output", None)
if not isinstance(output, list) or not output:
return False
status = str(getattr(response, "status", "") or "").strip().lower()
incomplete_details = getattr(response, "incomplete_details", None)
if isinstance(incomplete_details, dict):
reason = str(incomplete_details.get("reason") or "").strip().lower()
else:
reason = str(getattr(incomplete_details, "reason", "") or "").strip().lower()
return status == "incomplete" and reason == "content_filter"
return True
def preflight_kwargs(

View file

@ -262,3 +262,40 @@ test('addWorktree: base param branches off a specified local branch', async () =
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('addWorktree: base origin/main does not set up upstream tracking', async () => {
// Two repos: a bare "remote" and a clone, so origin/main resolves as a
// remote-tracking ref — the condition that triggers auto-tracking.
const remoteDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-remote-'))
const cloneDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-clone-'))
const git = (...args) => execFileSync('git', args, { cwd: cloneDir }).toString().trim()
try {
// Seed the remote with a commit on main. Inline identity so it works
// on CI runners with no global git config.
execFileSync('git', ['init', '-b', 'main', remoteDir])
execFileSync('git', ['-C', remoteDir, '-c', 'user.email=hermes@localhost', '-c', 'user.name=Hermes', 'commit', '--allow-empty', '-m', 'root'])
// Clone so origin/main exists as a remote-tracking ref.
execFileSync('git', ['clone', remoteDir, cloneDir])
const result = await addWorktree(cloneDir, { base: 'origin/main', branch: 'feature-branch', name: 'feature-branch' }, 'git')
assert.equal(result.branch, 'feature-branch')
// The new branch must NOT have an upstream — like `git checkout origin/main
// && git checkout -b feature-branch`, not `git worktree add -b … origin/main`.
let hasUpstream = true
try {
execFileSync('git', ['-C', result.path, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])
} catch {
hasUpstream = false
}
assert.equal(hasUpstream, false)
} finally {
fs.rmSync(remoteDir, { recursive: true, force: true })
fs.rmSync(cloneDir, { recursive: true, force: true })
}
})

View file

@ -265,8 +265,15 @@ async function addWorktree(repoPath, options, gitBin) {
} catch {
// The fetch isn't mandatory, but it would be nice to do if possible.
// If it's not possible, just use the local ref of the remote branch.
// If it doesn't exist locally, we'll get an error anyways
// If it doesn't exist locally, we'll get an error
}
// When branching off a remote-tracking ref, git auto-sets up tracking
// (e.g. `new-branch` → tracks `origin/main`). The user almost certainly
// wants a standalone local branch — like `git checkout origin/main &&
// git checkout -b new-branch` — not a branch silently wired to the
// remote's upstream. `--no-track` prevents that.
args.push('--no-track')
}
args.push(base)

View file

@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
import { Slot as ContribSlot } from '@/contrib/react/slot'
import { useI18n } from '@/i18n'
import { chatMessageText } from '@/lib/chat-messages'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
@ -285,7 +286,7 @@ export function ChatBar({
normalizeComposerEditorDom(editor)
const nextDraft = composerPlainText(editor)
const nextDraft = sanitizeComposerInput(composerPlainText(editor))
if (nextDraft !== draftRef.current) {
draftRef.current = nextDraft
@ -350,7 +351,7 @@ export function ChatBar({
// blank lines (common when selecting from terminals, code blocks, web pages)
// doesn't dump multiline padding into the composer. Internal newlines are
// preserved — only the edges are cleaned up.
const pastedText = event.clipboardData.getData('text').trim()
const pastedText = sanitizeComposerInput(event.clipboardData.getData('text').trim())
if (!pastedText) {
event.preventDefault()

View file

@ -218,6 +218,7 @@ function ReviewDirRow({
<span className="min-w-0 flex-1 truncate" title={node.name}>
{node.name}
</span>
{!open && <DiffCount added={node.added} className="text-[0.64rem] leading-4" removed={node.removed} />}
</div>
{open && node.children && (
<ReviewNodeList animate={animate} depth={depth + 1} motion={useMotion} nodes={node.children} />

View file

@ -7,6 +7,7 @@ import { useI18n } from '@/i18n'
import { stripAnsi } from '@/lib/ansi'
import { type ChatMessage, textPart } from '@/lib/chat-messages'
import { pathLabel, SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { triggerHaptic } from '@/lib/haptics'
import { setMutableRef } from '@/lib/mutable-ref'
import { normalize } from '@/lib/text'
@ -477,7 +478,7 @@ export function usePromptActions({
const submitText = useCallback(
async (rawText: string, options?: SubmitTextOptions) => {
const visibleText = rawText.trim()
const visibleText = sanitizeComposerInput(rawText).trim()
const attachments = options?.attachments ?? $composerAttachments.get()
if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) {
@ -597,7 +598,7 @@ export function usePromptActions({
// window) so the caller can fall back to queueing the words for the next turn.
const steerPrompt = useCallback(
async (rawText: string): Promise<boolean> => {
const text = rawText.trim()
const text = sanitizeComposerInput(rawText).trim()
const sessionId = activeSessionId || activeSessionIdRef.current
if (!text || !sessionId) {

View file

@ -4,6 +4,7 @@ import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
import type { Translations } from '@/i18n'
import { type ChatMessage, textPart } from '@/lib/chat-messages'
import { optimisticAttachmentRef } from '@/lib/chat-runtime'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { setMutableRef } from '@/lib/mutable-ref'
import {
$composerAttachments,
@ -88,7 +89,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
return useCallback(
async (rawText: string, options?: SubmitTextOptions) => {
const visibleText = rawText.trim()
const visibleText = sanitizeComposerInput(rawText).trim()
const usingComposerAttachments = !options?.attachments
// Drop undefined/null holes a session switch or draft restore can leave in

View file

@ -186,3 +186,139 @@ describe('ModelSettings', () => {
expect(await screen.findByText(/still run on/)).toBeTruthy()
})
})
describe('ModelSettings MoA preset editor', () => {
const moaConfig = () => ({
default_preset: 'default',
active_preset: '',
presets: {
default: {
reference_models: [
{ provider: 'nous', model: 'hermes-4' },
{ provider: 'openrouter', model: 'deepseek/deepseek-v4-pro' }
],
aggregator: { provider: 'openrouter', model: 'anthropic/claude-opus-4.8' },
reference_temperature: 0,
aggregator_temperature: 0,
max_tokens: 4096,
enabled: true
}
},
reference_models: [
{ provider: 'nous', model: 'hermes-4' },
{ provider: 'openrouter', model: 'deepseek/deepseek-v4-pro' }
],
aggregator: { provider: 'openrouter', model: 'anthropic/claude-opus-4.8' },
reference_temperature: 0,
aggregator_temperature: 0,
max_tokens: 4096,
enabled: true
})
beforeEach(() => {
getGlobalModelOptions.mockResolvedValue({
providers: [
{
name: 'Nous',
slug: 'nous',
models: ['hermes-4', 'hermes-4-mini'],
authenticated: true,
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
},
{
name: 'OpenRouter',
slug: 'openrouter',
models: ['deepseek/deepseek-v4-pro', 'anthropic/claude-opus-4.8'],
authenticated: true
}
]
})
getMoaModels.mockResolvedValue(moaConfig())
saveMoaModels.mockImplementation((body: unknown) => Promise.resolve(body))
})
async function openReferenceEditor() {
await renderModelSettings()
expect(await screen.findByText('Reference 1')).toBeTruthy()
}
function slotSelects() {
// Combobox order in the MoA section (last 7 on the page): preset select,
// then provider+model per reference (2 refs), then aggregator
// provider+model. Reference 1's pair is therefore at -6 / -5.
const all = screen.getAllByRole('combobox')
return { ref1Provider: all.at(-6)!, ref1Model: all.at(-5)! }
}
it('holds the autosave while a slot is half-filled (provider changed, model pending)', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
await openReferenceEditor()
fireEvent.click(slotSelects().ref1Provider)
fireEvent.click(await screen.findByRole('option', { name: 'OpenRouter' }))
// Model was cleared by the provider change → config incomplete → the
// debounced autosave must NOT fire, even well past the 600ms window.
await vi.advanceTimersByTimeAsync(2000)
expect(saveMoaModels).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('saves once the model pick completes the slot', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
await openReferenceEditor()
fireEvent.click(slotSelects().ref1Provider)
fireEvent.click(await screen.findByRole('option', { name: 'OpenRouter' }))
await vi.advanceTimersByTimeAsync(700)
fireEvent.click(slotSelects().ref1Model)
fireEvent.click(await screen.findByRole('option', { name: 'anthropic/claude-opus-4.8' }))
await vi.advanceTimersByTimeAsync(700)
expect(saveMoaModels).toHaveBeenCalledTimes(1)
const sent = saveMoaModels.mock.calls[0][0] as ReturnType<typeof moaConfig>
expect(sent.presets.default.reference_models[0]).toEqual({
provider: 'openrouter',
model: 'anthropic/claude-opus-4.8'
})
// The untouched slots ride along unchanged — nothing reverts to defaults.
expect(sent.presets.default.reference_models[1]).toEqual({
provider: 'openrouter',
model: 'deepseek/deepseek-v4-pro'
})
expect(sent.presets.default.aggregator).toEqual({
provider: 'openrouter',
model: 'anthropic/claude-opus-4.8'
})
} finally {
vi.useRealTimers()
}
})
it('does not clear the model or save when the same provider is re-selected', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
await openReferenceEditor()
fireEvent.click(slotSelects().ref1Provider)
fireEvent.click(await screen.findByRole('option', { name: 'Nous' }))
await vi.advanceTimersByTimeAsync(700)
// Radix treats re-picking the current value as a no-op (no
// onValueChange), so nothing changes: no save, model still shown.
expect(saveMoaModels).not.toHaveBeenCalled()
expect(screen.getByText('nous · hermes-4')).toBeTruthy()
} finally {
vi.useRealTimers()
}
})
})

View file

@ -130,6 +130,24 @@ const NO_PROVIDERS: readonly ModelOptionProvider[] = [{ name: '—', slug: '', m
export const withActive = (models: readonly string[], active: string): readonly string[] =>
active && !models.includes(active) ? [active, ...models] : models
// A slot is complete when both halves are chosen. Changing a slot's provider
// intentionally clears its model (see updateMoaSlot), so every provider change
// passes through an incomplete state while the user picks the new model.
export const moaSlotComplete = (slot: MoaModelSlot): boolean => !!(slot.provider.trim() && slot.model.trim())
// True when every slot in every preset is fully specified — the only state
// that is safe to persist. The backend rejects configs with half-filled slots
// (HTTP 422) instead of silently swapping the preset for hardcoded defaults
// (#64156), so the autosave must simply wait for the edit to finish rather
// than trying to "repair" the payload.
export const moaConfigComplete = (config: MoaConfigResponse): boolean =>
Object.values(config.presets).every(
preset =>
preset.reference_models.length > 0 &&
preset.reference_models.every(moaSlotComplete) &&
moaSlotComplete(preset.aggregator)
)
interface StaleAuxWarningProps {
applying: boolean
onReset: () => void
@ -317,19 +335,44 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
[]
)
// Guard against stale save responses overwriting newer state.
const moaSaveGeneration = useRef(0)
// Quiet debounced persist for inline MoA edits — mirrors the config page's
// autosave so slot/aggregator tweaks save themselves, matching the
// preset-level ops (set default / add / delete) that already persist on
// click. No `applying` spinner, so selecting stays responsive.
//
// While any slot is half-filled (provider picked, model pending) the save is
// HELD, not sent: the previous complete config stays on disk and the next
// edit that completes the slot flushes the whole preset. Every edit bumps
// the generation so an in-flight response from an older save can never
// repaint over the user's mid-edit state.
const scheduleMoaSave = useCallback((next: MoaConfigResponse) => {
if (moaSaveTimer.current) {
window.clearTimeout(moaSaveTimer.current)
moaSaveTimer.current = null
}
const generation = moaSaveGeneration.current + 1
moaSaveGeneration.current = generation
if (!moaConfigComplete(next)) {
return
}
moaSaveTimer.current = window.setTimeout(() => {
void saveMoaModels(next)
.then(setMoa)
.catch(err => setError(err instanceof Error ? err.message : String(err)))
.then(saved => {
if (moaSaveGeneration.current === generation) {
setMoa(saved)
}
})
.catch(err => {
if (moaSaveGeneration.current === generation) {
setError(err instanceof Error ? err.message : String(err))
}
})
}, 600)
}, [])
@ -359,7 +402,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const updateMoaSlot = useCallback((slot: MoaModelSlot, patch: Partial<MoaModelSlot>): MoaModelSlot => {
const next = { ...slot, ...patch }
if (patch.provider) {
// Picking a new provider invalidates the model choice (models are
// per-provider). A same-provider update must not wipe the model — Radix
// filters same-value changes, but programmatic callers may not.
if (patch.provider && patch.provider !== slot.provider) {
next.model = ''
}
@ -368,6 +414,16 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const saveMoa = useCallback(async (next: MoaConfigResponse) => {
const epoch = profileEpoch.current
// Explicit preset ops (set default / add / delete) supersede any pending
// debounced slot autosave — cancel it and invalidate in-flight responses
// so the two writers can't race each other's state.
if (moaSaveTimer.current) {
window.clearTimeout(moaSaveTimer.current)
moaSaveTimer.current = null
}
moaSaveGeneration.current += 1
setApplying(true)
setError('')
@ -991,11 +1047,15 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
<SelectValue placeholder={m.provider} />
</SelectTrigger>
<SelectContent>
{moaSlotProviderOptions.map(provider => (
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
{provider.name}
</SelectItem>
))}
{withActive(moaSlotProviderOptions.map(p => p.slug || 'none'), slot.provider).map(slug => {
const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug)
return (
<SelectItem key={slug} value={slug}>
{provider?.name || slug}
</SelectItem>
)
})}
</SelectContent>
</Select>
<Select
@ -1037,10 +1097,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
}
description={
<span className="font-mono text-[0.68rem]">
{slot.provider} · {slot.model}
{slot.provider} · {slot.model || m.model}
</span>
}
key={`${selectedMoaPreset}-${slot.provider}-${slot.model}-${index}`}
key={`${selectedMoaPreset}-${index}`}
title={`Reference ${index + 1}`}
/>
))}
@ -1070,11 +1130,15 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
<SelectValue placeholder={m.provider} />
</SelectTrigger>
<SelectContent>
{moaSlotProviderOptions.map(provider => (
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
{provider.name}
</SelectItem>
))}
{withActive(moaSlotProviderOptions.map(p => p.slug || 'none'), currentMoaPreset.aggregator.provider).map(slug => {
const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug)
return (
<SelectItem key={slug} value={slug}>
{provider?.name || slug}
</SelectItem>
)
})}
</SelectContent>
</Select>
<Select

View file

@ -57,6 +57,7 @@ import { Codicon } from '@/components/ui/codicon'
import type { HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runtime'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { Loader2Icon } from '@/lib/icons'
@ -188,7 +189,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
const syncDraftFromEditor = useCallback(
(editor: HTMLDivElement) => {
const nextDraft = composerPlainText(editor)
const nextDraft = sanitizeComposerInput(composerPlainText(editor))
if (nextDraft !== draftRef.current) {
draftRef.current = nextDraft
@ -477,7 +478,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
}
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
const pastedText = event.clipboardData.getData('text')
const pastedText = sanitizeComposerInput(event.clipboardData.getData('text'))
if (!pastedText || DATA_IMAGE_URL_RE.test(pastedText.trim())) {
event.preventDefault()

View file

@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import {
collapseRepeatedInputArtifacts,
sanitizeComposerInput,
stripLeakedBracketedPasteWrappers
} from './composer-input-sanitize'
describe('stripLeakedBracketedPasteWrappers', () => {
it('leaves plain text unchanged', () => {
expect(stripLeakedBracketedPasteWrappers('hello world')).toBe('hello world')
})
it('strips canonical escape wrappers', () => {
expect(stripLeakedBracketedPasteWrappers('\x1b[200~hello\x1b[201~')).toBe('hello')
})
it('keeps embedded literal bracket forms', () => {
const text = 'literal[200~tag and literal[201~tag should stay'
expect(stripLeakedBracketedPasteWrappers(text)).toBe(text)
})
})
describe('collapseRepeatedInputArtifacts', () => {
it('removes the desktop corruption tail from #62557', () => {
const prefix = '需要时随时叫我。'
const tail = '[e~[[e' + '~[[e'.repeat(20)
expect(collapseRepeatedInputArtifacts(prefix + tail)).toBe(prefix)
})
it('preserves a mid-string marker followed by valid suffix', () => {
const text = 'notes ~[[e more text here'
expect(collapseRepeatedInputArtifacts(text)).toBe(text)
})
it('preserves trailing punctuation that is not the corruption signature', () => {
expect(collapseRepeatedInputArtifacts('wait....')).toBe('wait....')
})
it('does not strip when fewer than minRepeats markers appear at the tail', () => {
const text = 'hello~[[e~[[e'
expect(collapseRepeatedInputArtifacts(text)).toBe(text)
})
})
describe('sanitizeComposerInput', () => {
it('normalizes wrappers and repeated artifact tails together', () => {
const corrupted = 'hello[' + '~[[e'.repeat(8)
expect(sanitizeComposerInput(corrupted)).toBe('hello')
})
})

View file

@ -0,0 +1,71 @@
/**
* Strip terminal bracketed-paste leaks and repeated artifact tails from composer
* text before it is shown in the UI or sent to the gateway.
*
* Mirrors hermes_cli/input_sanitize.py (CLI/TUI gateway defensive path).
*/
const BRACKETED_PASTE_BOUNDARY_START = /(^|[\s\n>:\]\)])\[200~/g
const BRACKETED_PASTE_BOUNDARY_END = /\[201~(?=$|[\s\n<[():;.,!?])/g
const BRACKETED_PASTE_DEGRADED_START = /(^|[\s\n>:\]\)])00~/g
const BRACKETED_PASTE_DEGRADED_END = /01~(?=$|[\s\n<[():;.,!?])/g
const DESKTOP_PASTE_ARTIFACT = '~[[e'
/** Strip leaked bracketed-paste wrapper markers from user-visible text. */
export function stripLeakedBracketedPasteWrappers(text: string): string {
if (!text) {
return text
}
let cleaned = text
.replace(/\x1b\[200~/g, '')
.replace(/\x1b\[201~/g, '')
.replace(/\^\[\[200~/g, '')
.replace(/\^\[\[201~/g, '')
cleaned = cleaned.replace(BRACKETED_PASTE_BOUNDARY_START, '$1')
cleaned = cleaned.replace(BRACKETED_PASTE_BOUNDARY_END, '')
cleaned = cleaned.replace(BRACKETED_PASTE_DEGRADED_START, '$1')
cleaned = cleaned.replace(BRACKETED_PASTE_DEGRADED_END, '')
return cleaned
}
/** Drop a trailing run of the desktop ~[[e corruption signature (#62557). */
export function collapseRepeatedInputArtifacts(text: string, minRepeats = 4): string {
if (!text) {
return text
}
const marker = DESKTOP_PASTE_ARTIFACT
let index = text.length
let repeatCount = 0
while (index >= marker.length && text.slice(index - marker.length, index) === marker) {
repeatCount += 1
index -= marker.length
}
if (repeatCount < minRepeats) {
return text
}
let start = index
if (start >= 2 && text.slice(start - 2, start) === '[e') {
start -= 2
} else if (start >= 1 && text[start - 1] === '[') {
start -= 1
}
return text.slice(0, start)
}
/** Normalize composer text before submit or draft persistence. */
export function sanitizeComposerInput(text: string): string {
if (!text) {
return text
}
return collapseRepeatedInputArtifacts(stripLeakedBracketedPasteWrappers(text))
}

View file

@ -857,6 +857,8 @@ export interface AuxiliaryModelsResponse {
export interface MoaModelSlot {
provider: string
model: string
/** Optional per-slot reasoning effort — round-tripped, not edited here. */
reasoning_effort?: string
}
export interface MoaConfigResponse {
@ -871,6 +873,10 @@ export interface MoaConfigResponse {
max_tokens: number
reference_models: MoaModelSlot[]
reference_temperature: number
/** Optional advisor output cap — round-tripped, not edited here. */
reference_max_tokens?: number | null
/** Fan-out cadence (per_iteration | user_turn) — round-tripped. */
fanout?: string
}
>
aggregator: MoaModelSlot

24
cli.py
View file

@ -2995,29 +2995,9 @@ def _should_auto_attach_clipboard_image_on_paste(pasted_text: str) -> bool:
def _strip_leaked_bracketed_paste_wrappers(text: str) -> str:
"""Strip leaked bracketed-paste wrapper markers from user-visible text.
from hermes_cli.input_sanitize import strip_leaked_bracketed_paste_wrappers
Defensive normalization for cases where terminal/prompt_toolkit parsing
fails and bracketed-paste markers end up in the buffer as literal text.
We strip canonical wrappers unconditionally and also handle degraded
visible forms like ``[200~`` / ``[201~`` and ``00~`` / ``01~`` when they
look like wrapper boundaries, not arbitrary user content.
"""
if not text:
return text
text = (
text.replace("\x1b[200~", "")
.replace("\x1b[201~", "")
.replace("^[[200~", "")
.replace("^[[201~", "")
)
text = re.sub(r"(^|[\s\n>:\]\)])\[200~", r"\1", text)
text = re.sub(r"\[201~(?=$|[\s\n<\[\(\):;.,!?])", "", text)
text = re.sub(r"(^|[\s\n>:\]\)])00~", r"\1", text)
text = re.sub(r"01~(?=$|[\s\n<\[\(\):;.,!?])", "", text)
return text
return strip_leaked_bracketed_paste_wrappers(text)
def _apply_bracketed_paste_timeout_patch() -> None:

117
docs/profile-routing.md Normal file
View file

@ -0,0 +1,117 @@
# Profile-Based Routing for Inbound Messages
> **Audience:** Gateway operators and contributors
> **Source files:** `gateway/profile_routing.py`, `gateway/run.py` (`_profile_name_for_source`), `gateway/platforms/base.py` (`build_source`), `gateway/config.py`
> **Related:** [Session Lifecycle](session-lifecycle.md), `docs/design/profile-builder.md`
## Overview
By default a single gateway run uses one profile (memory, persona, tools). **Profile-based
routing** lets one gateway instance serve **multiple isolated profiles**, selecting which
profile handles an inbound message based on *where the message came from* — the platform,
server (`guild_id`), channel (`chat_id`), and/or thread (`thread_id`).
This is the inbound counterpart to multiplexing: instead of running N gateways, run one
gateway and route per-community / per-channel / per-thread to a dedicated profile. Each
profile keeps fully isolated state (`MEMORY.md`, `USER.md`, `SOUL.md`, sessions, tools).
Routing is **platform-generic**: it works for Discord, Telegram, Feishu, Slack, and every
adapter — not just Discord.
## Configuring routes
Routes live under `profile_routes` in `config.yaml`. Both the top-level and the nested
`gateway.profile_routes` forms are accepted (the nested form is what
`hermes config set gateway.profile_routes ...` writes).
```yaml
profile_routes:
# Route an entire Discord server (guild) to one profile.
- name: server-default
platform: discord
guild_id: "1234567890"
profile: server-profile
# Override a specific channel within that server with a different profile.
- name: support-channel
platform: discord
guild_id: "1234567890"
chat_id: "9876543210"
profile: support-profile
# Pin a Telegram group to a profile (Telegram has no guild_id — chat_id only).
- name: tg-group
platform: telegram
chat_id: "-1001234567890"
profile: tg-profile
# Route a single Discord thread.
- name: standup-thread
platform: discord
guild_id: "1234567890"
chat_id: "9876543210"
thread_id: "1111111111"
profile: standup
```
### Fields
| Field | Required | Description |
|---|---|---|
| `name` | yes | Human-readable route identifier (used in logs). |
| `platform` | yes | Adapter platform: `discord`, `telegram`, `feishu`, `slack`, … |
| `profile` | yes | Target profile name (must exist under `~/.hermes/profiles/<name>`). |
| `guild_id` | no | Server/guild (Discord). |
| `chat_id` | no | Channel/group/DM id. |
| `thread_id` | no | Thread id within a channel. |
| `enabled` | no | Default `true`; set `false` to disable a route without removing it. |
## Matching rules
A route matches an inbound source when **every discriminator the route declares is satisfied**
(conjunctive / AND). A field the route leaves unset is ignored.
- **`platform`** must equal the source platform exactly.
- **`thread_id`** (if set) must equal the source thread id.
- **`chat_id`** (if set) must match the source channel **or** its parent — a thread in a
channel matches the channel's route (hierarchical match for Discord forums/threads).
- **`guild_id`** (if set) must equal the source guild.
> A route declaring **both** `guild_id` and `chat_id` requires both to hold. A channel match
> alone does not satisfy a guild constraint — this is intentional and tested.
When multiple routes match, the **most specific** one wins. Specificity is additive:
| Discriminator | Weight |
|---|---|
| `thread_id` | 8 |
| `chat_id` | 4 |
| `guild_id` | 2 |
| (platform only) | 0 |
So a thread route (8) beats a channel route (4) beats a guild route (2) within the same server.
If no route matches, the message uses the default/active profile.
## How it works at runtime
1. An inbound message arrives at a platform adapter.
2. `BasePlatformAdapter.build_source` builds the `SessionSource` for the message. Every
adapter carries a back-reference to the running `GatewayRunner`
(`gateway_runner`, injected in `gateway/run.py`), so it asks the runner to resolve the
target profile via `_profile_name_for_source`.
3. `_profile_name_for_source` runs the configured routes through `match_profile_route` and
stamps `source.profile` with the winning route's profile (or leaves it unset).
4. Downstream, `_resolve_profile_home_for_source` chooses the profile home directory
(`source.profile` → active profile → `default`) and the session is scoped per-profile, so
each routed community gets isolated memory and conversation state.
Because `gateway_runner` is injected for **all** adapters (declared on `BasePlatformAdapter`),
every platform goes through this path — not just Discord.
## Relationship to multiplexing
`profile_routes` requires `gateway.multiplex_profiles: true`. Multiplexing is what
activates the per-profile runtime scope (per-profile `HERMES_HOME`, secret scope, and
profile-namespaced session keys); routing is the decision layer that picks *which*
profile a given guild/channel/thread lands in. With multiplexing off, `profile_routes`
is ignored entirely — behavior is byte-identical to a single-profile gateway.

View file

@ -12,7 +12,7 @@ import logging
import os
import json
from pathlib import Path
from dataclasses import dataclass, field
from dataclasses import asdict, dataclass, field, is_dataclass
from typing import Dict, List, Optional, Any, Callable
from enum import Enum
@ -721,6 +721,11 @@ class GatewayConfig:
# fresh session exactly as if the reset policy had fired. 0 = disabled.
session_store_max_age_days: int = 90
# Profile-based routing: route specific guilds/channels/threads to
# different profiles. See gateway/profile_routing.py. Each entry is a
# dict with: name, platform, profile, and optional guild_id/chat_id/thread_id.
profile_routes: list = field(default_factory=list)
def get_connected_platforms(self) -> List[Platform]:
"""Return list of platforms that are enabled and configured."""
connected = []
@ -827,6 +832,10 @@ class GatewayConfig:
"unauthorized_dm_behavior": self.unauthorized_dm_behavior,
"streaming": self.streaming.to_dict(),
"session_store_max_age_days": self.session_store_max_age_days,
"profile_routes": [
asdict(r) if is_dataclass(r) and not isinstance(r, type) else r
for r in self.profile_routes
],
}
@classmethod
@ -919,6 +928,10 @@ class GatewayConfig:
except (TypeError, ValueError):
session_store_max_age_days = 90
# Parse profile routes (validated by gateway.profile_routing)
from gateway.profile_routing import parse_profile_routes
profile_routes = parse_profile_routes(data.get("profile_routes") or [])
return cls(
platforms=platforms,
default_reset_policy=default_policy,
@ -941,6 +954,7 @@ class GatewayConfig:
unauthorized_dm_behavior=unauthorized_dm_behavior,
streaming=StreamingConfig.from_dict(data.get("streaming", {})),
session_store_max_age_days=session_store_max_age_days,
profile_routes=profile_routes,
)
def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str:
@ -1053,6 +1067,17 @@ def load_gateway_config() -> GatewayConfig:
if "multiplex_profiles" in yaml_cfg:
gw_data["multiplex_profiles"] = yaml_cfg["multiplex_profiles"]
# Profile-based routing rules: accept either top-level
# ``profile_routes`` or the nested ``gateway.profile_routes`` form
# (matching the multiplex_profiles parity above).
_pr = yaml_cfg.get("profile_routes")
if _pr is None:
_gw_section = yaml_cfg.get("gateway")
if isinstance(_gw_section, dict):
_pr = _gw_section.get("profile_routes")
if isinstance(_pr, list):
gw_data["profile_routes"] = _pr
gateway_section = yaml_cfg.get("gateway")
if isinstance(gateway_section, dict):
if "multiplex_profiles" in gateway_section and "multiplex_profiles" not in gw_data:

View file

@ -2353,6 +2353,16 @@ class BasePlatformAdapter(ABC):
# generic seam; Slack is merely the first consumer).
supports_inchannel_continuable: bool = False
# Back-reference to the running ``GatewayRunner``, injected by
# ``gateway/run.py`` after the adapter is created. Adapters consume it via
# ``getattr(self, "gateway_runner", None)`` for cross-platform delivery and
# — critically — for inbound profile routing: ``build_source`` resolves the
# target profile through ``runner._profile_name_for_source(...)``. Declaring
# it on the base (rather than only on adapters that happen to pre-declare
# it) means EVERY platform adapter receives the injection, so profile
# routing is platform-generic instead of Discord-only.
gateway_runner = None # type: ignore[assignment] # set by gateway/run.py
def __init__(self, config: PlatformConfig, platform: Platform):
self.config = config
self.platform = platform
@ -5507,10 +5517,47 @@ class BasePlatformAdapter(ABC):
auto_thread_created: bool = False,
auto_thread_initial_name: Optional[str] = None,
) -> SessionSource:
"""Helper to build a SessionSource for this platform."""
"""Helper to build a SessionSource for this platform.
When ``gateway.profile_routes`` is configured, the routing engine
resolves the matching profile from guild/chat/thread and stamps it on
``source.profile``. Downstream code (``_resolve_profile_home_for_source``
in run.py) reads that field to enter ``_profile_runtime_scope`` for
per-profile HERMES_HOME isolation.
"""
# Normalize empty topic to None
if chat_topic is not None and not chat_topic.strip():
chat_topic = None
# Resolve profile from configured routes (None when no match / no routes)
profile = None
runner = getattr(self, "gateway_runner", None)
if runner is not None:
try:
profile = runner._profile_name_for_source(
SessionSource(
platform=self.platform,
chat_id=str(chat_id),
chat_name=chat_name,
chat_type=chat_type,
user_id=str(user_id) if user_id else None,
user_name=user_name,
thread_id=str(thread_id) if thread_id else None,
chat_topic=chat_topic.strip() if chat_topic else None,
user_id_alt=user_id_alt,
chat_id_alt=chat_id_alt,
is_bot=is_bot,
guild_id=str(guild_id) if guild_id else None,
parent_chat_id=str(parent_chat_id) if parent_chat_id else None,
message_id=str(message_id) if message_id else None,
)
)
except Exception:
logger.warning(
"Profile resolution failed for %s/%s, defaulting to active profile",
self.platform, chat_id, exc_info=True,
)
return SessionSource(
platform=self.platform,
chat_id=str(chat_id),
@ -5527,6 +5574,7 @@ class BasePlatformAdapter(ABC):
guild_id=str(guild_id) if guild_id else None,
parent_chat_id=str(parent_chat_id) if parent_chat_id else None,
message_id=str(message_id) if message_id else None,
profile=profile,
role_authorized=role_authorized,
auto_thread_created=auto_thread_created,
auto_thread_initial_name=auto_thread_initial_name,

View file

@ -1515,6 +1515,7 @@ class WeixinAdapter(BasePlatformAdapter):
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
profile=event.source.profile,
)
def _enqueue_text_event(self, event: MessageEvent) -> None:

166
gateway/profile_routing.py Normal file
View file

@ -0,0 +1,166 @@
"""Profile-based routing for the gateway with hierarchical matching.
Allows a single Hermes instance to route specific Discord guilds/channels/threads
to different profiles each with their own model, tools, memory, and persona.
Matching priority (most specific first):
1. platform + chat_id + thread_id (exact thread) specificity 14
2. platform + chat_id (channel route) specificity 6
3. platform + guild_id (guild/server route) specificity 2
4. No match default profile
Parent-chain matching:
For Discord threads and forum posts, ``parent_chat_id`` carries the
direct parent (the channel for a thread, the forum channel for a post).
Routes keyed on a channel match both direct messages and messages in
any thread/post whose parent is that channel.
Configuration (config.yaml):
gateway:
profile_routes:
- name: server-default
platform: discord
guild_id: "YOUR_GUILD_ID"
profile: server-profile
- name: special-channel
platform: discord
guild_id: "YOUR_GUILD_ID"
chat_id: "YOUR_CHANNEL_ID"
profile: channel-profile
- name: thread-route
platform: discord
chat_id: "YOUR_CHANNEL_ID"
thread_id: "YOUR_THREAD_ID"
profile: thread-profile
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import logging
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ProfileRoute:
"""A single routing rule that maps a platform scope to a profile."""
name: str
platform: str
profile: str
guild_id: Optional[str] = None
chat_id: Optional[str] = None
thread_id: Optional[str] = None
enabled: bool = True
@property
def specificity(self) -> int:
"""Higher value = more specific match."""
s = 0
if self.guild_id:
s += 2
if self.chat_id:
s += 4
if self.thread_id:
s += 8
return s
def matches(
self,
platform: str,
guild_id: Optional[str] = None,
chat_id: Optional[str] = None,
thread_id: Optional[str] = None,
parent_chat_id: Optional[str] = None,
) -> bool:
"""Return True if this route matches the given source fields.
All configured discriminators are matched conjunctively (AND): every
discriminator that the route declares must hold. ``chat_id`` supports
hierarchical matching for Discord forums/threads:
- Direct channel match: chat_id == route.chat_id
- Thread in channel: parent_chat_id == route.chat_id
A route declaring both ``guild_id`` and ``chat_id`` requires both to
match (a chat match alone does not satisfy a guild constraint).
"""
if not self.enabled:
return False
if self.platform != platform:
return False
if self.thread_id and self.thread_id != thread_id:
return False
if self.chat_id and self.chat_id != chat_id and self.chat_id != parent_chat_id:
return False
if self.guild_id and self.guild_id != guild_id:
return False
return True
def parse_profile_routes(raw: Optional[List[Dict[str, Any]]]) -> List[ProfileRoute]:
"""Parse profile_routes from config.yaml into ProfileRoute objects.
Returns routes sorted by specificity (most specific first).
"""
if not raw:
return []
routes: List[ProfileRoute] = []
for entry in raw:
if not isinstance(entry, dict):
continue
name = entry.get("name", "")
platform = entry.get("platform", "")
profile = entry.get("profile", "")
if not platform or not profile:
logger.warning(
"Skipping profile route %s: missing platform or profile",
name,
)
continue
# Validate profile name to prevent path traversal. Lazy import avoids a
# circular dependency at module load time.
try:
from hermes_cli.profiles import (
normalize_profile_name,
validate_profile_name,
)
profile = normalize_profile_name(profile)
validate_profile_name(profile)
except (ValueError, ImportError):
logger.warning("Skipping profile route %s: invalid profile name %r", name, profile)
continue
routes.append(
ProfileRoute(
name=name,
platform=platform,
profile=profile,
guild_id=entry.get("guild_id"),
chat_id=entry.get("chat_id"),
thread_id=entry.get("thread_id"),
enabled=entry.get("enabled", True),
)
)
# Sort: most specific first so the first match wins.
routes.sort(key=lambda r: r.specificity, reverse=True)
logger.debug("Loaded %d profile routes (most-specific-first)", len(routes))
return routes
def match_profile_route(
routes: List[ProfileRoute],
platform: str,
guild_id: Optional[str] = None,
chat_id: Optional[str] = None,
thread_id: Optional[str] = None,
parent_chat_id: Optional[str] = None,
) -> Optional[ProfileRoute]:
"""Return the best-matching route, or None for no match."""
for route in routes:
if route.matches(platform, guild_id=guild_id, chat_id=chat_id, thread_id=thread_id, parent_chat_id=parent_chat_id):
return route
return None

View file

@ -2667,6 +2667,8 @@ def _normalize_empty_agent_response(
)
return response
if api_calls > 0:
if _is_gateway_hidden_reasoning_incomplete_turn(agent_result):
return ""
if agent_result.get("partial"):
err = agent_result.get("error", "processing incomplete")
return f"⚠️ Processing stopped: {str(err)[:200]}. Try again."
@ -2694,6 +2696,30 @@ def _normalize_empty_agent_response(
return response
def _is_gateway_hidden_reasoning_incomplete_turn(agent_result: dict) -> bool:
"""Detect retry-exhausted turns with hidden reasoning but no visible answer.
The conversation loop returns the retry-exhaustion sentinel as BOTH
``final_response`` and ``error`` ("Codex response remained incomplete
after 3 continuation attempts"), so ``final_response`` being non-empty
does not mean the model produced a visible answer. Treat the turn as
hidden when the error sentinel is present and ``final_response`` is
either empty or merely echoes that sentinel any genuinely different
final text means the model DID answer and must be delivered.
"""
if not isinstance(agent_result, dict):
return False
if agent_result.get("failed") or agent_result.get("interrupted"):
return False
if not agent_result.get("partial"):
return False
error_text = str(agent_result.get("error", "") or "").strip()
if "remained incomplete after" not in error_text.lower():
return False
final_response = str(agent_result.get("final_response") or "").strip()
return not final_response or final_response == error_text
def _should_clear_resume_pending_after_turn(agent_result: dict) -> bool:
"""Return True only when a gateway turn really completed successfully.
@ -8759,6 +8785,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if isinstance(val, str) and val.strip():
token = val.strip()
break
# Many adapters (e.g. Discord) store the token on their `config`
# sub-object rather than directly on the adapter. Without this lookup
# those adapters all return None here, the same-token conflict check
# is silently skipped, and every profile's adapter for that platform
# starts polling the same bot token — producing a per-message race
# for which adapter answers. See test_reads_config_token.
if not token:
cfg = getattr(adapter, "config", None)
if cfg is not None:
for attr in ("token", "bot_token"):
val = getattr(cfg, attr, None)
if isinstance(val, str) and val.strip():
token = val.strip()
break
if not token:
config = getattr(adapter, "config", None)
val = getattr(config, "token", None)
@ -8795,12 +8835,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if platform_registry.is_registered(platform.value):
adapter = platform_registry.create_adapter(platform.value, config)
if adapter is not None:
# Adapters that need a back-reference to the gateway runner
# (e.g. for cross-platform admin alerts) declare a
# ``gateway_runner`` attribute. Inject it after creation so
# plugin adapters don't need a custom factory signature.
if hasattr(adapter, "gateway_runner"):
adapter.gateway_runner = self
# Inject a back-reference to the gateway runner so every
# adapter can (a) deliver cross-platform admin alerts and
# (b) resolve inbound profile routing through
# ``runner._profile_name_for_source``. Unconditional:
# ``BasePlatformAdapter`` declares ``gateway_runner``, so
# this reaches ALL platforms (not just the ones that
# pre-declared it), making profile routing platform-generic.
adapter.gateway_runner = self
return adapter
# Registered but failed to instantiate — don't silently fall
# through to built-ins (there are none for plugin platforms).
@ -11809,6 +11851,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
return None
response = agent_result.get("final_response") or ""
# Hidden-reasoning-only retry exhaustion: the loop's sentinel text
# ("Codex response remained incomplete after 3 continuation
# attempts") doubles as final_response, so it would be delivered
# verbatim into the channel — where peer agents can ingest it as a
# completed assistant turn (#51628). Blank it here so the normal
# empty-response handling (and the suppression below) applies.
if _is_gateway_hidden_reasoning_incomplete_turn(agent_result):
response = ""
try:
from gateway.response_filters import is_intentional_silence_agent_result
_intentional_silence = is_intentional_silence_agent_result(
@ -12039,6 +12089,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# forgets what was just asked. Persist the user turn so the
# conversation is preserved. (#7100)
agent_failed_early = bool(agent_result.get("failed"))
hidden_reasoning_incomplete = _is_gateway_hidden_reasoning_incomplete_turn(
agent_result
)
_err_str_for_classify = str(agent_result.get("error", "")).lower()
# Use specific multi-word phrases (not bare "exceed" or "token")
# to avoid false positives on transient errors like "rate limit
@ -12067,6 +12120,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"message so conversation context is preserved on retry.",
session_entry.session_id,
)
elif hidden_reasoning_incomplete:
logger.warning(
"Suppressing hidden-reasoning-only incomplete gateway turn "
"for session %s: %s",
session_entry.session_id,
agent_result.get("error", "processing incomplete"),
)
# When compression is exhausted, the session is permanently too
# large to process. Auto-reset it so the next message starts
@ -12150,11 +12210,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# entries that were stripped before the agent saw them.
if is_context_overflow_failure:
pass # handled above — skip all transcript writes
elif agent_failed_early:
elif agent_failed_early or hidden_reasoning_incomplete:
# Transient failure (429/timeout/5xx): persist only the user
# message so the next message can load a transcript that
# reflects what was said. Skip the assistant error text since
# it's a gateway-generated hint, not model output. (#7100)
# it's a gateway-generated hint, not model output. Hidden-
# reasoning-only incomplete turns follow the same persistence
# rule so peer-agent channels don't ingest them as completed
# assistant turns. (#7100, #51628)
_user_entry = {
"role": "user",
"content": (
@ -17246,19 +17309,108 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
persist_user_timestamp=persist_user_timestamp,
)
def _profile_name_for_source(self, source: SessionSource) -> Optional[str]:
"""Resolve the profile name for an inbound source via configured routes.
Returns ``None`` when multiplexing is off, no routes are configured, or
no route matches. Callers (``build_source``,
``_resolve_profile_home_for_source``) treat ``None`` as "use the
default/active profile". When ``gateway.profile_routes`` is configured,
the most specific matching route wins (guild < channel < thread). See
:mod:`gateway.profile_routing` for matching rules.
Gated on ``gateway.multiplex_profiles``: routing stamps
``source.profile``, which selects the session-key namespace and batch
keys but the profile-scoped agent run only activates under
multiplexing. Without this gate, a configured route with multiplexing
off would namespace batch/session keys by profile while the agent
still runs in ``agent:main``, splitting the two out of agreement.
"""
config = getattr(self, "config", None)
if not getattr(config, "multiplex_profiles", False):
return None
routes = getattr(config, "profile_routes", None)
if not routes:
return None
from gateway.profile_routing import match_profile_route
try:
matched = match_profile_route(
routes,
platform=source.platform.value,
guild_id=getattr(source, "guild_id", None),
chat_id=source.chat_id,
thread_id=getattr(source, "thread_id", None),
parent_chat_id=getattr(source, "parent_chat_id", None),
)
except Exception:
logger.warning(
"Profile route matching failed for %s/%s, falling back to default",
source.platform, source.chat_id, exc_info=True,
)
return None
if matched:
return matched.profile
logger.debug(
"No profile route matched: platform=%s chat_id=%s thread_id=%s parent_chat_id=%s",
source.platform.value, source.chat_id,
getattr(source, "thread_id", None), getattr(source, "parent_chat_id", None),
)
return None
def _resolve_profile_home_for_source(self, source: SessionSource) -> "Path":
"""Resolve which profile's HERMES_HOME should serve this inbound source.
Prefers the profile the source was routed to (``source.profile`` set
by the /p/<profile>/ URL prefix or a per-credential adapter), falling
back to the active profile (the multiplexer's own home).
Resolution order:
1. ``source.profile`` set by /p/<profile>/ URL prefix, per-credential
adapter ownership, OR profile_routes matching at ``build_source`` time.
2. ``_profile_name_for_source`` re-run routing here as a defensive
fallback for sources that bypass ``build_source``.
3. The active profile (the multiplexer's own home).
"""
from hermes_cli.profiles import get_active_profile_name, get_profile_dir
from hermes_cli.profiles import (
get_active_profile_name,
get_profile_dir,
profile_exists,
)
from hermes_constants import get_hermes_home
# Track whether a profile was explicitly requested (vs. falling back to default)
explicit_profile = None
try:
name = (source.profile or "").strip() or get_active_profile_name() or "default"
return get_profile_dir(name)
name = (source.profile or "").strip()
if name:
explicit_profile = name # User explicitly set this profile
if not name:
name = self._profile_name_for_source(source)
if name:
explicit_profile = name # Routing explicitly set this profile
if not name:
name = get_active_profile_name() or "default"
profile_dir = get_profile_dir(name)
# Warn if an explicit profile doesn't exist on disk
if explicit_profile and not profile_exists(name):
logger.warning(
"Profile %r does not exist for source %s/%s (guild_id=%s), "
"falling back to global HERMES_HOME",
explicit_profile,
source.platform.value,
source.chat_id,
getattr(source, "guild_id", None),
)
return get_hermes_home()
return profile_dir
except Exception:
from hermes_constants import get_hermes_home
# Catch normalization errors, path errors, etc.
logger.warning(
"Failed to resolve profile directory for source %s/%s (guild_id=%s), "
"falling back to global HERMES_HOME: %s",
source.platform.value,
source.chat_id,
getattr(source, "guild_id", None),
explicit_profile or "(no profile)",
exc_info=True,
)
return get_hermes_home()
async def _run_agent_inner(

View file

@ -1993,6 +1993,7 @@ class SessionStore:
"chat_id": source.chat_id,
"chat_type": source.chat_type,
"thread_id": source.thread_id,
"profile_name": source.profile,
}
if _needs_save:
@ -2273,6 +2274,7 @@ class SessionStore:
"chat_id": old_entry.origin.chat_id if old_entry.origin else None,
"chat_type": old_entry.origin.chat_type if old_entry.origin else None,
"thread_id": old_entry.origin.thread_id if old_entry.origin else None,
"profile_name": old_entry.origin.profile if old_entry.origin else None,
}
if self._db and db_end_session_id:

View file

@ -1700,6 +1700,18 @@ DEFAULT_CONFIG = {
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
# Goal judge — evaluates whether a /goal run's latest response
# satisfies the goal/contract, and drafts goal contracts. Short
# structured-JSON calls; a fast cheap model is fine.
"goal_judge": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 60,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
# Curator — skill-usage review fork. Timeout is generous because the
# review pass can take several minutes on reasoning models (umbrella
# building over hundreds of candidate skills). "auto" = use main chat
@ -5524,10 +5536,13 @@ def _persist_migration(config: Dict[str, Any]) -> None:
Every migration step MUST route its write through this helper instead of
calling ``save_config`` directly. It is a thin wrapper over
``save_config(config)`` (default-stripping ON); centralising the call makes
the invariant impossible to regress one migration at a time. Correctness
across seeds, non-default values, behaviour flips, and data transforms is
verified by the migration parity tests.
``save_config(config)`` (default-stripping ON, no ``merge_existing``);
centralising the call makes the invariant impossible to regress one
migration at a time. Callers must pass the full raw config returned by
``read_raw_config()`` after in-place mutations (including key removals);
deep-merging the on-disk file back in would resurrect keys the migration
just deleted. Partial-save preservation for unrelated top-level sections
belongs on ``save_config(..., merge_existing=True)``, not here.
"""
save_config(config)
@ -6302,6 +6317,25 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
return results
def _merge_partial_save(raw: dict, override: dict) -> dict:
"""Merge *override* over *raw* for partial ``save_config`` writes.
Top-level sections omitted from *override* are preserved from *raw*.
Shared top-level dict sections are deep-merged so a caller can update one
nested key without dropping sibling keys from disk. Intentional key
removals within a section are not supported here migration writes must
route through ``_persist_migration`` with a full ``read_raw_config()`` dict
instead.
"""
result = copy.deepcopy(override)
for key, value in raw.items():
if key not in result:
result[key] = copy.deepcopy(value)
elif isinstance(result.get(key), dict) and isinstance(value, dict):
result[key] = _deep_merge(value, result[key])
return result
def _deep_merge(base: dict, override: dict) -> dict:
"""Recursively merge *override* into *base*, preserving nested defaults.
@ -7184,6 +7218,7 @@ def save_config(
*,
strip_defaults: bool = True,
preserve_keys: Optional[Set[Tuple[str, ...]]] = None,
merge_existing: bool = False,
):
"""Save configuration to ~/.hermes/config.yaml.\n
@ -7192,6 +7227,12 @@ def save_config(
before any normalisation). This prevents config.yaml from being
contaminated with schema defaults on every save, which makes future
default changes invisible to users.
When ``merge_existing`` is True, the on-disk raw config is deep-merged
under *config* before writing so partial callers (migration steps via
``_persist_migration``) cannot drop unrelated sections the caller omitted.
Full-document replacement callers (dashboard raw YAML editor, callers that
already deep-merge) must leave this False so intentional deletions survive.
"""
with _CONFIG_LOCK:
if is_managed():
@ -7226,11 +7267,17 @@ def save_config(
explicit_raw_paths: Optional[Set[Tuple[str, ...]]] = (
_explicit_config_paths(_raw_for_paths) if _raw_for_paths else None
)
if merge_existing and _raw_for_paths:
config = _merge_partial_save(_raw_for_paths, config)
# ----------------------------------------------------------------
current_normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
normalized = current_normalized
raw_existing = _normalize_root_model_keys(_normalize_max_turns_config(read_raw_config()))
raw_existing = (
_normalize_root_model_keys(_normalize_max_turns_config(_raw_for_paths))
if _raw_for_paths
else {}
)
if raw_existing:
normalized = _preserve_env_ref_templates(
normalized,
@ -7278,6 +7325,7 @@ def save_config(
extra_content="".join(parts) if parts else None,
)
_secure_file(config_path)
_RAW_CONFIG_CACHE.pop(str(config_path), None)
_LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(current_normalized)

View file

@ -878,20 +878,11 @@ def judge_goal(
return "continue", "empty response (nothing to evaluate)", False, None
try:
from agent.auxiliary_client import get_auxiliary_extra_body, get_text_auxiliary_client
from agent.auxiliary_client import call_llm
except Exception as exc:
logger.debug("goal judge: auxiliary client import failed: %s", exc)
return "continue", "auxiliary client unavailable", False, None
try:
client, model = get_text_auxiliary_client("goal_judge")
except Exception as exc:
logger.debug("goal judge: get_text_auxiliary_client failed: %s", exc)
return "continue", "auxiliary client unavailable", False, None
if client is None or not model:
return "continue", "no auxiliary client configured", False, None
# Build the prompt. Priority: contract > subgoals > plain. When both a
# contract and subgoals exist, the subgoals are appended into the
# contract block as extra criteria so the judge sees a single source of
@ -935,8 +926,11 @@ def judge_goal(
)
try:
resp = client.chat.completions.create(
model=model,
# Route through call_llm so auxiliary.goal_judge.* config
# (provider/model/base_url, extra_body, reasoning_effort, retries)
# all apply — the direct-create path dropped extra_body (#35566).
resp = call_llm(
task="goal_judge",
messages=[
{"role": "system", "content": JUDGE_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
@ -944,7 +938,6 @@ def judge_goal(
temperature=0,
max_tokens=_goal_judge_max_tokens(),
timeout=timeout,
extra_body=get_auxiliary_extra_body() or None,
)
except Exception as exc:
logger.info("goal judge: API call failed (%s) — falling through to continue", exc)
@ -999,23 +992,15 @@ def draft_contract(objective: str, *, timeout: float = DEFAULT_JUDGE_TIMEOUT) ->
return None
try:
from agent.auxiliary_client import get_auxiliary_extra_body, get_text_auxiliary_client
from agent.auxiliary_client import call_llm
except Exception as exc:
logger.debug("goal draft: auxiliary client import failed: %s", exc)
return None
try:
client, model = get_text_auxiliary_client("goal_judge")
except Exception as exc:
logger.debug("goal draft: get_text_auxiliary_client failed: %s", exc)
return None
if client is None or not model:
return None
try:
resp = client.chat.completions.create(
model=model,
# Route through call_llm — same #35566 fix as the judge call above.
resp = call_llm(
task="goal_judge",
messages=[
{"role": "system", "content": DRAFT_CONTRACT_SYSTEM_PROMPT},
{"role": "user", "content": f"Objective:\n{_truncate(objective, 4000)}"},
@ -1023,7 +1008,6 @@ def draft_contract(objective: str, *, timeout: float = DEFAULT_JUDGE_TIMEOUT) ->
temperature=0,
max_tokens=_goal_judge_max_tokens(),
timeout=timeout,
extra_body=get_auxiliary_extra_body() or None,
)
except Exception as exc:
logger.info("goal draft: API call failed (%s)", exc)

View file

@ -0,0 +1,70 @@
"""Sanitize user prompt text leaked from terminal / paste control sequences."""
from __future__ import annotations
import re
_BRACKETED_PASTE_BOUNDARY_START = re.compile(r"(^|[\s\n>:\]\)])\[200~")
_BRACKETED_PASTE_BOUNDARY_END = re.compile(r"\[201~(?=$|[\s\n<\[\(\):;.,!?])")
_BRACKETED_PASTE_DEGRADED_START = re.compile(r"(^|[\s\n>:\]\)])00~")
_BRACKETED_PASTE_DEGRADED_END = re.compile(r"01~(?=$|[\s\n<\[\(\):;.,!?])")
# Corruption signature from desktop bracketed-paste leaks (#62557).
_DESKTOP_PASTE_ARTIFACT = "~[[e"
def strip_leaked_bracketed_paste_wrappers(text: str) -> str:
"""Strip leaked bracketed-paste wrapper markers from user-visible text.
Defensive normalization for cases where terminal/prompt_toolkit parsing
fails and bracketed-paste markers end up in the buffer as literal text.
Canonical wrappers are stripped unconditionally. Degraded visible forms like
``[200~`` / ``[201~`` and ``00~`` / ``01~`` are removed only at boundaries
so embedded literals such as ``literal[200~tag`` stay intact.
"""
if not text:
return text
text = (
text.replace("\x1b[200~", "")
.replace("\x1b[201~", "")
.replace("^[[200~", "")
.replace("^[[201~", "")
)
text = _BRACKETED_PASTE_BOUNDARY_START.sub(r"\1", text)
text = _BRACKETED_PASTE_BOUNDARY_END.sub("", text)
text = _BRACKETED_PASTE_DEGRADED_START.sub(r"\1", text)
text = _BRACKETED_PASTE_DEGRADED_END.sub("", text)
return text
def collapse_repeated_input_artifacts(text: str, min_repeats: int = 4) -> str:
"""Drop a trailing run of the desktop ~[[e corruption signature (#62557)."""
if not text:
return text
marker = _DESKTOP_PASTE_ARTIFACT
index = len(text)
repeat_count = 0
while index >= len(marker) and text[index - len(marker) : index] == marker:
repeat_count += 1
index -= len(marker)
if repeat_count < min_repeats:
return text
start = index
if start >= 2 and text[start - 2 : start] == "[e":
start -= 2
elif start >= 1 and text[start - 1] == "[":
start -= 1
return text[:start]
def sanitize_user_prompt_text(text: str) -> str:
"""Normalize user-authored prompt text before persistence or model input."""
if not isinstance(text, str) or not text:
return text
cleaned = strip_leaked_bracketed_paste_wrappers(text)
return collapse_repeated_input_artifacts(cleaned)

View file

@ -298,23 +298,11 @@ def decompose_task(
roster, valid_names = _build_roster()
try:
from agent.auxiliary_client import ( # type: ignore
get_auxiliary_extra_body,
get_text_auxiliary_client,
)
from agent.auxiliary_client import call_llm # type: ignore
except Exception as exc:
logger.debug("decompose: auxiliary client import failed: %s", exc)
return DecomposeOutcome(task_id, False, "auxiliary client unavailable")
try:
client, model = get_text_auxiliary_client("kanban_decomposer")
except Exception as exc:
logger.debug("decompose: get_text_auxiliary_client failed: %s", exc)
return DecomposeOutcome(task_id, False, "auxiliary client unavailable")
if client is None or not model:
return DecomposeOutcome(task_id, False, "no auxiliary client configured")
user_msg = _USER_TEMPLATE.format(
task_id=task.id,
title=_truncate(task.title or "", 400),
@ -324,8 +312,12 @@ def decompose_task(
)
try:
resp = client.chat.completions.create(
model=model,
# Route through call_llm so auxiliary.kanban_decomposer.* config
# (provider/model/base_url, extra_body, reasoning_effort, retries)
# all apply — the previous direct client.chat.completions.create()
# path dropped auxiliary.<task>.extra_body entirely (#35566).
resp = call_llm(
task="kanban_decomposer",
messages=[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
@ -333,7 +325,6 @@ def decompose_task(
temperature=0.3,
max_tokens=4000,
timeout=timeout or 180,
extra_body=get_auxiliary_extra_body() or None,
)
except Exception as exc:
logger.info(

View file

@ -162,22 +162,11 @@ def specify_task(
)
try:
from agent.auxiliary_client import get_auxiliary_extra_body, get_text_auxiliary_client
from agent.auxiliary_client import call_llm
except Exception as exc: # pragma: no cover — import smoke test
logger.debug("specify: auxiliary client import failed: %s", exc)
return SpecifyOutcome(task_id, False, "auxiliary client unavailable")
try:
client, model = get_text_auxiliary_client("triage_specifier")
except Exception as exc:
logger.debug("specify: get_text_auxiliary_client failed: %s", exc)
return SpecifyOutcome(task_id, False, "auxiliary client unavailable")
if client is None or not model:
return SpecifyOutcome(
task_id, False, "no auxiliary client configured"
)
user_msg = _USER_TEMPLATE.format(
task_id=task.id,
title=_truncate(task.title or "", 400),
@ -185,8 +174,11 @@ def specify_task(
)
try:
resp = client.chat.completions.create(
model=model,
# Route through call_llm so auxiliary.triage_specifier.* config
# (provider/model/base_url, extra_body, reasoning_effort, retries)
# all apply — the direct-create path dropped extra_body (#35566).
resp = call_llm(
task="triage_specifier",
messages=[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
@ -194,7 +186,6 @@ def specify_task(
temperature=0.3,
max_tokens=HERMES_KANBAN_SPECIFY_MAX_TOKENS,
timeout=timeout or 120,
extra_body=get_auxiliary_extra_body() or None,
)
except Exception as exc:
logger.info(

View file

@ -10,7 +10,11 @@ Catalog policy:
- Entries are added only by merging a PR into hermes-agent. Presence in the
``optional-mcps/`` directory = Nous approval. No community tier, no trust
signals beyond "it's in the catalog".
- Manifests pin transport details (commands, args, refs). MCPs are never
- Manifests pin transport details (commands, args, refs). Pins follow the
same supply-chain rules as pyproject dependencies: exact versions for
package launchers (``uvx pkg==X``, ``npx pkg@X``), full commit SHAs for
git installs, and the pinned release should be at least 2 weeks old at
pin time. MCPs are never
auto-updated; users explicitly re-run ``hermes mcp install <name>`` to
pull a new manifest version after a repo update.
- Secrets prompted at install time go to ``~/.hermes/.env`` (the
@ -77,6 +81,10 @@ class TransportSpec:
args: List[str] = field(default_factory=list)
url: Optional[str] = None
version: Optional[str] = None # informational, pinned
# Static environment variables for the stdio subprocess (e.g. telemetry
# opt-outs, mode flags). NOT for secrets — credentials go through
# auth.env so they are prompted for and land in ~/.hermes/.env.
env: Dict[str, str] = field(default_factory=dict)
@dataclass
@ -184,12 +192,20 @@ def _parse_manifest(path: Path) -> CatalogEntry:
args = transport_raw.get("args") or []
if not isinstance(args, list):
raise CatalogError(f"{path}: transport.args must be a list")
env_raw = transport_raw.get("env") or {}
if not isinstance(env_raw, dict) or not all(
isinstance(k, str) and isinstance(v, str) for k, v in env_raw.items()
):
raise CatalogError(
f"{path}: transport.env must be a mapping of string to string"
)
transport = TransportSpec(
type=t_type,
command=transport_raw.get("command"),
args=[str(a) for a in args],
url=transport_raw.get("url"),
version=transport_raw.get("version"),
env=dict(env_raw),
)
if t_type == "stdio" and not transport.command:
raise CatalogError(f"{path}: stdio transport requires 'command'")
@ -468,6 +484,8 @@ def _build_server_config(
cfg["command"] = _expand_install_dir(t.command or "", install_dir)
if t.args:
cfg["args"] = [_expand_install_dir(a, install_dir) for a in t.args]
if t.env:
cfg["env"] = dict(t.env)
elif t.type == "http":
cfg["url"] = t.url
if entry.auth.type == "oauth":

View file

@ -110,6 +110,77 @@ def _clean_slot(slot: Any) -> dict[str, Any] | None:
return clean
def _slot_problem(slot: Any) -> str | None:
"""Return a human-readable problem for a slot ``_clean_slot`` would drop.
None means the slot is complete and valid. Mirrors ``_clean_slot`` exactly
so the write-boundary validator (``validate_moa_payload``) and the
tolerant runtime normalizer can never disagree about what is acceptable.
"""
if not isinstance(slot, dict):
return "must be an object with 'provider' and 'model'"
provider = str(slot.get("provider") or "").strip()
model = str(slot.get("model") or "").strip()
if not provider and not model:
return "provider and model are required"
if not provider:
return "provider is required"
if not model:
return f"model is required (provider '{provider}' has no model selected)"
if provider.lower() == "moa":
return "the Mixture of Agents provider cannot be used inside a preset (recursive MoA)"
return None
def validate_moa_payload(raw: Any) -> list[str]:
"""Return the problems ``normalize_moa_config`` would silently paper over.
``normalize_moa_config`` is deliberately tolerant: at *read* time a
hand-edited config must degrade to defaults rather than crash the agent.
That same tolerance at *write* time is a corruption engine a client that
sends a half-filled slot gets its whole preset silently replaced with the
hardcoded defaults (#64156). API write paths call this first and reject
invalid payloads loudly instead of saving something the user never chose.
Returns a list of human-readable problems; empty means safe to save.
"""
if not isinstance(raw, dict):
return ["MoA config must be an object"]
presets_raw = raw.get("presets")
if isinstance(presets_raw, dict) and presets_raw:
presets: dict[Any, Any] = presets_raw
else:
# Legacy flat payload: the top-level object is the default preset.
presets = {DEFAULT_MOA_PRESET_NAME: raw}
problems: list[str] = []
for name, preset in presets.items():
label = str(name or "").strip() or "(unnamed)"
if not isinstance(preset, dict):
problems.append(f"preset '{label}': must be an object")
continue
refs = preset.get("reference_models")
if not isinstance(refs, list):
refs = [refs] if isinstance(refs, dict) else []
complete_refs = 0
for index, slot in enumerate(refs):
issue = _slot_problem(slot)
if issue:
problems.append(f"preset '{label}' reference {index + 1}: {issue}")
else:
complete_refs += 1
if not complete_refs:
problems.append(f"preset '{label}': needs at least one complete reference model")
agg_issue = _slot_problem(preset.get("aggregator"))
if agg_issue:
problems.append(f"preset '{label}' aggregator: {agg_issue}")
return problems
def _default_preset() -> dict[str, Any]:
return {
"reference_models": deepcopy(DEFAULT_MOA_REFERENCE_MODELS),

View file

@ -27,6 +27,54 @@ import subprocess
from hermes_cli.config import clear_model_endpoint_credentials
# AWS cross-region inference profile prefixes. Any geo-prefixed profile only
# routes from endpoints in its own geography, so the Bedrock picker must not
# offer (e.g.) us.* profiles to an eu-central-2 endpoint — selecting one
# produces a config AWS rejects regardless of credentials (#28156).
# global.* routes from everywhere. Full set per the AWS cross-region
# inference docs.
BEDROCK_GEO_PREFIXES = (
"us.", "eu.", "ap.", "apac.", "jp.", "ca.", "sa.", "me.", "af.",
)
def bedrock_region_geo_prefix(region_name: str) -> str:
"""Map an AWS region name to its inference-profile geo prefix ('' = unknown)."""
r = (region_name or "").lower()
for geo, region_prefixes in (
("us.", ("us-", "us_gov")),
("eu.", ("eu-",)),
("ap.", ("ap-",)),
("ca.", ("ca-",)),
("sa.", ("sa-",)),
("me.", ("me-",)),
("af.", ("af-",)),
):
if r.startswith(region_prefixes):
return geo
return ""
def bedrock_model_routable_from_region(model_id: str, region_name: str) -> bool:
"""True when *model_id* can be invoked from *region_name*'s endpoint.
Bare foundation-model ids and ``global.*`` profiles route from anywhere.
Geo-prefixed inference profiles (``us.*``, ``eu.*``, ...) only route from
endpoints in their own geography. Unknown region shapes hide nothing.
"""
mid = (model_id or "").lower()
matched_geo = next((p for p in BEDROCK_GEO_PREFIXES if mid.startswith(p)), None)
if matched_geo is None or mid.startswith("global."):
return True
geo = bedrock_region_geo_prefix(region_name)
if not geo:
return True
if geo == "ap.":
# Asia-Pacific regions can carry ap./apac./jp. profile spellings.
return matched_geo in ("ap.", "apac.", "jp.")
return matched_geo == geo
def _prune_replaced_custom_model_config_credentials(
base_url: str,
*,
@ -2265,6 +2313,8 @@ def _model_flow_bedrock(config, current_model=""):
"global.twelvelabs.",
)
_EXCLUDE_SUBSTRINGS = ("safeguard", "voxtral", "palmyra-vision")
filtered = []
for m in live_models:
mid = m["id"]
@ -2272,44 +2322,59 @@ def _model_flow_bedrock(config, current_model=""):
continue
if any(s in mid.lower() for s in _EXCLUDE_SUBSTRINGS):
continue
if not bedrock_model_routable_from_region(mid, region):
continue
filtered.append(m)
# Deduplicate: prefer inference profiles (us.*, global.*) over bare
# foundation model IDs.
# Deduplicate: prefer inference profiles (geo-prefixed or global.*)
# over bare foundation model IDs.
_PROFILE_PREFIXES = BEDROCK_GEO_PREFIXES + ("global.",)
profile_base_ids = set()
for m in filtered:
mid = m["id"]
if mid.startswith(("us.", "global.")):
base = mid.split(".", 1)[1] if "." in mid[3:] else mid
profile_base_ids.add(base)
_pp = next((p for p in _PROFILE_PREFIXES if mid.startswith(p)), None)
if _pp:
profile_base_ids.add(mid[len(_pp):])
deduped = []
for m in filtered:
mid = m["id"]
if not mid.startswith(("us.", "global.")) and mid in profile_base_ids:
if (
not mid.startswith(_PROFILE_PREFIXES)
and mid in profile_base_ids
):
continue
deduped.append(m)
_RECOMMENDED = [
"us.anthropic.claude-sonnet-4-6",
"us.anthropic.claude-opus-4-6",
"us.anthropic.claude-haiku-4-5",
"us.amazon.nova-pro",
"us.amazon.nova-lite",
"us.amazon.nova-micro",
# Recommended models, matched geo-agnostically so an EU (eu.*) or
# APAC (apac.*) picker pins its own region's profile of the same
# model rather than a us.* one it can't route to (#28156).
_RECOMMENDED_BASES = [
"anthropic.claude-sonnet-4-6",
"anthropic.claude-opus-4-6",
"anthropic.claude-haiku-4-5",
"amazon.nova-pro",
"amazon.nova-lite",
"amazon.nova-micro",
"deepseek.v3",
"us.meta.llama4-maverick",
"us.meta.llama4-scout",
"meta.llama4-maverick",
"meta.llama4-scout",
]
def _base_id(mid: str) -> str:
_pp = next((p for p in _PROFILE_PREFIXES if mid.startswith(p)), None)
return mid[len(_pp):] if _pp else mid
def _sort_key(m):
mid = m["id"]
for i, rec in enumerate(_RECOMMENDED):
if mid.startswith(rec):
return (0, i, mid)
base = _base_id(mid)
for i, rec in enumerate(_RECOMMENDED_BASES):
if base.startswith(rec):
# In-region geo profile beats global.* for the same model
return (0, i, 0 if not mid.startswith("global.") else 1, mid)
if mid.startswith("global."):
return (1, 0, mid)
return (2, 0, mid)
return (1, 0, 0, mid)
return (2, 0, 0, mid)
deduped.sort(key=_sort_key)
model_list = [m["id"] for m in deduped]

View file

@ -210,23 +210,11 @@ def describe_profile(
model, provider = None, None
try:
from agent.auxiliary_client import ( # type: ignore
get_auxiliary_extra_body,
get_text_auxiliary_client,
)
from agent.auxiliary_client import call_llm # type: ignore
except Exception as exc:
logger.debug("describe: auxiliary client import failed: %s", exc)
return DescribeOutcome(canon, False, "auxiliary client unavailable")
try:
client, aux_model = get_text_auxiliary_client("profile_describer")
except Exception as exc:
logger.debug("describe: get_text_auxiliary_client failed: %s", exc)
return DescribeOutcome(canon, False, "auxiliary client unavailable")
if client is None or not aux_model:
return DescribeOutcome(canon, False, "no auxiliary client configured")
user_msg = _USER_TEMPLATE.format(
name=canon,
model=(model or "(unset)"),
@ -237,8 +225,11 @@ def describe_profile(
)
try:
resp = client.chat.completions.create(
model=aux_model,
# Route through call_llm so auxiliary.profile_describer.* config
# (provider/model/base_url, extra_body, reasoning_effort, retries)
# all apply — the direct-create path dropped extra_body (#35566).
resp = call_llm(
task="profile_describer",
messages=[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
@ -246,7 +237,6 @@ def describe_profile(
temperature=0.3,
max_tokens=400,
timeout=timeout or 60,
extra_body=get_auxiliary_extra_body() or None,
)
except Exception as exc:
logger.info("describe: API call failed for %s (%s)", canon, exc)

View file

@ -1975,8 +1975,15 @@ def resolve_runtime_provider(
# Dual-path routing: Claude models use AnthropicBedrock SDK for full
# feature parity (prompt caching, thinking budgets, adaptive thinking).
# Non-Claude models use the Converse API for multi-model support.
#
# Exception: Bearer Token auth (AWS_BEARER_TOKEN_BEDROCK) is NOT
# supported by the AnthropicBedrock SDK (it only does SigV4 signing —
# a bearer-only setup fails at runtime with "could not resolve
# credentials from session"). Route these users through the Converse
# API regardless of model. Ref: #28156.
_current_model = str(target_model or model_cfg.get("default") or "").strip()
if is_anthropic_bedrock_model(_current_model):
_has_bearer_token = bool(os.environ.get("AWS_BEARER_TOKEN_BEDROCK", "").strip())
if is_anthropic_bedrock_model(_current_model) and not _has_bearer_token:
# Claude on Bedrock → AnthropicBedrock SDK → anthropic_messages path
runtime = {
"provider": "bedrock",

View file

@ -996,6 +996,9 @@ class ModelAssignment(BaseModel):
class MoaModelSlot(BaseModel):
provider: str = ""
model: str = ""
# Optional per-slot reasoning effort. Declared so a client round-tripping
# the GET payload doesn't have it stripped at parse time and wiped on save.
reasoning_effort: Optional[str] = None
class MoaPresetPayload(BaseModel):
@ -1006,6 +1009,11 @@ class MoaPresetPayload(BaseModel):
reference_temperature: Optional[float] = None
aggregator_temperature: Optional[float] = None
max_tokens: int = 4096
# Newer per-preset knobs (see moa_config._normalize_preset). Optional so
# older clients that never send them keep working; declared so clients
# that round-trip the GET payload don't silently erase hand-set values.
reference_max_tokens: Optional[int] = None
fanout: Optional[str] = None
enabled: bool = True
@ -1020,6 +1028,8 @@ class MoaConfigPayload(BaseModel):
reference_temperature: Optional[float] = None
aggregator_temperature: Optional[float] = None
max_tokens: int = 4096
reference_max_tokens: Optional[int] = None
fanout: Optional[str] = None
enabled: bool = True
profile: Optional[str] = None
@ -5701,7 +5711,23 @@ def get_moa_models(profile: Optional[str] = None):
def set_moa_models(body: MoaConfigPayload, profile: Optional[str] = None):
"""Persist the Mixture-of-Agents provider/model slots."""
try:
from hermes_cli.moa_config import normalize_moa_config
from hermes_cli.moa_config import normalize_moa_config, validate_moa_payload
def _slot_dict(slot: MoaModelSlot) -> dict:
# Drop unset optionals so saved slots stay minimal ({provider, model}).
return {k: v for k, v in slot.dict().items() if v is not None}
def _preset_dict(preset: MoaPresetPayload) -> dict:
return {
"reference_models": [_slot_dict(slot) for slot in preset.reference_models],
"aggregator": _slot_dict(preset.aggregator),
"reference_temperature": preset.reference_temperature,
"aggregator_temperature": preset.aggregator_temperature,
"max_tokens": preset.max_tokens,
"reference_max_tokens": preset.reference_max_tokens,
"fanout": preset.fanout,
"enabled": preset.enabled,
}
with _profile_scope(body.profile or profile):
cfg = load_config()
@ -5709,27 +5735,35 @@ def set_moa_models(body: MoaConfigPayload, profile: Optional[str] = None):
raw = {
"default_preset": body.default_preset,
"active_preset": body.active_preset,
"presets": {
name: {
"reference_models": [slot.dict() for slot in preset.reference_models],
"aggregator": preset.aggregator.dict(),
"reference_temperature": preset.reference_temperature,
"aggregator_temperature": preset.aggregator_temperature,
"max_tokens": preset.max_tokens,
"enabled": preset.enabled,
}
for name, preset in body.presets.items()
},
"presets": {name: _preset_dict(preset) for name, preset in body.presets.items()},
}
else:
raw = {
"reference_models": [slot.dict() for slot in body.reference_models],
"aggregator": body.aggregator.dict(),
"reference_temperature": body.reference_temperature,
"aggregator_temperature": body.aggregator_temperature,
"max_tokens": body.max_tokens,
"enabled": body.enabled,
}
raw = _preset_dict(
MoaPresetPayload(
reference_models=body.reference_models,
aggregator=body.aggregator,
reference_temperature=body.reference_temperature,
aggregator_temperature=body.aggregator_temperature,
max_tokens=body.max_tokens,
reference_max_tokens=body.reference_max_tokens,
fanout=body.fanout,
enabled=body.enabled,
)
)
# Reject-don't-repair: normalize_moa_config() silently swaps any
# preset containing incomplete slots for the hardcoded defaults —
# correct tolerance for hand-edited configs at READ time, silent
# data loss at WRITE time (#64156: desktop autosave of a
# half-filled slot replaced the user's whole preset). Refuse the
# save loudly so no client can corrupt config through this route.
problems = validate_moa_payload(raw)
if problems:
raise HTTPException(
status_code=422,
detail="Invalid MoA config: " + "; ".join(problems),
)
normalized = normalize_moa_config(raw)
cfg["moa"] = normalized
save_config(cfg)
@ -14148,7 +14182,9 @@ async def update_config_raw(body: RawConfigUpdate, profile: Optional[str] = None
if not isinstance(parsed, dict):
raise HTTPException(status_code=400, detail="YAML must be a mapping")
with _profile_scope(body.profile or profile):
save_config(parsed)
# Full-document replacement: the editor owns the whole file; do not
# merge omitted sections back from disk (#62723).
save_config(parsed, merge_existing=False)
return {"ok": True}
except yaml.YAMLError as e:
raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}")

View file

@ -791,6 +791,7 @@ CREATE TABLE IF NOT EXISTS sessions (
compression_failure_cooldown_until REAL,
compression_failure_error TEXT,
compression_fallback_streak INTEGER NOT NULL DEFAULT 0,
profile_name TEXT,
rewind_count INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
@ -986,7 +987,7 @@ class SessionDB:
_WRITE_MAX_RETRIES = 15
_WRITE_RETRY_MIN_S = 0.020 # 20ms
_WRITE_RETRY_MAX_S = 0.150 # 150ms
# Attempt a PASSIVE WAL checkpoint every N successful writes.
# Attempt a WAL checkpoint every N successful writes (PASSIVE mode).
_CHECKPOINT_EVERY_N_WRITES = 50
# Merge fragmented FTS5 segments every N successful writes. The message
# triggers append one segment per insert; left unmaintained these grow
@ -1301,35 +1302,34 @@ class SessionDB:
)
def _try_wal_checkpoint(self) -> None:
"""Best-effort TRUNCATE WAL checkpoint. Never raises.
"""Best-effort PASSIVE WAL checkpoint. Never raises.
Flushes committed WAL frames back into the main DB file and
truncates the WAL file to zero bytes. Keeps the WAL from
growing unbounded when many processes hold persistent
connections.
Flushes committed WAL frames back into the main DB file without
requiring an exclusive lock. PASSIVE is safe for frequent
periodic use because it does not block concurrent writers and
cannot corrupt B-tree pages under I/O pressure.
PASSIVE checkpoint was previously used here, but it never
truncates the WAL file the file stays at its high-water
mark until an explicit TRUNCATE is called (which only
happened inside the infrequent vacuum()).
PASSIVE does not truncate the WAL file it stays at its
high-water mark. WAL truncation happens in :meth:`close`
(TRUNCATE) and pre-VACUUM checkpoints, which run infrequently
under controlled conditions.
TRUNCATE may block writers briefly while checkpointing, but
_try_wal_checkpoint is called off the hot path (every 50
writes) and already runs under ``self._lock``, so the
additional hold time is negligible.
Previous TRUNCATE strategy caused B-tree corruption on large
databases (65K+ pages) due to the exclusive-lock I/O pressure
from checkpointing thousands of frames at once (issue #45383).
"""
try:
with self._lock:
result = self._conn.execute(
"PRAGMA wal_checkpoint(TRUNCATE)"
"PRAGMA wal_checkpoint(PASSIVE)"
).fetchone()
if result and result[1] > 0:
logger.debug(
"WAL checkpoint: %d/%d pages checkpointed",
result[2], result[1],
)
except Exception:
pass # Best effort — never fatal.
except Exception as exc:
logger.warning("WAL checkpoint (PASSIVE) failed: %s", exc)
def _try_optimize_fts(self) -> None:
"""Best-effort FTS5 segment merge. Never raises.
@ -1357,8 +1357,8 @@ class SessionDB:
if self._conn:
try:
self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except Exception:
pass
except Exception as exc:
logger.debug("WAL checkpoint (TRUNCATE) at close failed: %s", exc)
self._conn.close()
self._conn = None
@ -1760,6 +1760,7 @@ class SessionDB:
thread_id: str = None,
parent_session_id: str = None,
cwd: str = None,
profile_name: str = None,
) -> None:
"""Insert a session row, enriching NULL metadata on conflict.
@ -1783,9 +1784,9 @@ class SessionDB:
conn.execute(
"""INSERT INTO sessions (
id, source, user_id, session_key, chat_id, chat_type, thread_id,
model, model_config, system_prompt, parent_session_id, cwd, started_at
model, model_config, system_prompt, parent_session_id, cwd, profile_name, started_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
model = COALESCE(sessions.model, excluded.model),
model_config = COALESCE(sessions.model_config, excluded.model_config),
@ -1795,7 +1796,8 @@ class SessionDB:
chat_type = COALESCE(sessions.chat_type, excluded.chat_type),
thread_id = COALESCE(sessions.thread_id, excluded.thread_id),
parent_session_id = COALESCE(sessions.parent_session_id, excluded.parent_session_id),
cwd = COALESCE(sessions.cwd, excluded.cwd)""",
cwd = COALESCE(sessions.cwd, excluded.cwd),
profile_name = COALESCE(sessions.profile_name, excluded.profile_name)""",
(
session_id,
source,
@ -1809,6 +1811,7 @@ class SessionDB:
system_prompt,
parent_session_id,
cwd,
profile_name,
time.time(),
),
)
@ -7015,8 +7018,8 @@ class SessionDB:
# Best-effort WAL checkpoint first, then VACUUM.
try:
self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except Exception:
pass
except Exception as exc:
logger.debug("WAL checkpoint (TRUNCATE) before VACUUM failed: %s", exc)
self._conn.execute("VACUUM")
return optimized

View file

@ -0,0 +1,88 @@
# Nous-approved MCP catalog entry.
# Presence in this directory = approval. Merged via PR review.
manifest_version: 1
name: blender
description: Drive a live Blender session — modeling, scenes, and renders.
source: https://github.com/ahujasid/blender-mcp
# ahujasid/blender-mcp (MIT, PyPI: blender-mcp) is the de-facto standard
# Blender MCP. It is a bridge with two halves:
# 1. This stdio server (uvx blender-mcp), which Hermes launches.
# 2. An addon (addon.py) running inside Blender that opens a local command
# socket on 127.0.0.1:9876. The stdio server relays to it.
# There is nothing to git-clone on the Hermes side — uvx resolves the package —
# so no install block. The in-Blender addon setup is covered in post_install.
#
# Why not Blender's official MCP (blender.org/lab): it requires Blender 5.1+
# and targets scene analysis/documentation rather than asset creation. This
# server works across Blender 3.x/4.x and exposes full modeling control.
transport:
type: stdio
command: "uvx"
# Pinned per catalog dependency policy: exact version, and the release must
# be at least 2 weeks old at pin time (supply-chain cooldown — same rule as
# pyproject.toml dependencies). 1.6.4 released 2026-06-11. The catalog never
# auto-updates; bumping the pin is a PR to this manifest.
args:
- "blender-mcp==1.6.4"
version: "1.6.4"
env:
# Upstream ships anonymous telemetry, on by default. Hermes policy is
# no outbound telemetry without explicit opt-in, so the catalog entry
# disables it. Remove this line only if you deliberately want it on.
DISABLE_TELEMETRY: "true"
# The Blender addon binds 127.0.0.1 only and has no auth of its own; the
# stdio server needs no credentials. Optional integrations (Sketchfab,
# Hyper3D Rodin, Hunyuan3D) take API keys entered in the addon's N-panel
# inside Blender, not via Hermes env.
auth:
type: none
# Tool selection at install time:
# The server advertises 22 tools. 18 of them front optional third-party asset
# services (PolyHaven, Sketchfab, Hyper3D Rodin, Hunyuan3D) that are dead
# weight unless the matching integration is enabled in the addon panel — and
# upstream has no server-side trim mechanism, so without a filter every one
# of them costs schema tokens on every API call. Default to the core surface:
# scene/object inspection, viewport screenshots, and code execution, which is
# the complete modeling/render capability. Users who enable an asset service
# in the addon can opt into its tools with `hermes mcp configure blender`.
tools:
default_enabled:
- get_scene_info
- get_object_info
- get_viewport_screenshot
- execute_blender_code
post_install: |
This entry launches the bridge server, but the bridge talks to an addon
INSIDE a running Blender (3.0+). One-time setup:
1. Download addon.py from https://github.com/ahujasid/blender-mcp
(raw file: https://raw.githubusercontent.com/ahujasid/blender-mcp/main/addon.py)
2. Blender > Edit > Preferences > Add-ons > Install... > select addon.py,
then enable "Interface: Blender MCP".
3. In the 3D viewport press N, open the "BlenderMCP" tab, click
"Connect to Claude" (starts the local socket on 127.0.0.1:9876).
Blender must be RUNNING with the addon connected before the tools work —
start Blender first, then your Hermes session. The addon refuses to start
under `blender -b` (background mode): its command queue runs on UI timers.
On a machine without a display, run Blender under a virtual one:
xvfb-run blender (or Xvfb :99 & DISPLAY=:99 blender)
GPU rendering (Cycles/OptiX) works fine under Xvfb.
SECURITY: execute_blender_code runs arbitrary Python inside Blender with no
sandbox — same trust level as the terminal tool. Upstream telemetry is
disabled via DISABLE_TELEMETRY in this entry's env block.
The 18 asset-service tools (PolyHaven/Sketchfab/Hyper3D/Hunyuan3D) are off
by default. To use one: enable the service in the addon's BlenderMCP panel
(API key there if required), then `hermes mcp configure blender` and tick
its tools. PolyHaven is free and keyless; the others need accounts.
If Hermes and Blender run on different machines, note that file paths in
execute_blender_code (texture loads, exports) resolve on the BLENDER host's
filesystem, not where Hermes runs.

View file

@ -27,8 +27,12 @@ transport:
install:
type: git
url: https://github.com/CyberSamuraiX/hermes-n8n-mcp.git
# Pin to a commit/tag. Required — manifests do not float HEAD.
ref: main
# Pinned per catalog dependency policy: full commit SHA (branches and tags
# can be moved; SHAs cannot), and the pinned commit must be at least
# 2 weeks old at pin time — same supply-chain rules as pyproject
# dependencies. This SHA is "feat: add local n8n MCP bridge for Hermes"
# (2026-05-23). Bumping the pin is a PR to this manifest.
ref: 7a9ae00795593aa1fdb4e61ecd640e8bfd0c3841
# Bootstrap commands run inside the cloned directory after clone.
bootstrap:
- "python3 -m venv .venv"

View file

@ -1,94 +1,88 @@
---
name: blender-mcp
description: Control Blender directly from Hermes via socket connection to the blender-mcp addon. Create 3D objects, materials, animations, and run arbitrary Blender Python (bpy) code. Use when user wants to create or modify anything in Blender.
version: 1.0.0
requires: Blender 4.3+ (desktop instance required, headless not supported)
author: alireza78a
description: Drive Blender via the catalog blender MCP, with bpy recipes.
version: 2.0.0
requires: Blender 3.0+ desktop instance (headless via xvfb-run)
author: alireza78a + Hermes Agent
tags: [blender, 3d, animation, modeling, bpy, mcp]
platforms: [linux, macos, windows]
---
# Blender MCP
# Blender MCP Skill
Control a running Blender instance from Hermes via socket on TCP port 9876.
Companion skill for the `blender` entry in the Hermes MCP catalog. The MCP
server provides the connection to Blender; this skill teaches the bpy idioms
and pitfalls for driving it well. It does not cover Blender UI workflows —
everything here goes through the MCP tools against a live Blender session.
## Setup (one-time)
## When to Use
### 1. Install the Blender addon
Use when the user wants to create or modify anything in a running Blender
instance: meshes, materials, animations, lighting, renders. Requires the
blender MCP server installed and a Blender desktop session with the addon
connected.
curl -sL https://raw.githubusercontent.com/ahujasid/blender-mcp/main/addon.py -o ~/Desktop/blender_mcp_addon.py
## Prerequisites
In Blender:
Edit > Preferences > Add-ons > Install > select blender_mcp_addon.py
Enable "Interface: Blender MCP"
1. Install the MCP server from the Nous catalog (one-time):
### 2. Start the socket server in Blender
hermes mcp install blender
Press N in Blender viewport to open sidebar.
Find "BlenderMCP" tab and click "Start Server".
This configures the pinned `blender-mcp` stdio server with the curated
tool set: `get_scene_info`, `get_object_info`, `get_viewport_screenshot`,
`execute_blender_code`.
### 3. Verify connection
2. Install the addon inside Blender (one-time — the catalog entry's
post-install notes cover this too):
- Download https://raw.githubusercontent.com/ahujasid/blender-mcp/main/addon.py
- Blender > Edit > Preferences > Add-ons > Install... > select addon.py,
enable "Interface: Blender MCP".
nc -z -w2 localhost 9876 && echo "OPEN" || echo "CLOSED"
3. Every session: start Blender FIRST, press N in the viewport, open the
"BlenderMCP" tab, click "Connect to Claude" (starts the local bridge
socket). Then start your Hermes session so the MCP tools are loaded.
## Protocol
The addon refuses to start under `blender -b` (background mode). On a
machine without a display, run Blender under a virtual one:
`xvfb-run blender`. GPU rendering works fine under Xvfb.
Plain UTF-8 JSON over TCP -- no length prefix.
## Quick Reference
Send: {"type": "<command>", "params": {<kwargs>}}
Receive: {"status": "success", "result": <value>}
{"status": "error", "message": "<reason>"}
| MCP tool | Use for |
|---------------------------|--------------------------------------------|
| `get_scene_info` | List objects before touching the scene |
| `get_object_info` | Inspect one object (transform, materials) |
| `get_viewport_screenshot` | Visual check of what you built |
| `execute_blender_code` | Everything else — arbitrary bpy Python |
## Available Commands
Optional asset-service tools (PolyHaven, Sketchfab, Hyper3D, Hunyuan3D) are
disabled by default. If the user has enabled a service in the addon panel,
opt into its tools with `hermes mcp configure blender`.
| type | params | description |
|-------------------------|-------------------|---------------------------------|
| execute_code | code (str) | Run arbitrary bpy Python code |
| get_scene_info | (none) | List all objects in scene |
| get_object_info | object_name (str) | Details on a specific object |
| get_viewport_screenshot | (none) | Screenshot of current viewport |
## Procedure
## Python Helper
1. Call `get_scene_info` first — never assume the scene is empty.
2. Build with `execute_blender_code`, in small focused calls (one logical
step per call: add objects, then materials, then animation). Large
monolithic scripts hit the bridge timeout.
3. Verify visually with `get_viewport_screenshot` between major steps.
4. Render to an absolute path and tell the user where the file is.
Use this inside execute_code tool calls:
### Common bpy Patterns
import socket, json
Clear scene:
def blender_exec(code: str, host="localhost", port=9876, timeout=15):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((host, port))
s.settimeout(timeout)
payload = json.dumps({"type": "execute_code", "params": {"code": code}})
s.sendall(payload.encode("utf-8"))
buf = b""
while True:
try:
chunk = s.recv(4096)
if not chunk:
break
buf += chunk
try:
json.loads(buf.decode("utf-8"))
break
except json.JSONDecodeError:
continue
except socket.timeout:
break
s.close()
return json.loads(buf.decode("utf-8"))
## Common bpy Patterns
### Clear scene
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
### Add mesh objects
Add mesh objects:
bpy.ops.mesh.primitive_uv_sphere_add(radius=1, location=(0, 0, 0))
bpy.ops.mesh.primitive_cube_add(size=2, location=(3, 0, 0))
bpy.ops.mesh.primitive_cylinder_add(radius=0.5, depth=2, location=(-3, 0, 0))
### Create and assign material
Create and assign material:
mat = bpy.data.materials.new(name="MyMat")
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
@ -97,21 +91,43 @@ Use this inside execute_code tool calls:
bsdf.inputs["Metallic"].default_value = 0.0
obj.data.materials.append(mat)
### Keyframe animation
Keyframe animation:
obj.location = (0, 0, 0)
obj.keyframe_insert(data_path="location", frame=1)
obj.location = (0, 0, 3)
obj.keyframe_insert(data_path="location", frame=60)
### Render to file
Render to file:
bpy.context.scene.render.filepath = "/tmp/render.png"
bpy.context.scene.render.engine = 'CYCLES'
bpy.ops.render.render(write_still=True)
## Pitfalls
- Must check socket is open before running (nc -z localhost 9876)
- Addon server must be started inside Blender each session (N-panel > BlenderMCP > Connect)
- Break complex scenes into multiple smaller execute_code calls to avoid timeouts
- Render output path must be absolute (/tmp/...) not relative
- shade_smooth() requires object to be selected and in object mode
- The blender MCP tools only exist if the server is installed and the session
started after install. If they're missing, run `hermes mcp install blender`
and start a new session.
- The addon bridge must be (re)connected inside Blender each Blender session
(N-panel > BlenderMCP > Connect). "Connection refused" from the tools means
Blender isn't running or the addon isn't connected — fix that, don't retry.
- Break complex scenes into multiple smaller `execute_blender_code` calls to
avoid bridge timeouts.
- Render output paths must be absolute (`/tmp/render.png`), not relative —
they resolve on the BLENDER host's filesystem, which matters if Hermes and
Blender run on different machines.
- `shade_smooth()` requires the object to be selected and in object mode.
- `execute_blender_code` runs arbitrary Python inside Blender with no sandbox
— same trust level as the `terminal` tool. Don't paste untrusted code into
it.
- Do NOT hand-roll raw TCP JSON to port 9876 from `execute_code` — that was
this skill's pre-MCP workaround. It bypasses the catalog's version pinning
and tool curation. The MCP tools are the supported path.
## Verification
- `get_scene_info` returns the expected object list after each build step.
- `get_viewport_screenshot` shows the scene you intended.
- After a render, confirm the output file exists and report its absolute
path to the user.

View file

@ -4,7 +4,9 @@ Covers any endpoint registered as provider="custom", including local
Ollama instances and OpenAI-compatible reasoning endpoints (GLM-5.2 on
Volcengine ARK, vLLM, llama.cpp). Key quirks:
- ollama_num_ctx extra_body.options.num_ctx (local context window)
- reasoning_config disabled extra_body.think = False
- reasoning_config disabled top-level reasoning_effort="none"
(Ollama /v1/chat/completions ignores think=False ollama#14820)
+ extra_body.think = False for /api/chat and proxies
- reasoning_config enabled + effort top-level reasoning_effort
(the native OpenAI-compatible format GLM/ARK expect; unset omits it
so the endpoint's server default applies)
@ -53,6 +55,13 @@ class CustomProfile(ProviderProfile):
_effort = (reasoning_config.get("effort") or "").strip().lower()
_enabled = reasoning_config.get("enabled", True)
if _effort == "none" or _enabled is False:
# Ollama's /v1/chat/completions silently ignores
# extra_body.think (only /api/chat honours it — ollama#14820)
# but respects the top-level reasoning_effort field, so both
# are needed to actually stop a thinking-capable model from
# reasoning (#25758). Endpoints that recognize neither simply
# ignore them.
top_level["reasoning_effort"] = "none"
extra_body["think"] = False
elif _effort:
top_level["reasoning_effort"] = _effort

View file

@ -6612,12 +6612,20 @@ class DiscordAdapter(BasePlatformAdapter):
# ------------------------------------------------------------------
def _text_batch_key(self, event: MessageEvent) -> str:
"""Session-scoped key for text message batching."""
"""Session-scoped key for text message batching.
Passes ``event.source.profile`` through so routed messages batch
under the same namespace the agent run will use (e.g.
``agent:crypto-trader`` instead of ``agent:main``). Without this,
the batch key would always land in ``agent:main`` even when the
routed profile differs.
"""
from gateway.session import build_session_key
return build_session_key(
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
profile=event.source.profile,
)
def _enqueue_text_event(self, event: MessageEvent) -> None:

View file

@ -3633,6 +3633,7 @@ class FeishuAdapter(BasePlatformAdapter):
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
profile=event.source.profile,
)
@staticmethod

View file

@ -3455,6 +3455,7 @@ class MatrixAdapter(BasePlatformAdapter):
thread_sessions_per_user=self.config.extra.get(
"thread_sessions_per_user", False
),
profile=event.source.profile,
)
def _enqueue_text_event(self, event: MessageEvent) -> None:

View file

@ -7925,6 +7925,7 @@ class TelegramAdapter(BasePlatformAdapter):
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
profile=event.source.profile,
)
def _enqueue_text_event(self, event: MessageEvent) -> None:

View file

@ -578,6 +578,7 @@ class WeComAdapter(BasePlatformAdapter):
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
profile=event.source.profile,
)
def _enqueue_text_event(self, event: MessageEvent) -> None:

View file

@ -1278,6 +1278,7 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
profile=event.source.profile,
)
def _enqueue_text_event(self, event: MessageEvent) -> None:

View file

@ -599,6 +599,13 @@ class AIAgent:
return
source = _session_source_for_agent(self.platform)
try:
try:
from hermes_cli.profiles import get_active_profile_name
_profile_for_session = get_active_profile_name()
if _profile_for_session == "default":
_profile_for_session = None
except Exception:
_profile_for_session = None
self._session_db.create_session(
session_id=self.session_id,
source=source,
@ -608,6 +615,7 @@ class AIAgent:
user_id=None,
parent_session_id=self._parent_session_id,
cwd=_launch_cwd_for_session(source),
profile_name=_profile_for_session,
)
self._session_db_created = True
except Exception as e:

View file

@ -45,7 +45,10 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"Burgunthy@users.noreply.github.com": "Burgunthy", # PR #20096 salvage (gateway: profile-based routing for inbound messages)
"75556242+webtecnica@users.noreply.github.com": "webtecnica", # PR #63360 salvage (nous: restore inference-api base_url)
"skosarevivan@yandex.ru": "Epoxidex", # PR #29820 salvage (ollama: top-level reasoning_effort=none; #25758)
"jdjiayou@163.com": "JiaDe-Wu", # PR #34742 salvage (bedrock: bearer routing + streaming fallback + image decode; #28156)
"changhyun.min@gmail.com": "minchang", # PR #42231 salvage (providers: add Upstage Solar)
"neo@neodeMac-mini.local": "neo-claw-bot", # PR #58465 salvage (moa: drop empty user turns from advisory view)
"2024104039@mails.szu.edu.cn": "pixel4039", # PR #64420 salvage (streaming: retry zero-chunk streams)
@ -334,6 +337,7 @@ AUTHOR_MAP = {
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
"157689911+itsflownium@users.noreply.github.com": "itsflownium",
"dirtyren@users.noreply.github.com": "dirtyren",
"dorokuma@users.noreply.github.com": "dorokuma",
"liuwei666888@users.noreply.github.com": "liuwei666888",
"527711370@qq.com": "liuwei666888",
"217401759+justinschille@users.noreply.github.com": "justinschille",

View file

@ -3380,6 +3380,81 @@ class TestAuxiliaryTaskExtraBody:
}
mock_create.assert_called_once()
def _run_anthropic_adapter(self, *, call_extra_body=None, bak_result=None):
"""Drive _AnthropicCompletionsAdapter.create() with mocked SDK layers;
return the api_kwargs handed to create_anthropic_message."""
from agent.auxiliary_client import _AnthropicCompletionsAdapter
adapter = _AnthropicCompletionsAdapter(MagicMock(), "claude-sonnet-4-6", is_oauth=False)
bak_result = bak_result or {
"model": "claude-sonnet-4-6", "messages": [], "max_tokens": 64,
}
with patch("agent.anthropic_adapter.build_anthropic_kwargs",
return_value=dict(bak_result)), \
patch("agent.anthropic_adapter.create_anthropic_message") as mock_create, \
patch("agent.transports.get_transport") as mock_gt:
mock_gt.return_value.normalize_response.return_value = MagicMock(
content="ok", tool_calls=None, reasoning=None, finish_reason="stop",
usage=None, provider_data=None,
)
kwargs = {
"model": "claude-sonnet-4-6",
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 64,
}
if call_extra_body is not None:
kwargs["extra_body"] = call_extra_body
adapter.create(**kwargs)
return mock_create.call_args.args[1]
def test_anthropic_aux_extra_body_passthrough(self):
"""Bug B (#37217): vendor fields in extra_body reach the Anthropic SDK."""
api_kwargs = self._run_anthropic_adapter(
call_extra_body={"thinking": {"type": "disabled"}, "metadata": {"user_id": "u1"}},
)
assert api_kwargs["extra_body"] == {
"thinking": {"type": "disabled"}, "metadata": {"user_id": "u1"},
}
def test_anthropic_aux_extra_body_excludes_reasoning_and_private_keys(self):
"""The OpenAI-shaped reasoning dict is translated (not forwarded), and
private _-prefixed plumbing keys never reach the wire."""
api_kwargs = self._run_anthropic_adapter(
call_extra_body={
"reasoning": {"enabled": True, "effort": "low"},
"_internal": "plumbing",
"metadata": {"user_id": "u1"},
},
)
assert api_kwargs["extra_body"] == {"metadata": {"user_id": "u1"}}
def test_anthropic_aux_extra_body_merges_over_existing(self):
"""Caller extra_body merges on top of what build_anthropic_kwargs
already emitted (fast-mode speed) instead of clobbering it."""
api_kwargs = self._run_anthropic_adapter(
call_extra_body={"metadata": {"user_id": "u1"}},
bak_result={
"model": "claude-sonnet-4-6", "messages": [], "max_tokens": 64,
"extra_body": {"speed": "fast"},
},
)
assert api_kwargs["extra_body"] == {
"speed": "fast", "metadata": {"user_id": "u1"},
}
def test_anthropic_aux_no_extra_body_unchanged(self):
"""Regression guard: no caller extra_body -> kwargs identical to before."""
api_kwargs = self._run_anthropic_adapter(call_extra_body=None)
assert "extra_body" not in api_kwargs
def test_anthropic_aux_reasoning_only_extra_body_adds_nothing(self):
"""extra_body containing ONLY the reasoning key must not create an
empty extra_body dict on the wire."""
api_kwargs = self._run_anthropic_adapter(
call_extra_body={"reasoning": {"enabled": False}},
)
assert "extra_body" not in api_kwargs
def test_no_warning_when_provider_is_custom(self, monkeypatch, caplog):
"""No warning when the provider is 'custom' — OPENAI_BASE_URL is expected."""
import agent.auxiliary_client as mod

View file

@ -0,0 +1,47 @@
"""Regression for #64333 — auxiliary client must survive a version-skewed
agent.process_bootstrap that lacks build_keepalive_http_client.
Desktop installs can run a runtime whose agent/process_bootstrap.py predates
the helper while newer callers (cron scheduler auxiliary_client) expect it.
Before the fix the module-level import made every cron job die with
ImportError before any agent logic ran.
"""
import builtins
import logging
import agent.auxiliary_client as aux
def test_missing_bootstrap_helper_degrades_instead_of_raising(monkeypatch, caplog):
"""ImportError from process_bootstrap → empty kwargs + one-time warning."""
real_import = builtins.__import__
def _fail_bootstrap(name, *args, **kwargs):
if name == "agent.process_bootstrap":
raise ImportError(
"cannot import name 'build_keepalive_http_client' "
"from 'agent.process_bootstrap'"
)
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _fail_bootstrap)
monkeypatch.setattr(aux, "_WARNED_KEEPALIVE_IMPORT_SKEW", False)
with caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"):
result = aux._openai_http_client_kwargs("https://api.example.com/v1")
again = aux._openai_http_client_kwargs("https://api.example.com/v1")
assert result == {}
assert again == {}
skew_warnings = [
r for r in caplog.records if "mixed/stale install" in r.getMessage()
]
assert len(skew_warnings) == 1 # warned once, not per call
def test_healthy_bootstrap_still_injects_keepalive_client():
"""With the helper present, the keepalive http_client is injected."""
result = aux._openai_http_client_kwargs("https://api.example.com/v1")
assert "http_client" in result
assert result["http_client"] is not None

View file

@ -1712,3 +1712,85 @@ class TestRequireBoto3VersionCheck:
with patch.dict("sys.modules", {"boto3": fake_boto3}):
result = _require_boto3()
assert result is fake_boto3
class TestImageBase64Decoding:
"""Image data URLs must be decoded to raw bytes before passing to Converse API.
boto3 re-encodes at the wire layer, so passing the base64 string directly
results in double-encoding. Bedrock rejects with 'Failed to sanitize image'.
Ref: #33317.
"""
def test_data_url_decoded_to_bytes(self):
from agent.bedrock_adapter import _convert_content_to_converse
import base64
# A tiny 1x1 red PNG
raw_png = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
)
data_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
content = [{"type": "image_url", "image_url": {"url": data_url}}]
blocks = _convert_content_to_converse(content)
assert len(blocks) == 1
img_block = blocks[0]["image"]
assert img_block["format"] == "png"
# Must be raw bytes, not a base64 string
assert isinstance(img_block["source"]["bytes"], bytes)
assert img_block["source"]["bytes"] == raw_png
def test_invalid_base64_falls_back_to_encode(self):
from agent.bedrock_adapter import _convert_content_to_converse
data_url = "data:image/jpeg;base64,NOT_VALID_BASE64!!!"
content = [{"type": "image_url", "image_url": {"url": data_url}}]
blocks = _convert_content_to_converse(content)
# Should not crash — falls back to encoding the string as bytes
assert len(blocks) == 1
assert isinstance(blocks[0]["image"]["source"]["bytes"], bytes)
class TestBearerTokenRoutesToConverse:
"""Bearer Token users must go through Converse API, not AnthropicBedrock SDK.
The AnthropicBedrock SDK only supports SigV4 signing it cannot use
AWS_BEARER_TOKEN_BEDROCK. Ref: #28156.
"""
def _resolve(self, monkeypatch, *, bearer: bool):
import os
from hermes_cli import runtime_provider as rp
if bearer:
monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bearer-token-123")
else:
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
assert "AWS_BEARER_TOKEN_BEDROCK" in os.environ or not bearer
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {
"default": "us.anthropic.claude-sonnet-4-6",
"provider": "bedrock",
},
)
monkeypatch.setattr(rp, "load_config", lambda: {"bedrock": {}})
return rp.resolve_runtime_provider(requested="bedrock")
def test_bearer_token_forces_converse_for_claude(self, monkeypatch):
"""Claude model + Bearer Token → bedrock_converse, not anthropic_messages."""
runtime = self._resolve(monkeypatch, bearer=True)
assert runtime["api_mode"] == "bedrock_converse"
assert "bedrock_anthropic" not in runtime
def test_sigv4_claude_still_uses_anthropic_bedrock_sdk(self, monkeypatch):
"""Without a bearer token, Claude keeps the AnthropicBedrock SDK path."""
runtime = self._resolve(monkeypatch, bearer=False)
assert runtime["api_mode"] == "anthropic_messages"
assert runtime.get("bedrock_anthropic") is True

View file

@ -79,6 +79,21 @@ def test_normalize_codex_response_treats_summary_only_reasoning_as_incomplete():
assert assistant_message.codex_reasoning_items is None
def test_normalize_codex_response_maps_incomplete_content_filter_to_refusal():
response = SimpleNamespace(
status="incomplete",
incomplete_details=SimpleNamespace(reason="content_filter"),
output=[],
output_text="",
)
assistant_message, finish_reason = _normalize_codex_response(response)
assert finish_reason == "content_filter"
assert assistant_message.content == ""
assert response.output
# ---------------------------------------------------------------------------
# Server-side built-in tool calls (xAI native web_search, code interpreter,
# etc.) come back as discrete ``*_call`` output items that xAI's
@ -493,14 +508,21 @@ def test_normalize_codex_response_salvage_strips_closing_tag():
def test_normalize_codex_response_salvage_is_xai_scoped():
"""Non-xAI issuers keep the reasoning-only → incomplete classification;
the Codex backend replays encrypted reasoning, so its continuation
genuinely progresses and must not be short-circuited."""
"""Non-xAI special-cased issuers (Codex backend) keep the reasoning-only →
incomplete classification; the Codex backend replays encrypted reasoning,
so its continuation genuinely progresses and must not be short-circuited.
Pins ``issuer_kind="codex_backend"`` explicitly: with no issuer at all,
the unrecognized-backend rule (#64434) trusts ``status="completed"`` and
returns ``stop`` that path is covered by the #64434 regression tests.
"""
response = _xai_reasoning_only_response(
"Thinking.\n<response>The answer.</response>"
)
assistant_message, finish_reason = _normalize_codex_response(response)
assistant_message, finish_reason = _normalize_codex_response(
response, issuer_kind="codex_backend"
)
assert finish_reason == "incomplete"
assert assistant_message.content == ""

View file

@ -462,3 +462,143 @@ def test_large_codex_request_strict_ttfb_env_still_reconnects(tmp_path, monkeypa
assert "codex_ttfb_kill" in closes
finally:
stop["flag"] = True
def test_large_codex_request_hard_ceiling_reclaims_silent_stall(tmp_path, monkeypatch):
"""#64507 regression: a large Codex request (TTFB watchdog disabled by the
size gate, stale floor *raised*) that never emits a single byte must still
be reclaimed at a finite hard ceiling not hang for 13+ minutes while the
worker stays idle and the session shows as active.
Uses the real default TTFB threshold (120s) and asserts the request dies at
the hard ceiling regardless of the size-based TTFB disable.
"""
from agent import chat_completion_helpers as h
agent = _make_codex_agent(tmp_path, monkeypatch)
# Real default TTFB threshold (no HERMES_CODEX_TTFB_* override) → for a
# >10k-token request the no-byte TTFB watchdog is auto-disabled.
monkeypatch.setenv("HERMES_CODEX_HARD_TIMEOUT_SECONDS", "3")
closes: list = []
dummy_client = SimpleNamespace()
monkeypatch.setattr(agent, "_create_request_openai_client", lambda **k: dummy_client)
monkeypatch.setattr(
agent, "_abort_request_openai_client",
lambda c, reason=None: closes.append(reason),
)
monkeypatch.setattr(
agent, "_close_request_openai_client",
lambda c, reason=None: closes.append(reason),
)
stop = {"flag": False}
def fake_hang(api_kwargs, client=None, on_first_delta=None):
# No event marker AND no event ever: the exact issue-64507 stall.
deadline = time.time() + 120
while time.time() < deadline and not stop["flag"] and not agent._interrupt_requested:
time.sleep(0.02)
raise RuntimeError("connection closed")
monkeypatch.setattr(agent, "_run_codex_stream", fake_hang)
large_input = "x" * 44_000 # ~11k estimated tokens → TTFB disabled, stale raised
t0 = time.time()
try:
with pytest.raises(TimeoutError) as excinfo:
h.interruptible_api_call(agent, {"model": "gpt-5.5", "input": large_input})
elapsed = time.time() - t0
# Must die at the hard ceiling (3s), nowhere near the raised stale floor.
assert elapsed < 30, f"hard ceiling took {elapsed:.1f}s — stall not reclaimed"
assert "stale_call_kill" in closes, f"stale kill expected, got {closes}"
assert "timed out after" in str(excinfo.value)
assert "with no response" in str(excinfo.value)
finally:
stop["flag"] = True
def test_large_codex_request_hard_ceiling_disabled_restores_legacy(tmp_path, monkeypatch):
"""Setting HERMES_CODEX_HARD_TIMEOUT_SECONDS=0 disables the ceiling entirely,
restoring the pre-#64507 behavior (request waits out the raised stale floor
instead of being capped). Keeps the knob for operators who must.
"""
from agent import chat_completion_helpers as h
agent = _make_codex_agent(tmp_path, monkeypatch)
monkeypatch.setenv("HERMES_CODEX_HARD_TIMEOUT_SECONDS", "0")
closes: list = []
dummy_client = SimpleNamespace()
monkeypatch.setattr(agent, "_create_request_openai_client", lambda **k: dummy_client)
monkeypatch.setattr(
agent, "_abort_request_openai_client",
lambda c, reason=None: closes.append(reason),
)
monkeypatch.setattr(
agent, "_close_request_openai_client",
lambda c, reason=None: closes.append(reason),
)
sentinel = SimpleNamespace(ok=True)
def fake_stream(api_kwargs, client=None, on_first_delta=None):
# No event, but only briefly — well under the (here 60s) stale timeout.
time.sleep(2.0)
return sentinel
monkeypatch.setattr(agent, "_run_codex_stream", fake_stream)
large_input = "x" * 44_000
resp = h.interruptible_api_call(agent, {"model": "gpt-5.5", "input": large_input})
assert resp is sentinel
assert "codex_ttfb_kill" not in closes
assert "stale_call_kill" not in closes
def test_large_codex_request_hard_ceiling_caps_raised_stale_floor(tmp_path, monkeypatch):
"""The hard ceiling must cap the raised stale floor (openai-codex can push
the stale timeout to 1200s at >100k tokens). A large silent stall must die
at the ceiling, proving the min() wins over the floor.
"""
from agent import chat_completion_helpers as h
agent = _make_codex_agent(tmp_path, monkeypatch)
monkeypatch.setenv("HERMES_CODEX_HARD_TIMEOUT_SECONDS", "4")
# Force the >100k-token tier so openai_codex_stale_timeout_floor returns 1200s.
monkeypatch.setattr(
agent, "_compute_non_stream_stale_timeout", lambda *a, **k: 1200.0
)
closes: list = []
dummy_client = SimpleNamespace()
monkeypatch.setattr(agent, "_create_request_openai_client", lambda **k: dummy_client)
monkeypatch.setattr(
agent, "_abort_request_openai_client",
lambda c, reason=None: closes.append(reason),
)
monkeypatch.setattr(
agent, "_close_request_openai_client",
lambda c, reason=None: closes.append(reason),
)
stop = {"flag": False}
def fake_hang(api_kwargs, client=None, on_first_delta=None):
deadline = time.time() + 200
while time.time() < deadline and not stop["flag"] and not agent._interrupt_requested:
time.sleep(0.02)
raise RuntimeError("connection closed")
monkeypatch.setattr(agent, "_run_codex_stream", fake_hang)
huge_input = "x" * 500_000 # ~125k tokens → stale floor 1200s
t0 = time.time()
try:
with pytest.raises(TimeoutError):
h.interruptible_api_call(agent, {"model": "gpt-5.5", "input": huge_input})
elapsed = time.time() - t0
assert elapsed < 40, f"hard ceiling lost to stale floor: {elapsed:.1f}s"
assert "stale_call_kill" in closes, f"stale kill expected, got {closes}"
finally:
stop["flag"] = True

View file

@ -54,3 +54,77 @@ class TestMediaDirectiveStripping:
result = compressor._serialize_for_summary(turns)
assert "MEDIA:" not in result
assert result.count("[media attachment]") == 2
def test_multimodal_list_content_does_not_crash(self, compressor):
"""content as a list (multimodal) must be flattened to clean text.
Without flattening, str() coercion in redact_sensitive_text dumps
the raw part-dict repr including base64 image data into the
summarizer input, burning summary budget on noise.
"""
turns = [
{
"role": "user",
"content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc123"}},
],
},
]
result = compressor._serialize_for_summary(turns)
assert "What is in this image?" in result
assert "[image]" in result
assert "base64" not in result
def test_multimodal_remote_image_keeps_url(self, compressor):
"""http(s) image parts keep their URL as a referenceable handle."""
turns = [
{
"role": "user",
"content": [
{"type": "text", "text": "look at this"},
{"type": "image_url", "image_url": {"url": "https://example.com/a.png"}},
],
},
]
result = compressor._serialize_for_summary(turns)
assert "[image: https://example.com/a.png]" in result
def test_multimodal_unknown_part_type_keeps_marker(self, compressor):
"""Unknown part types are not silently dropped."""
turns = [
{
"role": "user",
"content": [
{"type": "text", "text": "see attachment"},
{"type": "document", "title": "spec.pdf"},
],
},
]
result = compressor._serialize_for_summary(turns)
assert "see attachment" in result
assert "[document]" in result
def test_multimodal_list_text_parts_extracted(self, compressor):
"""Text parts from multimodal list content are preserved in output."""
turns = [
{
"role": "user",
"content": [
{"type": "text", "text": "first part"},
{"type": "text", "text": "second part"},
],
},
]
result = compressor._serialize_for_summary(turns)
assert "first part" in result
assert "second part" in result
def test_multimodal_list_bare_strings_handled(self, compressor):
"""Bare strings inside a content list are joined."""
turns = [
{"role": "user", "content": ["hello", "world"]},
]
result = compressor._serialize_for_summary(turns)
assert "hello" in result
assert "world" in result

View file

@ -1,5 +1,6 @@
"""Tests for agent/context_compressor.py — compression logic, thresholds, truncation fallback."""
import json
import pytest
import time
from unittest.mock import patch, MagicMock
@ -9,6 +10,7 @@ from agent.context_compressor import (
HISTORICAL_TASK_HEADING,
SUMMARY_PREFIX,
COMPRESSED_SUMMARY_METADATA_KEY,
_summarize_tool_result,
)
from hermes_state import SessionDB
@ -27,6 +29,49 @@ def compressor():
return c
class TestSummarizeToolResultWebExtract:
"""Pre-compression pruning must survive web_extract calls whose ``urls`` are
web_search result dicts ({"url"/"href": ...}), which models routinely forward
straight into web_extract.
"""
CONTENT = "x" * 500 # >200 chars so the pruning pass actually summarizes
def test_multiple_dict_urls_do_not_crash(self):
# Two dict URLs previously hit ``dict + str`` -> TypeError, aborting
# _prune_old_tool_results() (and thus compress()).
args = json.dumps({
"urls": [
{"url": "https://example.com/a", "title": "A"},
{"url": "https://example.org/b", "title": "B"},
]
})
summary = _summarize_tool_result("web_extract", args, self.CONTENT)
assert summary == "[web_extract] https://example.com/a (+1 more) (500 chars)"
def test_single_dict_url_is_unwrapped_not_stringified(self):
args = json.dumps({"urls": [{"url": "https://example.com/a", "title": "A"}]})
summary = _summarize_tool_result("web_extract", args, self.CONTENT)
assert summary == "[web_extract] https://example.com/a (500 chars)"
assert "{" not in summary # no raw dict repr leaked into the summary
def test_href_key_is_unwrapped(self):
args = json.dumps({"urls": [{"href": "https://example.com/h"}]})
summary = _summarize_tool_result("web_extract", args, self.CONTENT)
assert summary == "[web_extract] https://example.com/h (500 chars)"
def test_malformed_dict_falls_back_to_placeholder(self):
args = json.dumps({"urls": [{"title": "no url here"}, {"title": "still none"}]})
summary = _summarize_tool_result("web_extract", args, self.CONTENT)
assert summary == "[web_extract] ? (+1 more) (500 chars)"
def test_plain_string_urls_unchanged(self):
# Regression guard: the normal (already-working) string path is intact.
args = json.dumps({"urls": ["https://example.com/a", "https://example.org/b"]})
summary = _summarize_tool_result("web_extract", args, self.CONTENT)
assert summary == "[web_extract] https://example.com/a (+1 more) (500 chars)"
class TestShouldCompress:
def test_below_threshold(self, compressor):
compressor.last_prompt_tokens = 50000

View file

@ -673,6 +673,34 @@ class TestCodexValidateResponse:
r = SimpleNamespace(output=None, output_text="Some text")
assert transport.validate_response(r) is False
def test_empty_output_content_filter_incomplete_is_valid(self, transport):
r = SimpleNamespace(
status="incomplete",
incomplete_details=SimpleNamespace(reason="content_filter"),
output=[],
output_text="",
)
assert transport.validate_response(r) is True
def test_empty_output_content_filter_dict_incomplete_is_valid(self, transport):
r = SimpleNamespace(
status=" incomplete ",
incomplete_details={"reason": " content_filter "},
output=[],
output_text="",
)
assert transport.validate_response(r) is True
@pytest.mark.parametrize("reason", ["max_output_tokens", "length", "", None])
def test_empty_output_other_incomplete_reasons_remain_invalid(self, transport, reason):
r = SimpleNamespace(
status="incomplete",
incomplete_details=SimpleNamespace(reason=reason),
output=[],
output_text="",
)
assert transport.validate_response(r) is False
class TestCodexMapFinishReason:

View file

@ -1467,3 +1467,93 @@ class TestMultiplexProfilesEnvOverride:
for noise in ("", " ", "maybe", "2"):
monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", noise)
assert _env_multiplex_profiles_override() is None, repr(noise)
class TestMultiplexProfilesConfig:
"""Tests for parsing multiplex_profiles (top-level and nested forms)."""
def test_multiplex_profiles_top_level(self, tmp_path, monkeypatch):
"""Top-level multiplex_profiles is honored."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"multiplex_profiles: true\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert config.multiplex_profiles is True
def test_multiplex_profiles_nested_under_gateway(self, tmp_path, monkeypatch):
"""gateway.multiplex_profiles (the form written by `hermes config set
gateway.multiplex_profiles true`) must be honored. Regression test for
the silent-fallback bug where the loader only forwarded the top-level
key, so users who wrote it under gateway: got multiplex_profiles=False
with no warning."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"gateway:\n multiplex_profiles: true\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert config.multiplex_profiles is True, (
"gateway.multiplex_profiles: true was silently ignored — "
"loader only forwarded the top-level form"
)
def test_multiplex_profiles_default_false(self, tmp_path, monkeypatch):
"""Default is False when neither form is present."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text("", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert config.multiplex_profiles is False
def test_multiplex_profiles_top_level_overrides_nested(self, tmp_path, monkeypatch):
"""When both forms are present, top-level wins (matches profile_routes
and other parity bridges in load_gateway_config)."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"multiplex_profiles: true\n"
"gateway:\n multiplex_profiles: false\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert config.multiplex_profiles is True
def test_multiplex_profiles_explicit_top_level_false_not_consulting_nested(
self, tmp_path, monkeypatch
):
"""Lock in the `is None` vs `is False` distinction: when top-level is
explicitly false, the loader must forward False WITHOUT consulting the
nested form (so a stale `gateway.multiplex_profiles: true` cannot
silently re-enable multiplexing). Guards against a future regression
that flips the check to `not _mp`."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"multiplex_profiles: false\n"
"gateway:\n multiplex_profiles: true\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert config.multiplex_profiles is False, (
"Explicit top-level false was overridden by nested true — "
"loader must respect top-level precedence when key is present"
)

View file

@ -0,0 +1,190 @@
"""Regression tests for hidden-reasoning-only incomplete gateway turns."""
import asyncio
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
import gateway.run as gateway_run
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, ProcessingOutcome, SendResult
from gateway.session import SessionEntry, SessionSource, build_session_key
class CaptureSlackAdapter(BasePlatformAdapter):
def __init__(self):
super().__init__(PlatformConfig(enabled=True, token="fake-token"), Platform.SLACK)
self.sent = []
self.processing_hooks = []
async def connect(self) -> bool:
return True
async def disconnect(self) -> None:
return None
async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:
self.sent.append(
{
"chat_id": chat_id,
"content": content,
"reply_to": reply_to,
"metadata": metadata,
}
)
return SendResult(success=True, message_id="slack-1")
async def send_typing(self, chat_id: str, metadata=None) -> None:
return None
async def get_chat_info(self, chat_id: str):
return {"id": chat_id}
async def on_processing_start(self, event: MessageEvent) -> None:
self.processing_hooks.append(("start", event.message_id))
async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None:
self.processing_hooks.append(("complete", event.message_id, outcome))
def _make_incomplete_result() -> dict:
# Mirror the REAL conversation-loop exhaustion shape: the sentinel text is
# returned as BOTH final_response and error (agent/conversation_loop.py's
# "remained incomplete after 3 continuation attempts" return).
_sentinel = "Codex response remained incomplete after 3 continuation attempts"
return {
"final_response": _sentinel,
"messages": [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": ""},
],
"tools": [],
"history_offset": 0,
"api_calls": 3,
"partial": True,
"completed": False,
"interrupted": False,
"error": _sentinel,
"last_prompt_tokens": 0,
}
def _make_runner(adapter: CaptureSlackAdapter) -> gateway_run.GatewayRunner:
runner = object.__new__(gateway_run.GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.SLACK: PlatformConfig(enabled=True, token="fake-token")}
)
runner.adapters = {Platform.SLACK: adapter}
runner._voice_mode = {}
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = SessionEntry(
session_key="agent:main:slack:channel:C123:171717",
session_id="sess-1",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.SLACK,
chat_type="channel",
)
runner.session_store.load_transcript.return_value = []
runner.session_store.has_any_sessions.return_value = True
runner.session_store.rewrite_transcript = MagicMock()
runner.session_store.append_to_transcript = MagicMock()
runner.session_store.update_session = MagicMock()
# The transient-failure persistence path dedupes on platform message_id
# (#47237). A bare MagicMock returns a truthy mock, which would wrongly
# mark the user turn as a duplicate and skip persisting it.
runner.session_store.has_platform_message_id = MagicMock(return_value=False)
runner._running_agents = {}
runner._pending_messages = {}
runner._pending_approvals = {}
runner._session_db = None
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._run_agent = AsyncMock(return_value=_make_incomplete_result())
return runner
def _make_event() -> MessageEvent:
return MessageEvent(
text="hello",
source=SessionSource(
platform=Platform.SLACK,
chat_id="C123",
chat_type="channel",
thread_id="171717",
user_id="U123",
),
message_id="m-1",
)
def test_incomplete_codex_warning_is_not_surfaced_as_chat_text():
agent_result = _make_incomplete_result()
# Mirror the gateway pipeline: the hidden-turn detector blanks the
# sentinel final_response BEFORE empty-response normalization runs.
response = agent_result.get("final_response") or ""
assert gateway_run._is_gateway_hidden_reasoning_incomplete_turn(agent_result)
response = ""
response = gateway_run._normalize_empty_agent_response(
agent_result,
response,
history_len=4,
)
assert response == ""
def test_real_answer_alongside_incomplete_error_is_never_suppressed():
"""A turn whose final_response is genuine model text (not the sentinel
echo) must be delivered even when the error field carries the
retry-exhaustion sentinel suppression is only for hidden turns."""
agent_result = _make_incomplete_result()
agent_result["final_response"] = "Here is the actual answer."
assert not gateway_run._is_gateway_hidden_reasoning_incomplete_turn(agent_result)
def test_interrupted_or_failed_turns_are_not_classified_hidden():
for key in ("interrupted", "failed"):
agent_result = _make_incomplete_result()
agent_result[key] = True
assert not gateway_run._is_gateway_hidden_reasoning_incomplete_turn(agent_result)
@pytest.mark.asyncio
async def test_incomplete_codex_turn_stays_out_of_slack_transcript(monkeypatch, tmp_path):
adapter = CaptureSlackAdapter()
runner = _make_runner(adapter)
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "fake"})
monkeypatch.setattr(
"agent.model_metadata.get_model_context_length",
lambda *_args, **_kwargs: 100,
)
monkeypatch.setenv("SLACK_HOME_CHANNEL", "C123")
adapter.set_message_handler(runner._handle_message)
adapter._keep_typing = lambda *_args, **_kwargs: asyncio.Event().wait()
event = _make_event()
await adapter._process_message_background(event, build_session_key(event.source))
assert adapter.sent == []
assert runner.session_store.update_session.called
transcript_roles = [
call.args[1]["role"]
for call in runner.session_store.append_to_transcript.call_args_list
]
assert transcript_roles == ["session_meta", "user"]
assert runner.session_store.append_to_transcript.call_args_list[1].args[1]["content"] == "hello"
assert adapter.processing_hooks == [
("start", "m-1"),
("complete", "m-1", ProcessingOutcome.SUCCESS),
]

View file

@ -45,6 +45,58 @@ class TestCredentialFingerprint:
assert "config-token" not in fp
def test_reads_config_token(self):
"""Adapters like Discord store token on `config`, not on self.
Without the config-token fallback, every Discord adapter in a
multiplexed gateway returns None here and the same-token conflict
check is silently skipped N adapters start polling the same bot
token and race on every inbound message.
"""
class _Config:
token = "discord-bot-token"
class _ConfigBackedAdapter:
config = _Config()
fp = GatewayRunner._adapter_credential_fingerprint(_ConfigBackedAdapter())
assert fp is not None
assert "discord-bot-token" not in fp
assert len(fp) == 16
def test_distinct_config_tokens_distinct_fp(self):
class _CfgA:
token = "tok-A"
class _CfgB:
token = "tok-B"
class _A:
config = _CfgA()
class _B:
config = _CfgB()
a = GatewayRunner._adapter_credential_fingerprint(_A())
b = GatewayRunner._adapter_credential_fingerprint(_B())
assert a is not None and b is not None
assert a != b
def test_direct_token_takes_precedence_over_config(self):
"""If both `adapter.token` and `adapter.config.token` exist, direct wins."""
class _Cfg:
token = "from-config"
class _Both:
token = "from-direct"
config = _Cfg()
fp = GatewayRunner._adapter_credential_fingerprint(_Both())
import hashlib
expected = hashlib.sha256(b"hermes-mux:from-direct").hexdigest()[:16]
assert fp == expected
def test_config_without_token_returns_none(self):
"""config present but no token attribute → None (no false positive)."""
class _Cfg:
pass
class _Adapter:
config = _Cfg()
assert GatewayRunner._adapter_credential_fingerprint(_Adapter()) is None
class TestProfileMessageHandler:
@pytest.mark.asyncio
async def test_stamps_profile_on_unstamped_source(self):

View file

@ -0,0 +1,471 @@
"""Tests for GatewayRunner._resolve_profile_home_for_source — profile resolution logic."""
import logging
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from gateway.session import SessionSource, build_session_key
from gateway.run import GatewayRunner
from gateway.profile_routing import ProfileRoute
from gateway.config import Platform
from gateway.platforms.base import BasePlatformAdapter
@pytest.fixture
def mock_runner():
"""Create a minimal mock GatewayRunner with the methods we need."""
runner = MagicMock(spec=GatewayRunner)
runner.config = MagicMock(profile_routes=[])
# Bind the actual methods to the mock
runner._profile_name_for_source = GatewayRunner._profile_name_for_source.__get__(runner)
runner._resolve_profile_home_for_source = GatewayRunner._resolve_profile_home_for_source.__get__(runner)
return runner
@pytest.fixture
def discord_source():
"""Create a basic Discord SessionSource for testing."""
return SessionSource(
platform=MagicMock(value="discord"),
chat_id="123456",
guild_id="789",
thread_id=None,
parent_chat_id=None,
)
@pytest.fixture
def telegram_source():
"""Create a basic Telegram SessionSource for testing.
Telegram (like Slack/Feishu/etc.) has no ``guild_id`` only ``chat_id``.
Used to prove profile routing is platform-generic, not Discord-only.
"""
return SessionSource(
platform=MagicMock(value="telegram"),
chat_id="-1001234567890",
guild_id=None,
thread_id=None,
parent_chat_id=None,
)
class TestResolutionOrder:
"""Tests that profile resolution follows the correct priority order."""
def test_source_profile_wins_over_routing(self, mock_runner, discord_source):
"""source.profile should be used even if routing would match."""
discord_source.profile = "from-source"
with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"):
with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir:
with patch("hermes_cli.profiles.profile_exists", return_value=True):
mock_get_dir.return_value = Path("/hermes/profiles/from-source")
result = mock_runner._resolve_profile_home_for_source(discord_source)
assert result == Path("/hermes/profiles/from-source")
mock_get_dir.assert_called_once_with("from-source")
def test_routing_wins_over_active_profile(self, mock_runner, discord_source):
"""When source.profile is empty, routing should win over active profile."""
discord_source.profile = None
# Mock routing to return a profile
with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"):
with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir:
with patch("hermes_cli.profiles.profile_exists", return_value=True):
mock_get_dir.return_value = Path("/hermes/profiles/routed")
# Manually set routing to return a profile
mock_runner._profile_name_for_source = MagicMock(return_value="routed")
result = mock_runner._resolve_profile_home_for_source(discord_source)
assert result == Path("/hermes/profiles/routed")
mock_get_dir.assert_called_once_with("routed")
def test_active_profile_fallback(self, mock_runner, discord_source):
"""When source.profile and routing both return None, active profile is used."""
discord_source.profile = None
with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"):
with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir:
mock_get_dir.return_value = Path("/hermes/profiles/active")
# No routing match
mock_runner._profile_name_for_source = MagicMock(return_value=None)
result = mock_runner._resolve_profile_home_for_source(discord_source)
assert result == Path("/hermes/profiles/active")
mock_get_dir.assert_called_once_with("active")
def test_default_fallback_when_no_active(self, mock_runner, discord_source):
"""When even active profile is None, 'default' is used."""
discord_source.profile = None
with patch("hermes_cli.profiles.get_active_profile_name", return_value=None):
with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir:
mock_get_dir.return_value = Path("/hermes")
mock_runner._profile_name_for_source = MagicMock(return_value=None)
result = mock_runner._resolve_profile_home_for_source(discord_source)
assert result == Path("/hermes")
mock_get_dir.assert_called_once_with("default")
class TestMissingProfileWarning:
"""Tests for warning when a profile doesn't exist on disk."""
def test_nonexistent_profile_warning(self, mock_runner, discord_source, caplog):
"""When source.profile points to a nonexistent profile, log a WARNING."""
discord_source.profile = "nonexistent"
with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"):
with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir:
mock_get_dir.return_value = Path("/hermes/profiles/nonexistent")
with patch("hermes_cli.profiles.profile_exists", return_value=False):
with patch("hermes_constants.get_hermes_home", return_value=Path("/hermes")):
with caplog.at_level(logging.WARNING):
result = mock_runner._resolve_profile_home_for_source(discord_source)
# Should fall back to global HERMES_HOME
assert result == Path("/hermes")
# Should have logged a warning
assert len(caplog.records) == 1
assert caplog.records[0].levelname == "WARNING"
assert "nonexistent" in caplog.records[0].message
assert "does not exist" in caplog.records[0].message
assert "discord" in caplog.records[0].message
assert "123456" in caplog.records[0].message
def test_nonexistent_routing_profile_warning(self, mock_runner, discord_source, caplog):
"""When routing returns a nonexistent profile, log a WARNING."""
discord_source.profile = None
with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"):
with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir:
mock_get_dir.return_value = Path("/hermes/profiles/routed")
with patch("hermes_cli.profiles.profile_exists", return_value=False):
with patch("hermes_constants.get_hermes_home", return_value=Path("/hermes")):
# Routing returns a profile that doesn't exist
mock_runner._profile_name_for_source = MagicMock(return_value="routed")
with caplog.at_level(logging.WARNING):
result = mock_runner._resolve_profile_home_for_source(discord_source)
# Should fall back to global HERMES_HOME
assert result == Path("/hermes")
# Should have logged a warning
assert len(caplog.records) == 1
assert "routed" in caplog.records[0].message
def test_empty_source_profile_no_warning(self, mock_runner, discord_source, caplog):
"""When source.profile is empty, silent fallback to active profile (no warning)."""
discord_source.profile = None
with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"):
with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir:
mock_get_dir.return_value = Path("/hermes/profiles/active")
with patch("hermes_cli.profiles.profile_exists", return_value=True):
with caplog.at_level(logging.WARNING):
mock_runner._profile_name_for_source = MagicMock(return_value=None)
result = mock_runner._resolve_profile_home_for_source(discord_source)
# Should use active profile
assert result == Path("/hermes/profiles/active")
# No warnings (active profile exists)
assert not any(r.levelname == "WARNING" for r in caplog.records)
def test_existing_profile_no_warning(self, mock_runner, discord_source, caplog):
"""When the profile exists, no warning should be logged."""
discord_source.profile = "existing"
with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"):
with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir:
mock_get_dir.return_value = Path("/hermes/profiles/existing")
with patch("hermes_cli.profiles.profile_exists", return_value=True):
with caplog.at_level(logging.WARNING):
result = mock_runner._resolve_profile_home_for_source(discord_source)
assert result == Path("/hermes/profiles/existing")
# No warnings
assert not any(r.levelname == "WARNING" for r in caplog.records)
class TestExceptionHandling:
"""Tests for exception handling in profile resolution."""
def test_get_profile_dir_exception_logs_warning(self, mock_runner, discord_source, caplog):
"""When get_profile_dir raises an exception, log a WARNING with context."""
discord_source.profile = "bad-profile"
with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"):
with patch("hermes_cli.profiles.get_profile_dir", side_effect=ValueError("Invalid profile name")):
with patch("hermes_constants.get_hermes_home", return_value=Path("/hermes")):
with caplog.at_level(logging.WARNING):
result = mock_runner._resolve_profile_home_for_source(discord_source)
# Should fall back to global HERMES_HOME
assert result == Path("/hermes")
# Should have logged a warning with exception info
assert len(caplog.records) == 1
assert caplog.records[0].levelname == "WARNING"
assert "bad-profile" in caplog.records[0].message
assert "Failed to resolve profile directory" in caplog.records[0].message
def test_exception_with_no_profile_name(self, mock_runner, discord_source, caplog):
"""Exception when no profile was set should still log a warning."""
discord_source.profile = None
with patch("hermes_cli.profiles.get_active_profile_name", return_value=None):
with patch("hermes_cli.profiles.get_profile_dir", side_effect=RuntimeError("Filesystem error")):
with patch("hermes_constants.get_hermes_home", return_value=Path("/hermes")):
mock_runner._profile_name_for_source = MagicMock(return_value=None)
with caplog.at_level(logging.WARNING):
result = mock_runner._resolve_profile_home_for_source(discord_source)
assert result == Path("/hermes")
# Warning should mention "(no profile)"
assert "(no profile)" in caplog.records[0].message
class TestRoutingConsultation:
"""Tests that _profile_name_for_source is consulted when source.profile is empty."""
def test_routing_consulted_when_source_profile_empty(self, mock_runner, discord_source):
"""_profile_name_for_source should be called when source.profile is empty."""
discord_source.profile = None
with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"):
with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir:
mock_get_dir.return_value = Path("/hermes/profiles/routed")
mock_runner._profile_name_for_source = MagicMock(return_value="routed")
mock_runner._resolve_profile_home_for_source(discord_source)
# Should have called routing
mock_runner._profile_name_for_source.assert_called_once_with(discord_source)
def test_routing_not_consulted_when_source_profile_set(self, mock_runner, discord_source):
"""_profile_name_for_source should NOT be called when source.profile is set."""
discord_source.profile = "from-source"
with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"):
with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir:
mock_get_dir.return_value = Path("/hermes/profiles/from-source")
mock_runner._profile_name_for_source = MagicMock(return_value="routed")
mock_runner._resolve_profile_home_for_source(discord_source)
# Should NOT have called routing
mock_runner._profile_name_for_source.assert_not_called()
class TestNonDiscordProfileRouting:
"""Profile routing must be platform-generic, not Discord-only.
Regression coverage for the ``gateway_runner`` injection gap: previously
only Discord's adapter pre-declared ``gateway_runner``, so only Discord
ever had ``build_source`` call ``_profile_name_for_source``. Telegram /
Feishu / Slack / etc. silently fell through to the default profile. These
tests pin the resolution half for a non-Discord platform (Telegram).
"""
def test_telegram_route_resolves(self, mock_runner, telegram_source):
"""A configured Telegram route resolves to its profile via the real
``_profile_name_for_source`` (bound onto the mock runner)."""
mock_runner.config.profile_routes = [
ProfileRoute(name="tg", platform="telegram", profile="tg-profile",
chat_id="-1001234567890"),
]
telegram_source.profile = None
assert mock_runner._profile_name_for_source(telegram_source) == "tg-profile"
def test_telegram_no_route_returns_none(self, mock_runner, telegram_source):
"""With no matching Telegram route, resolution returns None (caller
falls back to the default/active profile)."""
mock_runner.config.profile_routes = [
ProfileRoute(name="dc", platform="discord", profile="dc-profile",
chat_id="123456"),
]
telegram_source.profile = None
assert mock_runner._profile_name_for_source(telegram_source) is None
class TestGatewayRunnerInjection:
"""``BasePlatformAdapter`` declares ``gateway_runner`` so the gateway's
unconditional injection reaches every platform adapter the foundation
that makes the routing in TestNonDiscordProfileRouting reachable at runtime.
"""
def test_base_adapter_declares_gateway_runner(self):
from gateway.platforms.base import BasePlatformAdapter
# Class-level attribute exists and defaults to None.
assert hasattr(BasePlatformAdapter, "gateway_runner")
assert BasePlatformAdapter.gateway_runner is None
def test_subclass_inherits_gateway_runner(self):
from gateway.platforms.base import BasePlatformAdapter
class _ToyAdapter(BasePlatformAdapter):
pass
# No manual declaration — yet the attribute is inherited from the base,
# so the gateway's ``adapter.gateway_runner = self`` injection reaches
# every adapter, not just the ones that pre-declared it (Discord).
assert hasattr(_ToyAdapter, "gateway_runner")
assert _ToyAdapter.gateway_runner is None
# A concrete adapter we can instantiate without the full platform stack.
# ``build_source`` only reads ``self.platform`` and ``self.gateway_runner``, so a
# bare instance with those two attrs exercises the real BasePlatformAdapter
# method end-to-end. Clearing ``__abstractmethods__`` lets ``__new__`` bypass
# the ABC instantiation guard without stubbing connect/send/get_chat_info/…
class _StubAdapter(BasePlatformAdapter):
pass
_StubAdapter.__abstractmethods__ = frozenset() # type: ignore[attr-defined]
def _stub_adapter(platform: Platform, runner) -> "_StubAdapter":
a = _StubAdapter.__new__(_StubAdapter)
a.platform = platform
a.gateway_runner = runner
return a
class TestAdapterToSessionKeyIntegration:
"""Adapter -> ``source.profile`` -> session-key integration coverage.
The review asked for integration coverage for Discord AND a non-Discord
platform. These drive a concrete adapter's real ``build_source``
(BasePlatformAdapter) with an injected ``gateway_runner``, assert the
matched route's profile is stamped on the source, and that the resulting
session key is profile-scoped (``agent:<profile>:...`` rather than the
shared ``agent:main:...``). The Telegram case is the bug-#2 regression:
pre-fix it never received ``gateway_runner`` and fell through to default.
"""
@staticmethod
def _routes():
return [
ProfileRoute(name="dc", platform="discord", profile="coder",
guild_id="111", chat_id="222"),
ProfileRoute(name="tg", platform="telegram", profile="ops",
chat_id="-1001234567890"),
]
def test_discord_adapter_stamps_profile_and_scopes_key(self, mock_runner):
mock_runner.config.profile_routes = self._routes()
adapter = _stub_adapter(Platform.DISCORD, mock_runner)
source = adapter.build_source(
chat_id="222", chat_type="group", guild_id="111", user_id="u1",
)
assert source.profile == "coder"
key = build_session_key(source, profile=source.profile)
assert key.startswith("agent:coder:"), key
# A default-profile key would land in agent:main — must differ.
assert key != build_session_key(source, profile=None)
def test_telegram_adapter_stamps_profile_and_scopes_key(self, mock_runner):
"""Non-Discord platform (bug #2). The adapter now receives
``gateway_runner``, so ``build_source`` stamps the profile and the
session key is isolated under ``agent:ops:`` instead of ``agent:main:``."""
mock_runner.config.profile_routes = self._routes()
adapter = _stub_adapter(Platform.TELEGRAM, mock_runner)
source = adapter.build_source(
chat_id="-1001234567890", chat_type="group", user_id="u1",
)
assert source.profile == "ops"
key = build_session_key(source, profile=source.profile)
assert key.startswith("agent:ops:"), key
assert key != build_session_key(source, profile=None)
def test_adapter_without_runner_falls_back_to_default_namespace(self, mock_runner):
"""Regression anchor: with no ``gateway_runner`` injected (the pre-fix
state for non-Discord adapters), ``build_source`` leaves ``profile=None``
and the session key is the shared ``agent:main:`` namespace no
per-profile isolation. This is the silent fallback the fix removes for
non-Discord platforms."""
adapter = _stub_adapter(Platform.TELEGRAM, runner=None)
source = adapter.build_source(
chat_id="-1001234567890", chat_type="group", user_id="u1",
)
assert source.profile is None
key = build_session_key(source, profile=source.profile)
assert key.startswith("agent:main:"), key
class TestMultiplexGate:
"""``profile_routes`` only activates under ``gateway.multiplex_profiles``.
Routing stamps ``source.profile``, which namespaces session/batch keys
but the profile-scoped agent run (``_profile_runtime_scope``) only engages
when multiplexing is on. Without the gate, a configured route with
multiplexing off would split batch/session keys into ``agent:<profile>``
while the agent still served the turn from ``agent:main``'s home.
"""
def test_routes_ignored_when_multiplex_off(self, mock_runner, discord_source):
mock_runner.config.multiplex_profiles = False
mock_runner.config.profile_routes = [
ProfileRoute(name="dc", platform="discord", profile="coder",
guild_id="789", chat_id="123456"),
]
discord_source.profile = None
assert mock_runner._profile_name_for_source(discord_source) is None
def test_routes_active_when_multiplex_on(self, mock_runner, discord_source):
mock_runner.config.multiplex_profiles = True
mock_runner.config.profile_routes = [
ProfileRoute(name="dc", platform="discord", profile="coder",
guild_id="789", chat_id="123456"),
]
discord_source.profile = None
assert mock_runner._profile_name_for_source(discord_source) == "coder"
def test_build_source_leaves_profile_none_when_multiplex_off(self, mock_runner):
"""End-to-end through the real adapter ``build_source``: with routes
configured but multiplexing off, no profile is stamped and the session
key stays in the legacy ``agent:main`` namespace byte-identical to a
gateway with no routes at all."""
mock_runner.config.multiplex_profiles = False
mock_runner.config.profile_routes = [
ProfileRoute(name="dc", platform="discord", profile="coder",
guild_id="111", chat_id="222"),
]
adapter = _stub_adapter(Platform.DISCORD, mock_runner)
source = adapter.build_source(
chat_id="222", chat_type="group", guild_id="111", user_id="u1",
)
assert source.profile is None
key = build_session_key(source, profile=source.profile)
assert key.startswith("agent:main:"), key

View file

@ -0,0 +1,261 @@
"""Tests for gateway/profile_routing.py — profile-based routing."""
import pytest
from gateway.profile_routing import (
ProfileRoute,
parse_profile_routes,
match_profile_route,
)
class TestProfileRoute:
def test_specificity_thread(self):
r = ProfileRoute(name="t", platform="discord", profile="p",
guild_id="g", chat_id="c", thread_id="t")
assert r.specificity == 14 # 2 + 4 + 8
def test_specificity_channel(self):
r = ProfileRoute(name="c", platform="discord", profile="p",
guild_id="g", chat_id="c")
assert r.specificity == 6 # 2 + 4
def test_specificity_guild(self):
r = ProfileRoute(name="g", platform="discord", profile="p",
guild_id="g")
assert r.specificity == 2
def test_specificity_minimal(self):
r = ProfileRoute(name="m", platform="telegram", profile="p")
assert r.specificity == 0
def test_frozen(self):
r = ProfileRoute(name="x", platform="discord", profile="p")
with pytest.raises(AttributeError):
r.name = "y"
class TestProfileRouteMatching:
def test_exact_thread_match(self):
r = ProfileRoute(name="t", platform="discord", profile="trader",
guild_id="111", chat_id="222", thread_id="333")
assert r.matches("discord", guild_id="111", chat_id="222", thread_id="333")
assert not r.matches("discord", guild_id="111", chat_id="222", thread_id="444")
def test_channel_match(self):
r = ProfileRoute(name="c", platform="discord", profile="helper",
chat_id="222")
assert r.matches("discord", chat_id="222")
assert not r.matches("discord", chat_id="333")
assert not r.matches("telegram", chat_id="222")
def test_guild_match(self):
r = ProfileRoute(name="g", platform="discord", profile="server",
guild_id="111")
assert r.matches("discord", guild_id="111")
assert not r.matches("discord", guild_id="222")
def test_disabled_route_no_match(self):
r = ProfileRoute(name="d", platform="discord", profile="off",
guild_id="111", enabled=False)
assert not r.matches("discord", guild_id="111")
def test_guild_route_matches_any_channel_in_guild(self):
r = ProfileRoute(name="g", platform="discord", profile="server",
guild_id="111")
assert r.matches("discord", guild_id="111", chat_id="222")
assert r.matches("discord", guild_id="111", chat_id="222", thread_id="333")
def test_extra_fields_ignored(self):
r = ProfileRoute(name="g", platform="discord", profile="server",
guild_id="111")
assert r.matches("discord", guild_id="111", chat_id="any")
def test_guild_and_chat_are_conjunctive(self):
# A route declaring BOTH guild_id and chat_id requires both to match.
# Regression guard: previously chat_id was checked first and returned
# True before guild_id was ever consulted.
r = ProfileRoute(name="gc", platform="discord", profile="scoped",
guild_id="111", chat_id="222")
# Both match (direct channel) -> match
assert r.matches("discord", guild_id="111", chat_id="222")
# Both match via parent (thread inside the channel) -> match
assert r.matches("discord", guild_id="111", chat_id="333", parent_chat_id="222")
# chat matches but guild differs -> NO match (the bug this guards)
assert not r.matches("discord", guild_id="999", chat_id="222")
# guild matches but chat differs -> NO match
assert not r.matches("discord", guild_id="111", chat_id="333")
class TestParseProfileRoutes:
def test_empty(self):
assert parse_profile_routes(None) == []
assert parse_profile_routes([]) == []
def test_valid_routes_sorted_by_specificity(self):
raw = [
{"name": "guild", "platform": "discord", "profile": "p", "guild_id": "1"},
{"name": "thread", "platform": "discord", "profile": "p",
"guild_id": "1", "chat_id": "2", "thread_id": "3"},
{"name": "channel", "platform": "discord", "profile": "p", "chat_id": "2"},
]
routes = parse_profile_routes(raw)
names = [r.name for r in routes]
assert names == ["thread", "channel", "guild"]
def test_skips_invalid(self):
raw = [
{"platform": "discord"},
{"profile": "p"},
"not a dict",
{"name": "ok", "platform": "telegram", "profile": "p"},
]
routes = parse_profile_routes(raw)
assert len(routes) == 1
assert routes[0].name == "ok"
def test_enabled_flag(self):
raw = [
{"name": "off", "platform": "discord", "profile": "p",
"guild_id": "1", "enabled": False},
{"name": "on", "platform": "discord", "profile": "p", "guild_id": "1"},
]
routes = parse_profile_routes(raw)
assert not routes[0].enabled
assert routes[1].enabled
class TestMatchProfileRoute:
def test_no_routes(self):
assert match_profile_route([], "discord") is None
def test_returns_first_match(self):
routes = [
ProfileRoute(name="thread", platform="discord", profile="trader",
guild_id="1", chat_id="2", thread_id="3"),
ProfileRoute(name="channel", platform="discord", profile="helper",
chat_id="2"),
]
m = match_profile_route(routes, "discord", guild_id="1", chat_id="2", thread_id="3")
assert m is not None
assert m.profile == "trader"
def test_falls_through_to_channel(self):
routes = [
ProfileRoute(name="thread", platform="discord", profile="trader",
guild_id="1", chat_id="2", thread_id="3"),
ProfileRoute(name="channel", platform="discord", profile="helper",
chat_id="2"),
]
m = match_profile_route(routes, "discord", guild_id="1", chat_id="2")
assert m is not None
assert m.profile == "helper"
def test_no_match_returns_none(self):
routes = [
ProfileRoute(name="r", platform="telegram", profile="p"),
]
assert match_profile_route(routes, "discord") is None
class TestSessionKeyIntegration:
def test_default_profile_key(self):
from gateway.session import build_session_key, SessionSource, Platform
src = SessionSource(platform=Platform.DISCORD, chat_id="123",
chat_type="channel", user_id="456")
key = build_session_key(src)
assert key.startswith("agent:main:")
def test_custom_profile_key(self):
from gateway.session import build_session_key, SessionSource, Platform
src = SessionSource(platform=Platform.DISCORD, chat_id="123",
chat_type="channel", user_id="456")
key = build_session_key(src, profile="trader")
assert key.startswith("agent:trader:")
assert key == "agent:trader:discord:channel:123:456"
def test_isolated_sessions(self):
from gateway.session import build_session_key, SessionSource, Platform
src = SessionSource(platform=Platform.DISCORD, chat_id="123",
chat_type="channel", user_id="456")
key_default = build_session_key(src)
key_trader = build_session_key(src, profile="trader")
assert key_default != key_trader
def test_dm_profile_scoped(self):
from gateway.session import build_session_key, SessionSource, Platform
src = SessionSource(platform=Platform.DISCORD, chat_id="999",
chat_type="dm", user_id="111")
key = build_session_key(src, profile="bot2")
assert key == "agent:bot2:discord:dm:999"
class TestParentChatIdMatching:
"""Thread messages carry thread_id as chat_id; parent_chat_id is the channel."""
def test_channel_route_matches_via_parent_chat_id(self):
r = ProfileRoute(name="ch", platform="discord", profile="trader",
chat_id="222")
assert r.matches("discord", chat_id="333", parent_chat_id="222")
def test_channel_route_no_match_wrong_parent(self):
r = ProfileRoute(name="ch", platform="discord", profile="trader",
chat_id="222")
assert not r.matches("discord", chat_id="333", parent_chat_id="444")
def test_match_profile_route_with_parent_chat_id(self):
routes = [
ProfileRoute(name="ch", platform="discord", profile="trader",
chat_id="222"),
]
m = match_profile_route(routes, "discord", chat_id="333", parent_chat_id="222")
assert m is not None
assert m.profile == "trader"
def test_thread_id_does_not_match_parent_chat_id(self):
"""thread_id only matches the actual thread_id, never parent_chat_id.
Discord snowflakes are globally unique, so thread_id != channel_id."""
r = ProfileRoute(name="th", platform="discord", profile="helper",
thread_id="555")
assert r.matches("discord", thread_id="555")
assert not r.matches("discord", parent_chat_id="555")
def test_no_parent_chat_id_still_works(self):
r = ProfileRoute(name="ch", platform="discord", profile="trader",
chat_id="222")
assert r.matches("discord", chat_id="222")
def test_guild_route_matches_with_parent_chat_id(self):
"""Guild routes should match regardless of chat_id or parent_chat_id."""
r = ProfileRoute(name="g", platform="discord", profile="server",
guild_id="111")
assert r.matches("discord", guild_id="111", chat_id="333", parent_chat_id="444")
class TestForumPostMatching:
"""Test that forum posts match via parent_chat_id (direct parent)."""
def test_forum_channel_route_matches_forum_post(self):
"""A route on a forum channel should match comments on posts in that forum.
In Discord, forum posts (threads) have parent_chat_id = forum channel ID.
No cache is needed the parent relationship is direct.
"""
r = ProfileRoute(name="forum", platform="discord", profile="forum_profile",
chat_id="forum_channel_123")
# A comment on a forum post: chat_id=post_thread_id, parent_chat_id=forum_channel_id
assert r.matches("discord", chat_id="post_thread_456", parent_chat_id="forum_channel_123")
def test_forum_post_comment_matches_channel_not_thread_id(self):
"""Verify that thread_id matching is distinct from parent_chat_id matching."""
routes = [
ProfileRoute(name="forum", platform="discord", profile="forum_profile",
chat_id="forum_channel_123"),
ProfileRoute(name="post", platform="discord", profile="post_profile",
thread_id="post_thread_456"),
]
# A comment on the forum post should match the forum channel route, not the thread route
m = match_profile_route(routes, "discord", chat_id="post_thread_456",
parent_chat_id="forum_channel_123")
assert m is not None
assert m.profile == "forum_profile"

View file

@ -0,0 +1,82 @@
"""Regression tests for #28156 — Bedrock picker must be region-scoped.
Geo-prefixed cross-region inference profiles (us.*, eu.*, apac.*, ...) only
route from endpoints in their own geography. Offering us.* profiles to an
eu-central-2 picker produces configs AWS rejects regardless of credentials.
"""
from hermes_cli.model_setup_flows import (
BEDROCK_GEO_PREFIXES,
bedrock_model_routable_from_region,
bedrock_region_geo_prefix,
)
class TestRegionGeoPrefix:
def test_known_geographies(self):
assert bedrock_region_geo_prefix("us-east-1") == "us."
assert bedrock_region_geo_prefix("eu-central-2") == "eu."
assert bedrock_region_geo_prefix("ap-southeast-1") == "ap."
assert bedrock_region_geo_prefix("ca-central-1") == "ca."
assert bedrock_region_geo_prefix("sa-east-1") == "sa."
assert bedrock_region_geo_prefix("me-south-1") == "me."
assert bedrock_region_geo_prefix("af-south-1") == "af."
def test_unknown_region_is_empty(self):
assert bedrock_region_geo_prefix("") == ""
assert bedrock_region_geo_prefix("moon-base-1") == ""
class TestRoutableFromRegion:
def test_us_profile_not_offered_in_eu(self):
assert not bedrock_model_routable_from_region(
"us.anthropic.claude-sonnet-4-6", "eu-central-2"
)
def test_eu_profile_offered_in_eu(self):
assert bedrock_model_routable_from_region(
"eu.anthropic.claude-sonnet-4-6", "eu-central-2"
)
def test_global_profile_offered_everywhere(self):
for region in ("eu-central-2", "us-east-1", "ap-southeast-1"):
assert bedrock_model_routable_from_region(
"global.anthropic.claude-sonnet-4-6", region
)
def test_bare_foundation_id_offered_everywhere(self):
assert bedrock_model_routable_from_region(
"anthropic.claude-3-sonnet-20240229-v1:0", "eu-central-2"
)
def test_apac_spellings_route_in_ap_regions(self):
for prefix in ("ap.", "apac.", "jp."):
assert bedrock_model_routable_from_region(
f"{prefix}anthropic.claude-sonnet-4-6", "ap-northeast-1"
)
def test_eu_profile_not_offered_in_us(self):
assert not bedrock_model_routable_from_region(
"eu.anthropic.claude-sonnet-4-6", "us-east-1"
)
def test_unknown_region_hides_nothing(self):
assert bedrock_model_routable_from_region(
"us.anthropic.claude-sonnet-4-6", ""
)
class TestGeoPrefixContract:
def test_every_geo_prefix_maps_to_a_routable_region_or_is_alias(self):
"""Invariant: each geo prefix is either reachable from some region's
geo mapping or an ap-family alias no dead entries."""
mapped = {
bedrock_region_geo_prefix(r)
for r in (
"us-east-1", "eu-central-2", "ap-southeast-1",
"ca-central-1", "sa-east-1", "me-south-1", "af-south-1",
)
}
ap_aliases = {"apac.", "jp."}
for prefix in BEDROCK_GEO_PREFIXES:
assert prefix in mapped or prefix in ap_aliases

View file

@ -1645,6 +1645,114 @@ class TestMigrationWriteInvariant:
assert loaded["display"]["compact"] == DEFAULT_CONFIG["display"]["compact"]
class TestSaveConfigPartialWritePreservation:
"""Regression for #62723: partial migration writes must not drop unrelated sections."""
def test_merge_existing_preserves_platforms_on_partial_write(self, tmp_path):
body = """_config_version: 30
model:
default: deepseek-v4-pro
provider: deepseek
agent:
max_turns: 60
platforms:
feishu:
enabled: true
extra:
app_id: cli_xxx
app_secret: xxx
feishu:
require_mention: true
"""
(tmp_path / "config.yaml").write_text(body, encoding="utf-8")
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
save_config(
{
"_config_version": 30,
"model": {"default": "deepseek-v4-pro", "provider": "deepseek"},
"agent": {"max_turns": 60, "verify_on_stop": False},
},
merge_existing=True,
)
raw = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8"))
assert raw["platforms"]["feishu"]["extra"]["app_id"] == "cli_xxx"
assert raw["feishu"]["require_mention"] is True
assert raw["agent"]["verify_on_stop"] is False
def test_partial_write_without_merge_drops_omitted_sections(self, tmp_path):
"""Full-replacement callers (raw YAML editor) rely on merge_existing=False."""
body = """_config_version: 30
model:
default: deepseek-v4-pro
provider: deepseek
platforms:
feishu:
enabled: true
"""
(tmp_path / "config.yaml").write_text(body, encoding="utf-8")
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
save_config({"model": {"default": "other-model", "provider": "openrouter"}})
raw = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8"))
assert raw["model"]["default"] == "other-model"
assert "platforms" not in raw
def test_persist_migration_writes_full_read_raw_config(self, tmp_path):
from hermes_cli.config import _persist_migration, read_raw_config
body = """_config_version: 30
model:
default: deepseek-v4-pro
provider: deepseek
agent:
max_turns: 60
platforms:
feishu:
enabled: true
extra:
app_id: cli_xxx
app_secret: xxx
"""
(tmp_path / "config.yaml").write_text(body, encoding="utf-8")
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
config = read_raw_config()
config.setdefault("agent", {})["verify_on_stop"] = False
config["_config_version"] = 32
_persist_migration(config)
raw = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8"))
assert raw["platforms"]["feishu"]["extra"]["app_id"] == "cli_xxx"
assert raw["agent"]["verify_on_stop"] is False
assert raw["agent"]["max_turns"] == 60
assert raw["_config_version"] == 32
def test_v30_to_latest_migration_keeps_platforms(self, tmp_path):
"""End-to-end: reporter's v30 feishu profile survives version bump."""
body = """_config_version: 30
model:
default: deepseek-v4-pro
provider: deepseek
agent:
max_turns: 60
platforms:
feishu:
enabled: true
extra:
app_id: cli_xxx
app_secret: xxx
feishu:
require_mention: true
"""
(tmp_path / "config.yaml").write_text(body, encoding="utf-8")
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
migrate_config(interactive=False, quiet=True)
raw = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8"))
assert raw["platforms"]["feishu"]["extra"]["app_id"] == "cli_xxx"
assert raw["feishu"]["require_mention"] is True
class TestVerifyOnStopMigration:
"""v30 → v31: switch verify_on_stop OFF once, preserving explicit choices."""

View file

@ -166,8 +166,8 @@ class TestJudgeGoal:
from hermes_cli import goals
with patch(
"agent.auxiliary_client.get_text_auxiliary_client",
return_value=(None, None),
"agent.auxiliary_client.call_llm",
side_effect=RuntimeError("No LLM provider configured"),
):
verdict, _, _, _wd = goals.judge_goal("my goal", "my response")
assert verdict == "continue"
@ -176,11 +176,9 @@ class TestJudgeGoal:
"""Judge exception → fail-open continue (don't wedge progress on judge bugs)."""
from hermes_cli import goals
fake_client = MagicMock()
fake_client.chat.completions.create.side_effect = RuntimeError("boom")
with patch(
"agent.auxiliary_client.get_text_auxiliary_client",
return_value=(fake_client, "judge-model"),
"agent.auxiliary_client.call_llm",
side_effect=RuntimeError("boom"),
):
verdict, reason, _, _wd = goals.judge_goal("goal", "response")
assert verdict == "continue"
@ -189,17 +187,11 @@ class TestJudgeGoal:
def test_judge_says_done(self):
from hermes_cli import goals
fake_client = MagicMock()
fake_client.chat.completions.create.return_value = MagicMock(
choices=[
MagicMock(
message=MagicMock(content='{"done": true, "reason": "achieved"}')
)
]
)
with patch(
"agent.auxiliary_client.get_text_auxiliary_client",
return_value=(fake_client, "judge-model"),
"agent.auxiliary_client.call_llm",
return_value=MagicMock(
choices=[MagicMock(message=MagicMock(content='{"done": true, "reason": "achieved"}'))]
),
):
verdict, reason, _, _wd = goals.judge_goal("goal", "agent response")
assert verdict == "done"
@ -208,17 +200,11 @@ class TestJudgeGoal:
def test_judge_says_continue(self):
from hermes_cli import goals
fake_client = MagicMock()
fake_client.chat.completions.create.return_value = MagicMock(
choices=[
MagicMock(
message=MagicMock(content='{"done": false, "reason": "not yet"}')
)
]
)
with patch(
"agent.auxiliary_client.get_text_auxiliary_client",
return_value=(fake_client, "judge-model"),
"agent.auxiliary_client.call_llm",
return_value=MagicMock(
choices=[MagicMock(message=MagicMock(content='{"done": false, "reason": "not yet"}'))]
),
):
verdict, reason, _, _wd = goals.judge_goal("goal", "agent response")
assert verdict == "continue"
@ -448,11 +434,9 @@ class TestJudgeParseFailureAutoPause:
"""Transient network/API errors must not trip the auto-pause guard."""
from hermes_cli import goals
fake_client = MagicMock()
fake_client.chat.completions.create.side_effect = RuntimeError("connection reset")
with patch(
"agent.auxiliary_client.get_text_auxiliary_client",
return_value=(fake_client, "judge-model"),
"agent.auxiliary_client.call_llm",
side_effect=RuntimeError("connection reset"),
):
verdict, _, parse_failed, _wd = goals.judge_goal("goal", "response")
assert verdict == "continue"
@ -462,13 +446,9 @@ class TestJudgeParseFailureAutoPause:
"""End-to-end: judge returns empty content → parse_failed=True."""
from hermes_cli import goals
fake_client = MagicMock()
fake_client.chat.completions.create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content=""))]
)
with patch(
"agent.auxiliary_client.get_text_auxiliary_client",
return_value=(fake_client, "judge-model"),
"agent.auxiliary_client.call_llm",
return_value=MagicMock(choices=[MagicMock(message=MagicMock(content=""))]),
):
verdict, _, parse_failed, _wd = goals.judge_goal("goal", "response")
assert verdict == "continue"
@ -747,22 +727,11 @@ class TestJudgeGoalWithSubgoals:
message = _FakeMsg()
class _FakeResp:
choices = [_FakeChoice()]
class _FakeClient:
class chat:
class completions:
@staticmethod
def create(**kwargs):
captured.update(kwargs)
return _FakeResp()
def _fake_call_llm(**kwargs):
captured.update(kwargs)
return _FakeResp()
with patch.object(goals, "get_text_auxiliary_client",
return_value=(_FakeClient, "fake-model"), create=True), \
patch.object(goals, "get_auxiliary_extra_body",
return_value=None, create=True), \
patch("agent.auxiliary_client.get_text_auxiliary_client",
return_value=(_FakeClient, "fake-model")), \
patch("agent.auxiliary_client.get_auxiliary_extra_body",
return_value=None):
with patch("agent.auxiliary_client.call_llm", side_effect=_fake_call_llm):
verdict, reason, parse_failed, _wd = goals.judge_goal(
"ship the feature",
"ok shipped",
@ -790,18 +759,11 @@ class TestJudgeGoalWithSubgoals:
message = _FakeMsg()
class _FakeResp:
choices = [_FakeChoice()]
class _FakeClient:
class chat:
class completions:
@staticmethod
def create(**kwargs):
captured.update(kwargs)
return _FakeResp()
def _fake_call_llm(**kwargs):
captured.update(kwargs)
return _FakeResp()
with patch("agent.auxiliary_client.get_text_auxiliary_client",
return_value=(_FakeClient, "fake-model")), \
patch("agent.auxiliary_client.get_auxiliary_extra_body",
return_value=None):
with patch("agent.auxiliary_client.call_llm", side_effect=_fake_call_llm):
goals.judge_goal("ship it", "done", subgoals=None)
sent_messages = captured.get("messages") or []
@ -1363,7 +1325,8 @@ class TestGoalManagerContract:
class TestJudgeWithContract:
def _fake_client(self, captured, content='{"done": false, "reason": "more"}'):
def _fake_call_llm(self, captured, content='{"done": false, "reason": "more"}'):
"""judge_goal routes through call_llm (#35566) — capture its kwargs."""
class _FakeMsg:
pass
_FakeMsg.content = content
@ -1371,14 +1334,11 @@ class TestJudgeWithContract:
message = _FakeMsg()
class _FakeResp:
choices = [_FakeChoice()]
class _FakeClient:
class chat:
class completions:
@staticmethod
def create(**kwargs):
captured.update(kwargs)
return _FakeResp()
return _FakeClient
def _fake(**kwargs):
captured.update(kwargs)
return _FakeResp()
return _fake
def test_judge_uses_contract_template(self, hermes_home):
from unittest.mock import patch
@ -1386,10 +1346,8 @@ class TestJudgeWithContract:
from hermes_cli.goals import GoalContract
captured = {}
client = self._fake_client(captured)
with patch("agent.auxiliary_client.get_text_auxiliary_client",
return_value=(client, "fake-model")), \
patch("agent.auxiliary_client.get_auxiliary_extra_body", return_value=None):
with patch("agent.auxiliary_client.call_llm",
side_effect=self._fake_call_llm(captured)):
goals.judge_goal(
"ship it", "I think it's done",
contract=GoalContract(verification="pytest -q passes"),
@ -1407,10 +1365,8 @@ class TestJudgeWithContract:
from hermes_cli.goals import GoalContract
captured = {}
client = self._fake_client(captured)
with patch("agent.auxiliary_client.get_text_auxiliary_client",
return_value=(client, "fake-model")), \
patch("agent.auxiliary_client.get_auxiliary_extra_body", return_value=None):
with patch("agent.auxiliary_client.call_llm",
side_effect=self._fake_call_llm(captured)):
goals.judge_goal(
"ship it", "done",
subgoals=["write changelog"],
@ -1438,16 +1394,8 @@ class TestDraftContract:
message = _FakeMsg()
class _FakeResp:
choices = [_FakeChoice()]
class _FakeClient:
class chat:
class completions:
@staticmethod
def create(**kwargs):
return _FakeResp()
with patch("agent.auxiliary_client.get_text_auxiliary_client",
return_value=(_FakeClient, "fake-model")), \
patch("agent.auxiliary_client.get_auxiliary_extra_body", return_value=None):
with patch("agent.auxiliary_client.call_llm",
return_value=_FakeResp()):
contract = goals.draft_contract("Migrate auth to JWT")
assert contract is not None
assert contract.outcome == "auth on JWT"
@ -1464,24 +1412,16 @@ class TestDraftContract:
message = _FakeMsg()
class _FakeResp:
choices = [_FakeChoice()]
class _FakeClient:
class chat:
class completions:
@staticmethod
def create(**kwargs):
return _FakeResp()
with patch("agent.auxiliary_client.get_text_auxiliary_client",
return_value=(_FakeClient, "fake-model")), \
patch("agent.auxiliary_client.get_auxiliary_extra_body", return_value=None):
with patch("agent.auxiliary_client.call_llm",
return_value=_FakeResp()):
assert goals.draft_contract("anything") is None
def test_draft_returns_none_when_no_client(self, hermes_home):
from unittest.mock import patch
from hermes_cli import goals
with patch("agent.auxiliary_client.get_text_auxiliary_client",
return_value=(None, None)):
with patch("agent.auxiliary_client.call_llm",
side_effect=RuntimeError("No LLM provider configured")):
assert goals.draft_contract("anything") is None
@ -1495,7 +1435,8 @@ class TestContractAndBackgroundCompose:
the contract block and the background-process list to the judge, so it
can return either done (evidence met) or wait (parked on the poller)."""
def _capture_client(self, captured, content='{"verdict": "wait", "wait_on_pid": 4242, "reason": "CI still running"}'):
def _capture_call_llm(self, captured, content='{"verdict": "wait", "wait_on_pid": 4242, "reason": "CI still running"}'):
"""judge_goal routes through call_llm (#35566) — capture its kwargs."""
class _FakeMsg:
pass
_FakeMsg.content = content
@ -1503,14 +1444,11 @@ class TestContractAndBackgroundCompose:
message = _FakeMsg()
class _FakeResp:
choices = [_FakeChoice()]
class _FakeClient:
class chat:
class completions:
@staticmethod
def create(**kwargs):
captured.update(kwargs)
return _FakeResp()
return _FakeClient
def _fake(**kwargs):
captured.update(kwargs)
return _FakeResp()
return _fake
def test_judge_prompt_carries_contract_and_background(self, hermes_home):
from unittest.mock import patch
@ -1518,14 +1456,12 @@ class TestContractAndBackgroundCompose:
from hermes_cli.goals import GoalContract
captured = {}
client = self._capture_client(captured)
bg = [{
"session_id": "ci-watch", "pid": 4242, "status": "running",
"command": "wait_for_pr_green.sh 50501", "trigger": "exit",
}]
with patch("agent.auxiliary_client.get_text_auxiliary_client",
return_value=(client, "fake-model")), \
patch("agent.auxiliary_client.get_auxiliary_extra_body", return_value=None):
with patch("agent.auxiliary_client.call_llm",
side_effect=self._capture_call_llm(captured)):
verdict, reason, parse_failed, wait_directive = goals.judge_goal(
"ship the PR",
"I pushed and started the CI watcher; waiting on it now.",
@ -1550,14 +1486,12 @@ class TestContractAndBackgroundCompose:
from hermes_cli.goals import GoalContract
captured = {}
client = self._capture_client(
captured,
content='{"verdict": "done", "reason": "CI is green, evidence shown"}',
)
bg = [{"session_id": "ci", "pid": 4242, "status": "running", "command": "ci", "trigger": "exit"}]
with patch("agent.auxiliary_client.get_text_auxiliary_client",
return_value=(client, "fake-model")), \
patch("agent.auxiliary_client.get_auxiliary_extra_body", return_value=None):
with patch("agent.auxiliary_client.call_llm",
side_effect=self._capture_call_llm(
captured,
content='{"verdict": "done", "reason": "CI is green, evidence shown"}',
)):
verdict, reason, parse_failed, wait_directive = goals.judge_goal(
"ship the PR",
"CI finished: 30 passed, 0 failed. Done.",

View file

@ -0,0 +1,51 @@
"""Tests for shared user prompt input sanitization."""
from hermes_cli.input_sanitize import (
collapse_repeated_input_artifacts,
sanitize_user_prompt_text,
strip_leaked_bracketed_paste_wrappers,
)
class TestStripLeakedBracketedPasteWrappers:
def test_plain_text_unchanged(self):
assert strip_leaked_bracketed_paste_wrappers("hello world") == "hello world"
def test_strips_canonical_escape_wrappers(self):
assert strip_leaked_bracketed_paste_wrappers("\x1b[200~hello\x1b[201~") == "hello"
def test_strips_visible_caret_escape_wrappers(self):
assert strip_leaked_bracketed_paste_wrappers("^[[200~hello^[[201~") == "hello"
def test_does_not_strip_non_wrapper_bracket_forms_in_normal_text(self):
text = "literal[200~tag and literal[201~tag should stay"
assert strip_leaked_bracketed_paste_wrappers(text) == text
class TestCollapseRepeatedInputArtifacts:
def test_issue_62557_corruption_tail(self):
prefix = "需要时随时叫我。"
tail = "[e~[[e" + "~[[e" * 20
assert collapse_repeated_input_artifacts(prefix + tail) == prefix
def test_plain_text_unchanged(self):
text = "build00~tag should stay"
assert collapse_repeated_input_artifacts(text) == text
def test_mid_string_marker_followed_by_suffix_preserved(self):
text = "notes ~[[e more text here"
assert collapse_repeated_input_artifacts(text) == text
def test_trailing_punctuation_preserved(self):
assert collapse_repeated_input_artifacts("wait....") == "wait...."
def test_insufficient_tail_repeats_preserved(self):
text = "hello~[[e~[[e"
assert collapse_repeated_input_artifacts(text) == text
class TestSanitizeUserPromptText:
def test_combines_wrapper_strip_and_tail_collapse(self):
prefix = "hello["
corrupted = prefix + "~[[e" * 8
assert sanitize_user_prompt_text(corrupted) == "hello"

View file

@ -465,11 +465,10 @@ def test_run_slash_specify_end_to_end(kanban_home, monkeypatch):
resp.choices[0].message.content = (
'{"title": "Spec: rough idea", "body": "**Goal**\\nShip it."}'
)
fake_client = MagicMock()
fake_client.chat.completions.create = MagicMock(return_value=resp)
# specify_task routes through call_llm now (#35566) — mock it directly.
monkeypatch.setattr(
"agent.auxiliary_client.get_text_auxiliary_client",
lambda *a, **kw: (fake_client, "test-model"),
"agent.auxiliary_client.call_llm",
MagicMock(return_value=resp),
)
# Specify via slash.

View file

@ -41,18 +41,19 @@ def _mock_client_returning(content: str):
def _patch_aux_client(content: str, *, model: str = "test-model"):
client = _mock_client_returning(content)
# decompose_task now routes through call_llm (see #35566) — mock it at
# the source module so task config, extra_body, and retries stay out of
# unit-test scope.
return patch(
"agent.auxiliary_client.get_text_auxiliary_client",
return_value=(client, model),
"agent.auxiliary_client.call_llm",
return_value=_fake_aux_response(content),
)
def _patch_extra_body():
return patch(
"agent.auxiliary_client.get_auxiliary_extra_body",
return_value={},
)
# No-op shim retained for call-site compatibility: extra_body plumbing
# now lives inside call_llm, which _patch_aux_client already mocks.
return patch("agent.auxiliary_client.get_auxiliary_extra_body", return_value={})
def _patch_list_profiles(names: list[str]):
@ -334,9 +335,11 @@ def test_decompose_no_aux_client_configured(kanban_home):
for p in patches:
p.start()
try:
# call_llm raises RuntimeError when no provider is configured; the
# decomposer must convert that into a failed outcome, not a crash.
with patch(
"agent.auxiliary_client.get_text_auxiliary_client",
return_value=(None, ""),
"agent.auxiliary_client.call_llm",
side_effect=RuntimeError("No LLM provider configured"),
):
outcome = decomp.decompose_task(tid, author="me")
finally:
@ -344,4 +347,5 @@ def test_decompose_no_aux_client_configured(kanban_home):
p.stop()
assert outcome.ok is False
assert "no auxiliary client" in outcome.reason
# call_llm's no-provider RuntimeError surfaces via the LLM-error branch.
assert "LLM error" in outcome.reason

View file

@ -48,15 +48,12 @@ def _mock_client_returning(content: str):
def _patch_aux_client(content: str, *, model: str = "test-model"):
"""Patch get_text_auxiliary_client at its source + at the module that
imported it lazily inside specify_task. Both patches are needed
because kanban_specify imports the function inside the function body.
"""Patch call_llm at its source module — specify_task now routes through
it (#35566) instead of building a raw client. Returns (patcher, mock) so
callers can still assert on the call.
"""
client = _mock_client_returning(content)
return patch(
"agent.auxiliary_client.get_text_auxiliary_client",
return_value=(client, model),
), client
mock_fn = MagicMock(return_value=_fake_aux_response(content))
return patch("agent.auxiliary_client.call_llm", mock_fn), mock_fn
# ---------------------------------------------------------------------------
@ -159,13 +156,14 @@ def test_specify_task_no_aux_client_configured(kanban_home):
tid = kb.create_task(conn, title="rough", triage=True)
with patch(
"agent.auxiliary_client.get_text_auxiliary_client",
return_value=(None, ""),
"agent.auxiliary_client.call_llm",
side_effect=RuntimeError("No LLM provider configured"),
):
outcome = spec.specify_task(tid)
assert outcome.ok is False
assert "auxiliary client" in outcome.reason
# call_llm's no-provider RuntimeError surfaces via the LLM-error branch.
assert "LLM error" in outcome.reason
# Task must stay in triage — we never touched it.
with kb.connect() as conn:
assert kb.get_task(conn, tid).status == "triage"
@ -176,10 +174,9 @@ def test_specify_task_llm_api_error_keeps_task_in_triage(kanban_home):
tid = kb.create_task(conn, title="rough", triage=True)
client = MagicMock()
client.chat.completions.create = MagicMock(side_effect=RuntimeError("429 rate limited"))
with patch(
"agent.auxiliary_client.get_text_auxiliary_client",
return_value=(client, "test-model"),
"agent.auxiliary_client.call_llm",
side_effect=RuntimeError("429 rate limited"),
):
outcome = spec.specify_task(tid)
@ -288,8 +285,8 @@ def test_cli_specify_all_returns_1_when_every_task_fails(kanban_home, capsys):
kb.create_task(conn, title="b", triage=True)
with patch(
"agent.auxiliary_client.get_text_auxiliary_client",
return_value=(None, ""), # no aux client → every task fails
"agent.auxiliary_client.call_llm",
side_effect=RuntimeError("No LLM provider configured"), # every task fails
):
rc = _run_cli("specify", "--all")

View file

@ -7,6 +7,7 @@ launch an MCP is mocked.
from __future__ import annotations
import re
from pathlib import Path
from unittest.mock import patch
@ -197,6 +198,32 @@ class TestManifestParsing:
assert get_entry("official/demo") is not None
assert get_entry("missing") is None
def test_transport_env_parsed_and_written_to_server_config(self, catalog_dir):
body = _basic_manifest()
body["transport"]["env"] = {"DISABLE_TELEMETRY": "true"}
_write_manifest(catalog_dir, "demo", body)
from hermes_cli.mcp_catalog import _build_server_config
e = _entry("demo")
assert e.transport.env == {"DISABLE_TELEMETRY": "true"}
cfg = _build_server_config(e, None)
assert cfg["env"] == {"DISABLE_TELEMETRY": "true"}
def test_transport_env_absent_leaves_config_without_env_key(self, catalog_dir):
_write_manifest(catalog_dir, "demo", _basic_manifest())
from hermes_cli.mcp_catalog import _build_server_config
cfg = _build_server_config(_entry("demo"), None)
assert "env" not in cfg
def test_transport_env_bad_shape_rejected(self, catalog_dir):
body = _basic_manifest()
body["transport"]["env"] = ["DISABLE_TELEMETRY=true"] # list, not mapping
_write_manifest(catalog_dir, "demo", body)
from hermes_cli.mcp_catalog import list_catalog
assert list_catalog() == []
# ---------------------------------------------------------------------------
# Install flow
@ -812,3 +839,59 @@ class TestShippedCatalog:
assert entry.name
assert entry.description
assert entry.transport.type in ("stdio", "http")
def test_all_shipped_manifests_are_version_locked(self, monkeypatch):
"""Contract: catalog entries follow the same supply-chain rules as
pyproject dependencies everything Hermes fetches/launches is pinned
to an exact version.
- git installs must pin a full 40-char commit SHA (branches and tags
can be moved by the upstream owner; SHAs cannot).
- package-launcher stdio transports (uvx/npx and their pkg-manager
equivalents) must carry an exact version specifier on the package
arg (``pkg==X`` for Python, ``pkg@X`` for npm).
http transports and ${INSTALL_DIR}-anchored commands have nothing to
pin at the transport layer (the server runs elsewhere / comes from the
SHA-pinned clone), so they're exempt.
"""
monkeypatch.delenv("HERMES_OPTIONAL_MCPS", raising=False)
from hermes_cli.mcp_catalog import _catalog_root, _parse_manifest
root = _catalog_root()
if not root.exists():
pytest.skip("optional-mcps/ not present in this checkout")
launcher_commands = {"uvx", "npx", "pipx", "bunx", "pnpx"}
problems = []
for m in root.glob("*/manifest.yaml"):
entry = _parse_manifest(m)
if entry.install is not None:
if not re.fullmatch(r"[0-9a-f]{40}", entry.install.ref):
problems.append(
f"{entry.name}: install.ref {entry.install.ref!r} is not "
"a full 40-char commit SHA"
)
t = entry.transport
if t.type == "stdio" and (t.command or "") in launcher_commands:
pkg_args = [a for a in t.args if not a.startswith("-")]
if not pkg_args:
problems.append(f"{entry.name}: launcher {t.command} has no package arg")
continue
pkg = pkg_args[0]
# Exact-pin shapes: pkg==1.2.3 (uvx/pipx) or pkg@1.2.3 /
# @scope/pkg@1.2.3 (npx/bunx/pnpx). The version must start
# with a digit — a bare name, a range operator, or an npm
# dist-tag (@latest, @next) floats and is rejected.
exact = re.fullmatch(r"[^=@\s]+==\d[\w.\-+]*", pkg) or re.fullmatch(
r"(@[\w.\-]+/)?[\w.\-]+@\d[\w.\-+]*", pkg
)
if not exact:
problems.append(
f"{entry.name}: package arg {pkg!r} is not pinned to an "
"exact version (expected pkg==X or pkg@X)"
)
assert not problems, "unpinned catalog entries:\n" + "\n".join(problems)

View file

@ -304,3 +304,115 @@ def test_reference_max_tokens_in_flattened_view():
active preset's reference_max_tokens."""
cfg = normalize_moa_config(_preset(reference_max_tokens=750))
assert cfg["reference_max_tokens"] == 750
# ── validate_moa_payload (write-boundary validation, #64156) ─────────────────
#
# normalize_moa_config is deliberately tolerant at READ time (hand-edited
# configs degrade to defaults). validate_moa_payload is the strict WRITE-time
# counterpart: it must flag exactly the payloads normalize would silently
# repair, so API save paths reject them instead of corrupting user config.
def _valid_preset_payload():
return {
"reference_models": [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}],
"aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"},
}
def test_validate_moa_payload_accepts_complete_presets():
from hermes_cli.moa_config import validate_moa_payload
assert validate_moa_payload({"presets": {"default": _valid_preset_payload()}}) == []
def test_validate_moa_payload_accepts_legacy_flat_payload():
from hermes_cli.moa_config import validate_moa_payload
assert validate_moa_payload(_valid_preset_payload()) == []
def test_validate_moa_payload_flags_half_filled_reference_slot():
"""The #64156 shape: provider picked, model still empty (mid-edit autosave)."""
from hermes_cli.moa_config import validate_moa_payload
preset = _valid_preset_payload()
preset["reference_models"].append({"provider": "kilo", "model": ""})
problems = validate_moa_payload({"presets": {"default": preset}})
assert problems
assert any("reference 2" in p and "model is required" in p for p in problems)
def test_validate_moa_payload_flags_half_filled_aggregator():
from hermes_cli.moa_config import validate_moa_payload
preset = _valid_preset_payload()
preset["aggregator"] = {"provider": "openrouter", "model": ""}
problems = validate_moa_payload({"presets": {"default": preset}})
assert any("aggregator" in p and "model is required" in p for p in problems)
def test_validate_moa_payload_flags_empty_references():
from hermes_cli.moa_config import validate_moa_payload
preset = _valid_preset_payload()
preset["reference_models"] = []
problems = validate_moa_payload({"presets": {"default": preset}})
assert any("at least one complete reference model" in p for p in problems)
def test_validate_moa_payload_flags_recursive_moa_slot():
from hermes_cli.moa_config import validate_moa_payload
preset = _valid_preset_payload()
preset["aggregator"] = {"provider": "MoA", "model": "default"}
problems = validate_moa_payload({"presets": {"default": preset}})
assert any("recursive MoA" in p for p in problems)
def test_validate_moa_payload_names_the_broken_preset():
"""Multi-preset payloads must say WHICH preset is broken."""
from hermes_cli.moa_config import validate_moa_payload
problems = validate_moa_payload(
{
"presets": {
"good": _valid_preset_payload(),
"broken": {
"reference_models": [{"provider": "", "model": ""}],
"aggregator": {"provider": "a", "model": "b"},
},
}
}
)
assert problems
assert all("'broken'" in p for p in problems)
assert not any("'good'" in p for p in problems)
def test_validate_moa_payload_agrees_with_clean_slot():
"""Contract: a payload validate accepts must survive normalize UNCHANGED in
its slots validate and _clean_slot can never disagree (else a payload
could pass validation and still be swapped for defaults)."""
from hermes_cli.moa_config import validate_moa_payload
payload = {"presets": {"p": _valid_preset_payload()}}
assert validate_moa_payload(payload) == []
cfg = normalize_moa_config(payload)
assert cfg["presets"]["p"]["reference_models"] == payload["presets"]["p"]["reference_models"]
assert cfg["presets"]["p"]["aggregator"] == payload["presets"]["p"]["aggregator"]
def test_validate_moa_payload_rejects_non_dict():
from hermes_cli.moa_config import validate_moa_payload
assert validate_moa_payload(None)
assert validate_moa_payload([1, 2])
assert validate_moa_payload({"presets": {"p": "not-a-dict"}})

View file

@ -79,11 +79,11 @@ def _fake_aux_response(content: str):
def _patch_aux_client(content: str):
client = MagicMock()
client.chat.completions.create = MagicMock(return_value=_fake_aux_response(content))
# describe_profile now routes through call_llm (#35566) — mock it at the
# source module.
return patch(
"agent.auxiliary_client.get_text_auxiliary_client",
return_value=(client, "test-model"),
"agent.auxiliary_client.call_llm",
return_value=_fake_aux_response(content),
)

View file

@ -861,6 +861,72 @@ class TestWebServerEndpoints:
assert cfg["moa"]["reference_models"] == payload["reference_models"]
assert cfg["moa"]["aggregator"] == payload["aggregator"]
def test_put_moa_models_rejects_half_filled_slot_with_422(self):
"""#64156: a mid-edit autosave (provider picked, model empty) used to be
silently normalized into the hardcoded default preset the user's
config was replaced without any error. The write path must reject it."""
from hermes_cli.config import load_config
original = load_config().get("moa")
payload = {
"presets": {
"default": {
"reference_models": [{"provider": "kilo", "model": ""}],
"aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"},
}
}
}
resp = self.client.put("/api/model/moa", json=payload)
assert resp.status_code == 422
assert "model is required" in resp.json()["detail"]
# Config untouched — not swapped for defaults.
assert load_config().get("moa") == original
def test_put_moa_models_rejects_half_filled_aggregator_with_422(self):
payload = {
"presets": {
"default": {
"reference_models": [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}],
"aggregator": {"provider": "openrouter", "model": ""},
}
}
}
resp = self.client.put("/api/model/moa", json=payload)
assert resp.status_code == 422
assert "aggregator" in resp.json()["detail"]
def test_put_moa_models_round_trips_fanout_and_reference_max_tokens(self):
"""GET → PUT round-trip must not erase newer per-preset knobs. The old
Pydantic payload didn't declare fanout / reference_max_tokens, so any
client save silently wiped hand-set values back to defaults."""
from hermes_cli.config import load_config
payload = {
"presets": {
"default": {
"reference_models": [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}],
"aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"},
"fanout": "user_turn",
"reference_max_tokens": 600,
}
}
}
resp = self.client.put("/api/model/moa", json=payload)
assert resp.status_code == 200
saved = load_config()["moa"]["presets"]["default"]
assert saved["fanout"] == "user_turn"
assert saved["reference_max_tokens"] == 600
# And the GET view carries them back to the client.
fetched = self.client.get("/api/model/moa").json()
assert fetched["presets"]["default"]["fanout"] == "user_turn"
assert fetched["presets"]["default"]["reference_max_tokens"] == 600
# ── GET /api/media (remote image display) ───────────────────────────
def test_get_media_serves_image_in_root(self):

View file

@ -49,20 +49,26 @@ class TestCustomReasoningWireShape:
assert tl == {}
def test_disabled_sends_think_false(self, custom_profile):
"""enabled=False → extra_body.think = False (Ollama thinking-off flag)."""
"""enabled=False → reasoning_effort='none' top-level + think=False.
Both fields are required: Ollama's /v1/chat/completions silently
ignores extra_body.think (only /api/chat honours it ollama#14820)
but respects top-level reasoning_effort (#25758). think=False stays
for proxies and the native /api/chat path.
"""
eb, tl = custom_profile.build_api_kwargs_extras(
reasoning_config={"enabled": False}, model="glm-5.2"
)
assert eb == {"think": False}
assert tl == {}
assert tl == {"reasoning_effort": "none"}
def test_effort_none_sends_think_false(self, custom_profile):
"""effort='none' is the disable alias → think=False, no effort."""
"""effort='none' is the disable alias → same dual emission."""
eb, tl = custom_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "none"}, model="glm-5.2"
)
assert eb == {"think": False}
assert tl == {}
assert tl == {"reasoning_effort": "none"}
@pytest.mark.parametrize(
"effort", ["minimal", "low", "medium", "high", "xhigh", "max"]

View file

@ -2182,13 +2182,10 @@ def _patch_specifier_response(monkeypatch, *, content, model="test-model"):
resp = MagicMock()
resp.choices = [MagicMock()]
resp.choices[0].message.content = content
fake_client = MagicMock()
fake_client.chat.completions.create = MagicMock(return_value=resp)
monkeypatch.setattr(
"agent.auxiliary_client.get_text_auxiliary_client",
lambda *a, **kw: (fake_client, model),
)
return fake_client
# specify_task routes through call_llm now (#35566) — mock it directly.
fake_call = MagicMock(return_value=resp)
monkeypatch.setattr("agent.auxiliary_client.call_llm", fake_call)
return fake_call
def test_specify_happy_path(client, monkeypatch):
@ -2250,11 +2247,11 @@ def test_specify_no_aux_client_surfaces_reason(client, monkeypatch):
json={"title": "rough", "triage": True},
).json()["task"]
# Simulate "no auxiliary client configured".
monkeypatch.setattr(
"agent.auxiliary_client.get_text_auxiliary_client",
lambda *a, **kw: (None, ""),
)
# Simulate "no auxiliary client configured" — call_llm raises when
# no provider resolves (#35566 routing).
def _no_provider(**kwargs):
raise RuntimeError("No LLM provider configured")
monkeypatch.setattr("agent.auxiliary_client.call_llm", _no_provider)
r = client.post(
f"/api/plugins/kanban/tasks/{t['id']}/specify",
@ -2263,7 +2260,8 @@ def test_specify_no_aux_client_surfaces_reason(client, monkeypatch):
assert r.status_code == 200
body = r.json()
assert body["ok"] is False
assert "auxiliary client" in body["reason"]
# call_llm's no-provider RuntimeError surfaces via the LLM-error branch.
assert "LLM error" in body["reason"]
# Task must stay in triage — nothing was touched.
detail = client.get(f"/api/plugins/kanban/tasks/{t['id']}").json()["task"]

View file

@ -4149,6 +4149,66 @@ class TestRunConversation:
assert result["final_response"] == "Final answer"
assert result["completed"] is True
def test_codex_content_filter_incomplete_routes_to_policy_fallback(self, agent):
self._setup_agent(agent)
agent.api_mode = "codex_responses"
agent.provider = "openai-codex"
agent.base_url = "https://chatgpt.com/backend-api/codex"
agent._base_url_lower = agent.base_url.lower()
agent._base_url_hostname = "chatgpt.com"
agent.model = "gpt-5.5"
agent._fallback_chain = [
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.7"},
]
agent._fallback_index = 0
content_filter_response = SimpleNamespace(
status="incomplete",
incomplete_details=SimpleNamespace(reason="content_filter"),
output=[],
output_text="",
model="gpt-5.5",
usage=None,
)
fallback_response = SimpleNamespace(
status="completed",
incomplete_details=None,
output=[
SimpleNamespace(
type="message",
status="completed",
content=[SimpleNamespace(type="output_text", text="Recovered on fallback")],
)
],
model="fallback/model",
usage=None,
)
hook_events = []
def _fake_activate(reason=None):
agent._fallback_index = len(agent._fallback_chain)
return True
with (
patch.object(agent, "_create_request_openai_client", return_value=MagicMock()),
patch.object(agent, "_close_request_openai_client"),
patch.object(agent, "_run_codex_stream", side_effect=[content_filter_response, fallback_response]) as mock_run_codex_stream,
patch.object(agent, "_try_activate_fallback", side_effect=_fake_activate) as mock_try_activate_fallback,
patch.object(agent, "_invoke_api_request_error_hook", side_effect=lambda **kw: hook_events.append(kw)),
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
):
result = agent.run_conversation("summarize this large Slack thread")
assert result["final_response"] == "Recovered on fallback"
assert result["completed"] is True
mock_try_activate_fallback.assert_called_once_with()
assert mock_run_codex_stream.call_count == 2
assert hook_events[0]["error_type"] == "ContentPolicyBlocked"
assert hook_events[0]["retryable"] is False
assert hook_events[0]["reason"] == FailoverReason.content_policy_blocked.value
def test_ollama_small_runtime_context_fails_before_api_call(self, agent, caplog):
self._setup_agent(agent)
agent.model = "qwen3.5:9b"

View file

@ -1704,6 +1704,90 @@ def test_mid_turn_compaction_does_not_double_persist_in_place_rows(monkeypatch,
)
def _codex_incomplete_with_reasoning(text: str, reasoning_id: str = "rs_default"):
"""Incomplete response with a reasoning item whose id/encrypted_content
can vary independently of the visible message text."""
return SimpleNamespace(
output=[
SimpleNamespace(
type="reasoning",
id=reasoning_id,
encrypted_content=f"opaque_{reasoning_id}",
summary=[SimpleNamespace(text="thinking...")],
),
SimpleNamespace(
type="message",
status="in_progress",
content=[SimpleNamespace(type="output_text", text=text)],
),
],
usage=SimpleNamespace(input_tokens=4, output_tokens=2, total_tokens=6),
status="in_progress",
model="gpt-5-codex",
)
def test_codex_incomplete_visible_dedup_suppresses_duplicate_interims(monkeypatch):
"""Two consecutive incomplete responses with identical visible content
but different opaque reasoning items should be collapsed only the first
interim is emitted to the user (#52711)."""
agent = _build_agent(monkeypatch)
# 2 incompletes with same text but different reasoning ids, then a final.
# (Only 2 to avoid hitting the cap of 3.)
responses = [
_codex_incomplete_with_reasoning("Working on it...", "rs_1"),
_codex_incomplete_with_reasoning("Working on it...", "rs_2"),
_codex_message_response("Done."),
]
monkeypatch.setattr(agent, "_interruptible_api_call", lambda api_kwargs: responses.pop(0))
emitted: list = []
original_emit = agent._emit_interim_assistant_message
def _capture_emit(msg):
emitted.append(msg.get("content"))
original_emit(msg)
monkeypatch.setattr(agent, "_emit_interim_assistant_message", _capture_emit)
result = agent.run_conversation("test dedup")
assert result["completed"] is True
# Only ONE interim should have been emitted (the first), not two.
assert len(emitted) == 1
assert emitted[0] == "Working on it..."
def test_codex_incomplete_opaque_state_updated_in_place(monkeypatch):
"""When visible content is a duplicate, the last message's opaque state
(codex_reasoning_items) should be updated in-place without emitting a new
interim (#52711)."""
agent = _build_agent(monkeypatch)
responses = [
_codex_incomplete_with_reasoning("Partial output...", "rs_1"),
_codex_incomplete_with_reasoning("Partial output...", "rs_2"),
_codex_message_response("Final."),
]
monkeypatch.setattr(agent, "_interruptible_api_call", lambda api_kwargs: responses.pop(0))
result = agent.run_conversation("test opaque update")
assert result["completed"] is True
# Find the incomplete interim message in the result.
incompletes = [
m for m in result["messages"]
if m.get("role") == "assistant" and m.get("finish_reason") == "incomplete"
]
# Only one incomplete message should exist (the second was deduped).
assert len(incompletes) == 1
# The opaque state should reflect the LATEST reasoning item (rs_2),
# updated in-place on the single message.
items = incompletes[0].get("codex_reasoning_items")
if items:
assert any(
(i.get("id") if isinstance(i, dict) else getattr(i, "id", None)) == "rs_2"
for i in items
)
def test_normalize_codex_response_marks_commentary_only_message_as_incomplete(monkeypatch):
agent = _build_agent(monkeypatch)
from agent.codex_responses_adapter import _normalize_codex_response
@ -2608,7 +2692,8 @@ def test_codex_message_item_status_survives_conversion_and_preflight(monkeypatch
def test_duplicate_detection_distinguishes_different_codex_reasoning(monkeypatch):
"""Two consecutive reasoning-only responses with different encrypted content
must NOT be treated as duplicates."""
are deduped on visible content only one interim is kept, but opaque state
is updated in-place (#52711)."""
agent = _build_agent(monkeypatch)
responses = [
# First reasoning-only response
@ -2641,23 +2726,23 @@ def test_duplicate_detection_distinguishes_different_codex_reasoning(monkeypatch
assert result["completed"] is True
assert result["final_response"] == "Final answer after thinking."
# Both reasoning-only interim messages should be in history (not collapsed)
# Only one reasoning-only interim should be in history (deduped on
# visible content — both have empty visible output).
interim_msgs = [
msg for msg in result["messages"]
if msg.get("role") == "assistant"
and msg.get("finish_reason") == "incomplete"
]
assert len(interim_msgs) == 2
encrypted_contents = [
msg["codex_reasoning_items"][0]["encrypted_content"]
for msg in interim_msgs
]
assert "enc_first" in encrypted_contents
assert "enc_second" in encrypted_contents
assert len(interim_msgs) == 1
# But the opaque state should reflect the LATEST reasoning item.
items = interim_msgs[0].get("codex_reasoning_items")
if items:
assert items[0].get("encrypted_content") == "enc_second"
def test_duplicate_detection_distinguishes_different_codex_message_items(monkeypatch):
"""Incomplete turns with new message ids/phases/statuses must not be collapsed."""
"""Incomplete turns with same visible content but different message ids
are deduped only one interim kept, opaque state updated in-place (#52711)."""
agent = _build_agent(monkeypatch)
responses = [
SimpleNamespace(
@ -2700,12 +2785,12 @@ def test_duplicate_detection_distinguishes_different_codex_message_items(monkeyp
if msg.get("role") == "assistant"
and msg.get("finish_reason") == "incomplete"
]
assert len(interim_msgs) == 2
assert [msg["codex_message_items"][0]["id"] for msg in interim_msgs] == [
"msg_first",
"msg_second",
]
assert all(msg["codex_message_items"][0]["status"] == "in_progress" for msg in interim_msgs)
# Only one interim — deduped on visible content ("Still working..." == "Still working...").
assert len(interim_msgs) == 1
# Opaque state should reflect the latest message item.
items = interim_msgs[0].get("codex_message_items")
if items:
assert items[0].get("id") == "msg_second"
def test_chat_messages_to_responses_input_deduplicates_reasoning_ids(monkeypatch):

View file

@ -5093,6 +5093,51 @@ def test_prompt_submit_history_version_mismatch_surfaces_warning(monkeypatch):
server._sessions.pop("sid", None)
def test_prompt_submit_sanitizes_bracketed_paste_before_agent(monkeypatch):
"""prompt.submit must sanitize corrupted user text before run_conversation."""
captured: dict[str, str] = {}
class _Agent:
def run_conversation(
self, prompt, conversation_history=None, stream_callback=None
):
captured["prompt"] = prompt
return {
"final_response": "ok",
"messages": [{"role": "assistant", "content": "ok"}],
}
class _ImmediateThread:
def __init__(self, target=None, daemon=None, **kw):
self._target = target
def start(self):
self._target()
corrupted = "hello[" + "~[[e" * 8
server._sessions["sid"] = _session(agent=_Agent())
try:
monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
monkeypatch.setattr(server, "_get_usage", lambda _a: {})
monkeypatch.setattr(server, "render_message", lambda _t, _c: "")
monkeypatch.setattr(server, "_emit", lambda *a: None)
monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
monkeypatch.setattr(server, "_ensure_session_db_row", lambda *a, **k: None)
monkeypatch.setattr(server, "_persist_branch_seed", lambda *a, **k: None)
resp = server.handle_request(
{
"id": "1",
"method": "prompt.submit",
"params": {"session_id": "sid", "text": corrupted},
}
)
assert resp.get("result"), f"got error: {resp.get('error')}"
assert captured["prompt"] == "hello"
finally:
server._sessions.pop("sid", None)
def test_prompt_submit_history_version_match_persists_normally(monkeypatch):
"""Regression guard: the backstop does not affect the happy path."""

View file

@ -0,0 +1,138 @@
"""Tests for SessionDB WAL checkpoint strategy (issue #45383).
Verifies that periodic checkpoints use PASSIVE mode (safe for large DBs)
while close() and pre-VACUUM paths still use TRUNCATE.
"""
import sqlite3
import logging
from unittest.mock import MagicMock, patch
import pytest
from hermes_state import SessionDB
@pytest.fixture()
def db(tmp_path):
"""Create a SessionDB with a temp database file."""
db_path = tmp_path / "test_state.db"
session_db = SessionDB(db_path=db_path)
yield session_db
try:
session_db.close()
except Exception:
pass
class TestTryWalCheckpointPassive:
"""_try_wal_checkpoint() should use PASSIVE mode for periodic use."""
def test_checkpoint_uses_passive_mode(self, db):
"""PASSIVE checkpoint does not require exclusive lock — safe for large DBs."""
# Capture the real connection's execute before mocking
real_conn = db._conn
execute_calls = []
def tracking_execute(sql, *args, **kwargs):
execute_calls.append(sql)
return real_conn.execute(sql, *args, **kwargs)
# sqlite3.Connection.execute is read-only (C extension) — replace _conn
mock_conn = MagicMock()
mock_conn.execute.side_effect = tracking_execute
mock_conn.fetchone.return_value = None
db._conn = mock_conn
db._try_wal_checkpoint()
passive_calls = [c for c in execute_calls if "wal_checkpoint(PASSIVE)" in c]
truncate_calls = [c for c in execute_calls if "wal_checkpoint(TRUNCATE)" in c]
assert len(passive_calls) == 1, (
f"Expected 1 PASSIVE checkpoint call, got {len(passive_calls)}"
)
assert len(truncate_calls) == 0, (
"Periodic checkpoint should NOT use TRUNCATE"
)
def test_checkpoint_logs_warning_on_failure(self, db, caplog):
"""Failed PASSIVE checkpoint logs a warning instead of silent pass."""
mock_conn = MagicMock()
mock_conn.execute.side_effect = sqlite3.OperationalError("disk I/O error")
db._conn = mock_conn
with caplog.at_level(logging.WARNING):
db._try_wal_checkpoint()
assert any("WAL checkpoint (PASSIVE) failed" in r.message for r in caplog.records), (
f"Expected warning log about PASSIVE checkpoint failure, got: {caplog.text}"
)
def test_checkpoint_returns_result_on_success(self, db):
"""Successful PASSIVE checkpoint does not raise."""
db._try_wal_checkpoint()
class TestCloseUsesTruncate:
"""close() should still use TRUNCATE to shrink WAL on shutdown."""
def test_close_uses_truncate_mode(self, db):
"""TRUNCATE at close is safe — no concurrent writers during shutdown."""
real_conn = db._conn
execute_calls = []
def tracking_execute(sql, *args, **kwargs):
execute_calls.append(sql)
return real_conn.execute(sql, *args, **kwargs)
mock_conn = MagicMock()
mock_conn.execute.side_effect = tracking_execute
db._conn = mock_conn
db.close()
truncate_calls = [c for c in execute_calls if "wal_checkpoint(TRUNCATE)" in c]
assert len(truncate_calls) == 1, (
f"Expected 1 TRUNCATE checkpoint at close, got {len(truncate_calls)}"
)
def test_close_logs_debug_on_failure(self, db, caplog):
"""Failed TRUNCATE at close logs debug (not warning — close is best-effort)."""
mock_conn = MagicMock()
mock_conn.execute.side_effect = sqlite3.OperationalError("database is locked")
db._conn = mock_conn
with caplog.at_level(logging.DEBUG):
db.close()
assert any("WAL checkpoint (TRUNCATE) at close failed" in r.message for r in caplog.records), (
f"Expected debug log about TRUNCATE failure at close, got: {caplog.text}"
)
class TestCheckpointFrequency:
"""Checkpoint triggers every N writes."""
def test_checkpoint_triggers_at_interval(self, db):
"""_try_wal_checkpoint is called every _CHECKPOINT_EVERY_N_WRITES writes."""
call_count = [0]
original = db._try_wal_checkpoint
def counting_checkpoint():
call_count[0] += 1
original()
db._try_wal_checkpoint = counting_checkpoint
# Write exactly _CHECKPOINT_EVERY_N_WRITES sessions to trigger one checkpoint
n = db._CHECKPOINT_EVERY_N_WRITES
import time as _time
for i in range(n):
db._execute_write(lambda conn, _i=i: conn.execute(
"INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)",
(f"sess_{_i}", "test", _time.time()),
))
assert call_count[0] == 1, (
f"Expected 1 checkpoint after {n} writes, got {call_count[0]}"
)

View file

@ -8463,7 +8463,11 @@ def _(rid, params: dict) -> dict:
@method("prompt.submit")
def _(rid, params: dict) -> dict:
sid, text = params.get("session_id", ""), params.get("text", "")
from hermes_cli.input_sanitize import sanitize_user_prompt_text
sid = params.get("session_id", "")
raw_text = params.get("text", "")
text = sanitize_user_prompt_text(raw_text) if isinstance(raw_text, str) else raw_text
truncate_user_ordinal = params.get("truncate_before_user_ordinal")
session, err = _sess_nowait(params, rid)
if err:

View file

@ -2301,6 +2301,8 @@ export interface AuxiliaryModelsResponse {
export interface MoaModelSlot {
provider: string;
model: string;
/** Optional per-slot reasoning effort — round-tripped, not edited here. */
reasoning_effort?: string;
}
export interface MoaConfigResponse {
@ -2312,6 +2314,10 @@ export interface MoaConfigResponse {
reference_temperature: number;
aggregator_temperature: number;
max_tokens: number;
/** Optional advisor output cap — round-tripped, not edited here. */
reference_max_tokens?: number | null;
/** Fan-out cadence (per_iteration | user_turn) — round-tripped. */
fanout?: string;
enabled: boolean;
}>;
reference_models: MoaModelSlot[];

View file

@ -57,7 +57,7 @@ hermes skills uninstall <skill-name>
|-------|-------------|
| [**baoyu-article-illustrator**](/docs/user-guide/skills/optional/creative/creative-baoyu-article-illustrator) | Article illustrations: type × style × palette consistency. |
| [**baoyu-comic**](/docs/user-guide/skills/optional/creative/creative-baoyu-comic) | Knowledge comics (知识漫画): educational, biography, tutorial. |
| [**blender-mcp**](/docs/user-guide/skills/optional/creative/creative-blender-mcp) | Control Blender directly from Hermes via socket connection to the blender-mcp addon. Create 3D objects, materials, animations, and run arbitrary Blender Python (bpy) code. Use when user wants to create or modify anything in Blender. |
| [**blender-mcp**](/docs/user-guide/skills/optional/creative/creative-blender-mcp) | Drive Blender via the catalog blender MCP, with bpy recipes. |
| [**concept-diagrams**](/docs/user-guide/skills/optional/creative/creative-concept-diagrams) | Generate flat, minimal light/dark-aware SVG diagrams as standalone HTML files, using a unified educational visual language with 9 semantic color ramps, sentence-case typography, and automatic dark mode. Best suited for educational and no... |
| [**creative-ideation**](/docs/user-guide/skills/optional/creative/creative-creative-ideation) | Generate ideas via named methods from creative practice. |
| [**hyperframes**](/docs/user-guide/skills/optional/creative/creative-hyperframes) | Create HTML-based video compositions, animated title cards, social overlays, captioned talking-head videos, audio-reactive visuals, and shader transitions using HyperFrames. HTML is the source of truth for video. Use when the user wants... |

View file

@ -217,6 +217,12 @@ hermes gateway install # Install as a user service
sudo hermes gateway install --system # Linux only: boot-time system service
```
:::tip Codex reasoning-effort safety
For Codex-backed Slack peer-agent channels, prefer `agent.reasoning_effort: high` or lower. `xhigh`
can spend the entire turn in hidden reasoning and never produce visible assistant text; Hermes now
suppresses those incomplete-turn warnings from the thread and keeps the diagnostics in gateway logs.
:::
---
## Step 9: Invite the Bot to Channels

View file

@ -189,6 +189,50 @@ Kanban workers only ever see their own profile's secrets). Kanban,
profile-scoped skills/memory/SOUL, and model routing all behave per-profile
exactly as they do with separate gateways.
### Routing shared-bot chats to profiles (`profile_routes`)
Multiplexing selects a profile per **credential** (each profile's own bot
token) or per **URL prefix** (`/p/<profile>/` for HTTP platforms). When several
communities share **one** bot token — for example one Discord bot serving many
guilds — you can additionally route specific guilds/channels/threads to
different profiles with `gateway.profile_routes`:
```yaml
gateway:
multiplex_profiles: true
profile_routes:
# An entire Discord server → one profile
- name: acme-server
platform: discord
guild_id: "1234567890"
profile: acme
# One channel in that server → a different profile
- name: acme-support
platform: discord
guild_id: "1234567890"
chat_id: "9876543210"
profile: acme-support
# A Telegram group (no guild concept — chat_id only)
- name: tg-group
platform: telegram
chat_id: "-1001234567890"
profile: tg-profile
```
Routes are matched most-specific-first (`thread_id` > `chat_id` > `guild_id`),
all declared fields must hold (AND), and a route keyed on a channel also
matches threads/forum posts whose parent is that channel. Messages that match
no route stay on the default/active profile. The routed profile gets the full
per-profile isolation described above (config, skills, memory, credentials,
session namespace). Routing works on every platform adapter, not just Discord.
`profile_routes` requires `gateway.multiplex_profiles: true`; with
multiplexing off the routes are ignored. If a route names a profile that does
not exist on disk, the gateway logs a warning naming the profile and source and
falls back to the default home.
## Start, stop, or restart all gateways at once
The CLI ships with single-profile lifecycle commands. To act across every

View file

@ -1,14 +1,14 @@
---
title: "Blender Mcp — Control Blender directly from Hermes via socket connection to the blender-mcp addon"
title: "Blender Mcp — Drive Blender via the catalog blender MCP, with bpy recipes"
sidebar_label: "Blender Mcp"
description: "Control Blender directly from Hermes via socket connection to the blender-mcp addon"
description: "Drive Blender via the catalog blender MCP, with bpy recipes"
---
{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */}
# Blender Mcp
Control Blender directly from Hermes via socket connection to the blender-mcp addon. Create 3D objects, materials, animations, and run arbitrary Blender Python (bpy) code. Use when user wants to create or modify anything in Blender.
Drive Blender via the catalog blender MCP, with bpy recipes.
## Skill metadata
@ -16,8 +16,8 @@ Control Blender directly from Hermes via socket connection to the blender-mcp ad
|---|---|
| Source | Optional — install with `hermes skills install official/creative/blender-mcp` |
| Path | `optional-skills/creative/blender-mcp` |
| Version | `1.0.0` |
| Author | alireza78a |
| Version | `2.0.0` |
| Author | alireza78a + Hermes Agent |
| Platforms | linux, macos, windows |
## Reference: full SKILL.md
@ -26,87 +26,81 @@ Control Blender directly from Hermes via socket connection to the blender-mcp ad
The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active.
:::
# Blender MCP
# Blender MCP Skill
Control a running Blender instance from Hermes via socket on TCP port 9876.
Companion skill for the `blender` entry in the Hermes MCP catalog. The MCP
server provides the connection to Blender; this skill teaches the bpy idioms
and pitfalls for driving it well. It does not cover Blender UI workflows —
everything here goes through the MCP tools against a live Blender session.
## Setup (one-time)
## When to Use
### 1. Install the Blender addon
Use when the user wants to create or modify anything in a running Blender
instance: meshes, materials, animations, lighting, renders. Requires the
blender MCP server installed and a Blender desktop session with the addon
connected.
curl -sL https://raw.githubusercontent.com/ahujasid/blender-mcp/main/addon.py -o ~/Desktop/blender_mcp_addon.py
## Prerequisites
In Blender:
Edit > Preferences > Add-ons > Install > select blender_mcp_addon.py
Enable "Interface: Blender MCP"
1. Install the MCP server from the Nous catalog (one-time):
### 2. Start the socket server in Blender
hermes mcp install blender
Press N in Blender viewport to open sidebar.
Find "BlenderMCP" tab and click "Start Server".
This configures the pinned `blender-mcp` stdio server with the curated
tool set: `get_scene_info`, `get_object_info`, `get_viewport_screenshot`,
`execute_blender_code`.
### 3. Verify connection
2. Install the addon inside Blender (one-time — the catalog entry's
post-install notes cover this too):
- Download https://raw.githubusercontent.com/ahujasid/blender-mcp/main/addon.py
- Blender > Edit > Preferences > Add-ons > Install... > select addon.py,
enable "Interface: Blender MCP".
nc -z -w2 localhost 9876 && echo "OPEN" || echo "CLOSED"
3. Every session: start Blender FIRST, press N in the viewport, open the
"BlenderMCP" tab, click "Connect to Claude" (starts the local bridge
socket). Then start your Hermes session so the MCP tools are loaded.
## Protocol
The addon refuses to start under `blender -b` (background mode). On a
machine without a display, run Blender under a virtual one:
`xvfb-run blender`. GPU rendering works fine under Xvfb.
Plain UTF-8 JSON over TCP -- no length prefix.
## Quick Reference
Send: &#123;"type": "&lt;command>", "params": &#123;&lt;kwargs>&#125;&#125;
Receive: &#123;"status": "success", "result": &lt;value>&#125;
&#123;"status": "error", "message": "&lt;reason>"&#125;
| MCP tool | Use for |
|---------------------------|--------------------------------------------|
| `get_scene_info` | List objects before touching the scene |
| `get_object_info` | Inspect one object (transform, materials) |
| `get_viewport_screenshot` | Visual check of what you built |
| `execute_blender_code` | Everything else — arbitrary bpy Python |
## Available Commands
Optional asset-service tools (PolyHaven, Sketchfab, Hyper3D, Hunyuan3D) are
disabled by default. If the user has enabled a service in the addon panel,
opt into its tools with `hermes mcp configure blender`.
| type | params | description |
|-------------------------|-------------------|---------------------------------|
| execute_code | code (str) | Run arbitrary bpy Python code |
| get_scene_info | (none) | List all objects in scene |
| get_object_info | object_name (str) | Details on a specific object |
| get_viewport_screenshot | (none) | Screenshot of current viewport |
## Procedure
## Python Helper
1. Call `get_scene_info` first — never assume the scene is empty.
2. Build with `execute_blender_code`, in small focused calls (one logical
step per call: add objects, then materials, then animation). Large
monolithic scripts hit the bridge timeout.
3. Verify visually with `get_viewport_screenshot` between major steps.
4. Render to an absolute path and tell the user where the file is.
Use this inside execute_code tool calls:
### Common bpy Patterns
import socket, json
Clear scene:
def blender_exec(code: str, host="localhost", port=9876, timeout=15):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((host, port))
s.settimeout(timeout)
payload = json.dumps(&#123;"type": "execute_code", "params": &#123;"code": code&#125;&#125;)
s.sendall(payload.encode("utf-8"))
buf = b""
while True:
try:
chunk = s.recv(4096)
if not chunk:
break
buf += chunk
try:
json.loads(buf.decode("utf-8"))
break
except json.JSONDecodeError:
continue
except socket.timeout:
break
s.close()
return json.loads(buf.decode("utf-8"))
## Common bpy Patterns
### Clear scene
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
### Add mesh objects
Add mesh objects:
bpy.ops.mesh.primitive_uv_sphere_add(radius=1, location=(0, 0, 0))
bpy.ops.mesh.primitive_cube_add(size=2, location=(3, 0, 0))
bpy.ops.mesh.primitive_cylinder_add(radius=0.5, depth=2, location=(-3, 0, 0))
### Create and assign material
Create and assign material:
mat = bpy.data.materials.new(name="MyMat")
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
@ -115,21 +109,43 @@ Use this inside execute_code tool calls:
bsdf.inputs["Metallic"].default_value = 0.0
obj.data.materials.append(mat)
### Keyframe animation
Keyframe animation:
obj.location = (0, 0, 0)
obj.keyframe_insert(data_path="location", frame=1)
obj.location = (0, 0, 3)
obj.keyframe_insert(data_path="location", frame=60)
### Render to file
Render to file:
bpy.context.scene.render.filepath = "/tmp/render.png"
bpy.context.scene.render.engine = 'CYCLES'
bpy.ops.render.render(write_still=True)
## Pitfalls
- Must check socket is open before running (nc -z localhost 9876)
- Addon server must be started inside Blender each session (N-panel > BlenderMCP > Connect)
- Break complex scenes into multiple smaller execute_code calls to avoid timeouts
- Render output path must be absolute (/tmp/...) not relative
- shade_smooth() requires object to be selected and in object mode
- The blender MCP tools only exist if the server is installed and the session
started after install. If they're missing, run `hermes mcp install blender`
and start a new session.
- The addon bridge must be (re)connected inside Blender each Blender session
(N-panel > BlenderMCP > Connect). "Connection refused" from the tools means
Blender isn't running or the addon isn't connected — fix that, don't retry.
- Break complex scenes into multiple smaller `execute_blender_code` calls to
avoid bridge timeouts.
- Render output paths must be absolute (`/tmp/render.png`), not relative —
they resolve on the BLENDER host's filesystem, which matters if Hermes and
Blender run on different machines.
- `shade_smooth()` requires the object to be selected and in object mode.
- `execute_blender_code` runs arbitrary Python inside Blender with no sandbox
— same trust level as the `terminal` tool. Don't paste untrusted code into
it.
- Do NOT hand-roll raw TCP JSON to port 9876 from `execute_code` — that was
this skill's pre-MCP workaround. It bypasses the catalog's version pinning
and tool curation. The MCP tools are the supported path.
## Verification
- `get_scene_info` returns the expected object list after each build step.
- `get_viewport_screenshot` shows the scene you intended.
- After a render, confirm the output file exists and report its absolute
path to the user.

View file

@ -62,6 +62,14 @@ const config: Config = {
/^user-guide\/skills\/bundled\//,
/^user-guide\/skills\/optional\//,
],
// Exact-or-prefix matching only (default is edit distance 1).
// With fuzzy distance 1, "keet" matched "meetings"/"keep" (one
// edit away after stemming), and multi-word typo queries against
// our ~14 MB index could stall the single-threaded search worker
// for 25s+, backing up every subsequent keystroke's search until
// the bar appeared dead. Distance 0 keeps "word or its extension"
// semantics (keet -> keet*) and removes the pathological scans.
fuzzyMatchingDistance: 0,
}),
],
],