Skip to content
20 changes: 8 additions & 12 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -601,12 +601,11 @@ def _resolve_provider_vision_default(provider: str) -> Optional[str]:
# it must skip straight to the aggregator chain instead of returning a client
# that will 404 on every vision request.
#
# kimi-coding / kimi-coding-cn: the Kimi Coding Plan routes through
# api.kimi.com/coding (Anthropic Messages wire) which Kimi's own docs
# describe as having no image_in capability. Vision lives on the separate
# Kimi Platform (api.moonshot.ai, OpenAI-wire, pay-as-you-go). See #17076.
# NOTE: kimi-coding is intentionally NOT listed here. Kimi K3 on
# api.kimi.com/coding/v1 accepts OpenAI-style image_url content and models.dev
# reports supports_vision=True. Keep the skip only for variants without a
# verified image-input path.
_PROVIDERS_WITHOUT_VISION: frozenset = frozenset({
"kimi-coding",
"kimi-coding-cn",
})

Expand Down Expand Up @@ -6191,13 +6190,10 @@ def _finalize(resolved_provider: str, sync_client: Any, default_model: Optional[
)
return _finalize(main_provider, sync_client, default_model)
elif main_provider in _PROVIDERS_WITHOUT_VISION:
# Kimi Coding Plan's /coding endpoint (Anthropic Messages wire)
# does not accept image input — Kimi's own docs say "Current
# model does not support image input, switch to a model with
# image_in capability" and vision lives on the separate Kimi
# Platform (api.moonshot.ai). Skip the main provider and fall
# through to the aggregator chain instead of returning a
# client that will 404 on every vision request (#17076).
# Some provider variants do not accept image input on their
# main endpoint. Skip the main provider and fall through to the
# aggregator chain instead of returning a client that will 404
# on every vision request (#17076).
logger.debug(
"Vision auto-detect: skipping main provider %s (no "
"vision support) — falling through to aggregator chain",
Expand Down
15 changes: 14 additions & 1 deletion agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -5526,12 +5526,25 @@ def _perform_api_call(next_api_kwargs):

# Notify progress callback of model's thinking (used by subagent
# delegation to relay the child's reasoning to the parent display).
if (assistant_message.content and agent.tool_progress_callback):
# Prioritise structured reasoning fields (reasoning/reasoning_content)
# over content with inline <think> tags. Models with structured
# reasoning (DeepSeek, Qwen, Kimi thinking mode) return the final
# answer in content and thinking in a separate field; sending content
# as "reasoning" would put the answer text in execution_details
# instead of the message output.
_think_text = ""
if hasattr(assistant_message, 'reasoning') and assistant_message.reasoning:
_think_text = assistant_message.reasoning.strip()
elif hasattr(assistant_message, 'reasoning_content') and assistant_message.reasoning_content:
_think_text = assistant_message.reasoning_content.strip()
if not _think_text and assistant_message.content:
_think_text = assistant_message.content.strip()
# Strip reasoning XML tags that shouldn't leak to parent display
_think_text = re.sub(
r'</?(?:REASONING_SCRATCHPAD|think|reasoning)>', '', _think_text
).strip()

if _think_text and agent.tool_progress_callback:
# For subagents: relay first line to parent display (existing behaviour).
# For all agents with a structured callback: emit reasoning.available event.
first_line = _think_text.split('\n')[0][:80] if _think_text else ""
Expand Down
60 changes: 37 additions & 23 deletions agent/credential_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1096,6 +1096,14 @@ def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None
tokens["refresh_token"] = entry.refresh_token
if entry.last_refresh:
state["last_refresh"] = entry.last_refresh
root_path = auth_mod._global_auth_file_path()
if root_path is not None:
# Named-profile Codex state is always root-owned; do
# not materialize a refreshed singleton locally.
auth_mod._persist_provider_state_to_store(
"openai-codex", state, root_path, set_active=False,
)
return
_store_provider_state(auth_store, "openai-codex", state, set_active=False)

elif self.provider == "xai-oauth":
Expand Down Expand Up @@ -1401,31 +1409,37 @@ def _refresh_entry_impl(
# in-memory pool. Mirrors the xAI and Nous quarantine paths.
if auth_mod._is_terminal_codex_oauth_refresh_error(exc):
logger.debug(
"Codex OAuth refresh token is terminally invalid; clearing local token state"
"Codex OAuth refresh token is terminally invalid; clearing root-owned token state"
)
try:
with _auth_store_lock():
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "openai-codex") or {}
if isinstance(state, dict):
tokens = state.get("tokens") or {}
if isinstance(tokens, dict):
store_refresh = str(tokens.get("refresh_token") or "").strip()
entry_refresh = str(entry.refresh_token or "").strip()
if not store_refresh or store_refresh == entry_refresh:
tokens.pop("access_token", None)
tokens.pop("refresh_token", None)
state["tokens"] = tokens
state["last_auth_error"] = {
"provider": "openai-codex",
"code": getattr(exc, "code", "unknown"),
"message": str(exc),
"reason": "credential_pool_refresh_failure",
"relogin_required": True,
"at": datetime.now(timezone.utc).isoformat(),
}
_save_provider_state(auth_store, "openai-codex", state)
_save_auth_store(auth_store)
target_path = auth_mod._global_auth_file_path() or auth_mod._auth_file_path()
with _auth_store_lock(target_path=target_path):
auth_store = _load_auth_store(target_path)
providers = auth_store.get("providers")
state = (
dict(providers.get("openai-codex"))
if isinstance(providers, dict)
and isinstance(providers.get("openai-codex"), dict)
else {}
)
tokens = state.get("tokens") or {}
if isinstance(tokens, dict):
store_refresh = str(tokens.get("refresh_token") or "").strip()
entry_refresh = str(entry.refresh_token or "").strip()
if not store_refresh or store_refresh == entry_refresh:
tokens.pop("access_token", None)
tokens.pop("refresh_token", None)
state["tokens"] = tokens
state["last_auth_error"] = {
"provider": "openai-codex",
"code": getattr(exc, "code", "unknown"),
"message": str(exc),
"reason": "credential_pool_refresh_failure",
"relogin_required": True,
"at": datetime.now(timezone.utc).isoformat(),
}
_save_provider_state(auth_store, "openai-codex", state)
_save_auth_store(auth_store, target_path=target_path)
except Exception as clear_exc:
logger.debug(
"Failed to clear terminal Codex OAuth state: %s", clear_exc
Expand Down
63 changes: 54 additions & 9 deletions agent/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@
from collections import OrderedDict
from pathlib import Path

from hermes_constants import get_hermes_home, get_skills_dir, is_wsl
from hermes_constants import (
get_default_hermes_root,
get_hermes_home,
get_skills_dir,
is_wsl,
)
from typing import Optional

from agent.runtime_cwd import resolve_agent_cwd
Expand Down Expand Up @@ -243,14 +248,11 @@ def _strip_yaml_frontmatter(content: str) -> str:
"(`{changed_files: [...], tests_run: N, decisions: [...]}`). Downstream "
"workers read both via their own `kanban_show`. Never put secrets / "
"tokens / raw PII in either field — run rows are durable forever. "
"Exception: if your output is a code change that needs human review "
"before counting as merged/done (most coding tasks), drop the "
"structured metadata (changed_files / tests_run / diff_path) into a "
"`kanban_comment` first, then end with "
"`kanban_block(reason=\"review-required: <one-line summary>\")` so a "
"reviewer can approve+unblock or request changes. Reviewing-then-"
"completing is more honest than auto-completing work that still needs "
"eyes on it.\n"
"Exception: if your output is a code change that needs independent review, "
"call `kanban_submit_review(reviewer=..., summary=..., metadata=...)`. "
"It preserves implementation evidence and routes the card to the Review "
"lane; `kanban_block` remains for genuine human input, credentials, "
"capability, dependency, or transient failures.\n"
"6. **If follow-up work appears, create it; don't do it.** Use "
"`kanban_create(title=..., assignee=<right-profile>, parents=[your-task-id])` "
"to spawn a child task for the appropriate specialist profile instead of "
Expand Down Expand Up @@ -2013,6 +2015,49 @@ def load_soul_md(context_length: Optional[int] = None) -> Optional[str]:
return None


def load_universal_policy_md(context_length: Optional[int] = None) -> Optional[str]:
"""Load the root universal policy for a named profile, if configured.

``<root>/AGENTS.md`` is intentionally *not* part of ordinary project-context
discovery: ``AGENTS.md`` remains cwd-only so portable project instructions
never leak across workspaces. The root file is instead an explicit,
profile-wide policy source for named profiles. Returning the body without
a project-context wrapper lets the system-prompt builder keep it separate
and avoid a duplicate when the root directory is deliberately the cwd.

The default profile already owns the root and does not receive a second
copy. ``--ignore-rules`` is enforced by the caller, alongside SOUL and
project-context loading.
"""
profile_home = get_hermes_home()
root_home = get_default_hermes_root()
try:
if profile_home.resolve() == root_home.resolve():
return None
Comment on lines +2034 to +2036

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Apply the universal policy to the default profile

When the active profile is the default profile, this unconditional return prevents ~/.hermes/AGENTS.md from entering the new universal-policy slot. The ordinary AGENTS loader only examines the conversation cwd, and CLI/TUI sessions normally start in the user's project rather than ~/.hermes, so the purported universal policy applies to named profiles but silently disappears from the default profile. Load it for the default profile as well and let the existing context-body deduplication handle the uncommon root-cwd case.

Useful? React with 👍 / 👎.

except OSError:
# A path that cannot be resolved is not a safe basis for cross-profile
# policy inheritance; preserve the profile's normal isolated prompt.
return None

policy_path = root_home / "AGENTS.md"
if not policy_path.is_file():
return None
try:
content = policy_path.read_text(encoding="utf-8").strip()
if not content:
return None
content = _scan_context_content(content, "universal AGENTS.md")
return _truncate_content(
content,
"universal AGENTS.md",
context_length=context_length,
read_path=str(policy_path),
)
except Exception as e:
logger.debug("Could not read universal policy from %s: %s", policy_path, e)
return None


def _load_hermes_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
""".hermes.md / HERMES.md — walk to git root."""
hermes_md_path = _find_hermes_md(cwd_path)
Expand Down
13 changes: 13 additions & 0 deletions agent/system_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,19 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
if context_files_prompt:
context_parts.append(context_files_prompt)

# Root AGENTS.md is a deliberate universal-policy source for named
# profiles, not cwd-local project context. Keep it in the stable tier
# and suppress it if an explicit root cwd already loaded the same body
# through the ordinary AGENTS.md contract.
universal_policy = _r.load_universal_policy_md(_ctx_len)
if universal_policy and universal_policy not in context_files_prompt:
stable_parts.append(
"# Universal Profile Policy\n\n"
"The following policy comes from the Hermes root AGENTS.md and "
"applies to named profiles. It is not project-cwd context.\n\n"
+ universal_policy
)

# ── Volatile tier (changes per session/turn — never cached) ───
volatile_parts: List[str] = []

Expand Down
Loading
Loading