Skip to content

Commit cf3e1c8

Browse files
authored
Merge pull request #352 from Lexus2016/sync/upstream-2026-06-19
[UPSTREAM] Sync upstream/main — full parity (182 commits)
2 parents c56c764 + 4d8859c commit cf3e1c8

304 files changed

Lines changed: 26703 additions & 1855 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.dockerignore

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,3 @@ acp_registry/
102102
.gitattributes
103103
.hadolint.yaml
104104
.mailmap
105-
106-
# Top-level LICENSE (not matched by *.md); not needed inside the container
107-
LICENSE
138 KB
Loading
148 KB
Loading

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*.pyc*
66
__pycache__/
77
.venv/
8+
.venv
89
.vscode/
910
.env
1011
.env.local

Dockerfile

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df228
99
FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source
1010
FROM debian:13.4
1111

12-
# Disable Python stdout buffering to ensure logs are printed immediately
12+
# Disable Python stdout buffering to ensure logs are printed immediately.
13+
# Do not write .pyc files at runtime: /opt/hermes is immutable in the
14+
# published container and writable state belongs under /opt/data.
1315
ENV PYTHONUNBUFFERED=1
16+
ENV PYTHONDONTWRITEBYTECODE=1
1417

1518
# Store Playwright browsers outside the volume mount so the build-time
1619
# install survives the /opt/data volume overlay at runtime.
@@ -186,36 +189,38 @@ RUN cd web && npm run build && \
186189

187190
# ---------- Source code ----------
188191
# .dockerignore excludes node_modules, so the installs above survive.
189-
COPY --chown=hermes:hermes . .
192+
COPY . .
190193

191194
# ---------- Permissions ----------
192-
# Make install dir world-readable so any HERMES_UID can read it at runtime.
193-
# The venv needs to be traversable too.
194-
# node_modules trees additionally need to be writable by the hermes user
195-
# so the runtime `npm install` triggered by _tui_need_npm_install() in
196-
# hermes_cli/main.py succeeds (see #18800). /opt/hermes/web is build-time
197-
# only (HERMES_WEB_DIST points at hermes_cli/web_dist) and is intentionally
198-
# not chowned here.
199-
# /opt/hermes/gateway is runtime-writable: Python may create __pycache__ and
200-
# gateway state artifacts beneath the package after services drop privileges,
201-
# especially when the hermes UID is remapped at boot (#27221).
202-
# The .venv MUST remain hermes-writable so lazy_deps.py can install
203-
# remaining optional platform packages and future pin bumps at first use.
204-
# Without this, `uv pip install` fails with EACCES and adapters silently
205-
# fail to load. See tools/lazy_deps.py.
195+
# Link hermes-agent itself (editable). Deps are already installed in the
196+
# cached layer above; `--no-deps` makes this a fast egg-link creation with no
197+
# resolution or downloads.
198+
RUN uv pip install --no-cache-dir --no-deps -e "."
199+
200+
# Keep /opt/hermes immutable for the runtime hermes user. Hosted/container
201+
# instances must not be able to self-edit the installed source or venv; user
202+
# data, skills, plugins, config, logs, and dashboard uploads live under
203+
# /opt/data instead. Root can still repair the image during build/boot, but
204+
# supervised Hermes processes drop to the non-root hermes user.
206205
USER root
207-
RUN chmod -R a+rX /opt/hermes && \
208-
chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/gateway /opt/hermes/node_modules
206+
RUN mkdir -p /opt/hermes/bin && \
207+
cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \
208+
chmod 0755 /opt/hermes/bin/hermes && \
209+
printf 'docker\n' > /opt/hermes/.install_method && \
210+
chown -R root:root /opt/hermes && \
211+
chmod -R a+rX /opt/hermes && \
212+
chmod -R a-w /opt/hermes
213+
# The ``.install_method`` stamp is baked next to the running code (the install
214+
# tree), NOT into $HERMES_HOME. $HERMES_HOME (/opt/data) is a shared data
215+
# volume that is commonly bind-mounted from the host and even shared with a
216+
# host-side Desktop/CLI install; stamping it at boot used to clobber that
217+
# host install's marker and wrongly block its ``hermes update``. A code-scoped
218+
# stamp is read first by detect_install_method() and is immune to the share.
209219
# Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
210220
# the data volume. Each supervised service then drops to the hermes user via
211221
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
212222
# run as the default hermes user (UID 10000).
213223

214-
# ---------- Link hermes-agent itself (editable) ----------
215-
# Deps are already installed in the cached layer above; `--no-deps` makes
216-
# this a fast (~1s) egg-link creation with no resolution or downloads.
217-
RUN uv pip install --no-cache-dir --no-deps -e "."
218-
219224
# ---------- Bake build-time git revision ----------
220225
# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
221226
# container always returns nothing — meaning `hermes dump` reports
@@ -235,8 +240,9 @@ RUN uv pip install --no-cache-dir --no-deps -e "."
235240
# every published image has it.
236241
ARG HERMES_GIT_SHA=
237242
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
243+
chmod u+w /opt/hermes && \
238244
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \
239-
chown hermes:hermes /opt/hermes/.hermes_build_sha; \
245+
chmod a-w /opt/hermes /opt/hermes/.hermes_build_sha; \
240246
fi
241247

242248
# ---------- s6-overlay service wiring ----------
@@ -282,6 +288,8 @@ ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
282288
# check. (A separate launcher hardening is tracked independently.)
283289
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
284290
ENV HERMES_HOME=/opt/data
291+
ENV HERMES_WRITE_SAFE_ROOT=/opt/data
292+
ENV HERMES_DISABLE_LAZY_INSTALLS=1
285293

286294
# `docker exec` privilege-drop shim. When operators run
287295
# `docker exec <c> hermes ...` they default to root, and any file the
@@ -294,7 +302,6 @@ ENV HERMES_HOME=/opt/data
294302
# Recursion is impossible because the shim exec's the venv binary by
295303
# absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for
296304
# the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1).
297-
COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes
298305

299306
# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
300307
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the

agent/agent_init.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1172,6 +1172,9 @@ def init_agent(
11721172
"hermes_home": str(get_hermes_home()),
11731173
"agent_context": "primary",
11741174
}
1175+
if _init_kwargs["platform"] == "cli":
1176+
_init_kwargs["warning_callback"] = agent._emit_warning
1177+
_init_kwargs["status_callback"] = agent._emit_status
11751178
# Thread session title for memory provider scoping
11761179
# (e.g. honcho uses this to derive chat-scoped session keys)
11771180
if agent._session_db:
@@ -1240,12 +1243,35 @@ def init_agent(
12401243
# targets.
12411244
agent._task_completion_guidance = bool(_agent_section.get("task_completion_guidance", True))
12421245

1246+
# Universal parallel-tool-call guidance toggle. Default True. Separate
1247+
# flag from task_completion_guidance because a user may want one but not
1248+
# the other. Steers the model to batch independent tool calls into a
1249+
# single turn; the runtime already executes such batches concurrently.
1250+
agent._parallel_tool_call_guidance = bool(_agent_section.get("parallel_tool_call_guidance", True))
1251+
12431252
# Local Python toolchain probe toggle. Default True. When False,
12441253
# the probe is skipped entirely (no subprocess calls, no system-prompt
12451254
# line). Useful for users on exotic setups where the probe heuristics
12461255
# are noisy.
12471256
agent._environment_probe = bool(_agent_section.get("environment_probe", True))
12481257

1258+
# Per-platform prompt-hint overrides (config.yaml → platform_hints).
1259+
# Lets an enterprise admin append to or replace Hermes' built-in
1260+
# platform hint for a single messaging platform (e.g. WhatsApp) without
1261+
# affecting other platforms. Shape:
1262+
# platform_hints:
1263+
# whatsapp:
1264+
# append: "When tabular output would help, invoke the ... skill."
1265+
# slack:
1266+
# replace: "Custom Slack hint that fully replaces the default."
1267+
# Stored verbatim; resolution happens in agent/system_prompt.py against
1268+
# the active platform. Invalid shapes are ignored defensively so a bad
1269+
# config entry can never break prompt assembly.
1270+
_platform_hints_cfg = _agent_cfg.get("platform_hints", {})
1271+
if not isinstance(_platform_hints_cfg, dict):
1272+
_platform_hints_cfg = {}
1273+
agent._platform_hint_overrides = _platform_hints_cfg
1274+
12491275
# App-level API retry count (wraps each model API call). Default 3,
12501276
# overridable via agent.api_max_retries in config.yaml. See #11616.
12511277
try:

agent/agent_runtime_helpers.py

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1922,6 +1922,7 @@ def _execute(next_args: dict) -> Any:
19221922
elif function_name == "memory":
19231923
def _execute(next_args: dict) -> Any:
19241924
target = next_args.get("target", "memory")
1925+
operations = next_args.get("operations")
19251926
from tools.memory_tool import memory_tool as _memory_tool
19261927
from tools.memory_tool import DEFAULT_SOURCE_CLASS, DEFAULT_TRUST_TIER
19271928
result = _memory_tool(
@@ -1933,22 +1934,35 @@ def _execute(next_args: dict) -> Any:
19331934
trust_tier=next_args.get("trust_tier", DEFAULT_TRUST_TIER),
19341935
source_filter=next_args.get("source_filter"),
19351936
min_trust=next_args.get("min_trust"),
1937+
operations=operations,
19361938
store=agent._memory_store,
19371939
)
1938-
# Bridge: notify external memory provider of built-in memory writes
1939-
if agent._memory_manager and next_args.get("action") in {"add", "replace"}:
1940-
try:
1941-
agent._memory_manager.on_memory_write(
1942-
next_args.get("action", ""),
1943-
target,
1944-
next_args.get("content", ""),
1945-
metadata=agent._build_memory_write_metadata(
1946-
task_id=effective_task_id,
1947-
tool_call_id=tool_call_id,
1948-
),
1940+
# Bridge: notify external memory provider of built-in memory writes.
1941+
# Covers both the single-op shape and each add/replace inside a batch.
1942+
if agent._memory_manager:
1943+
if operations:
1944+
_mem_ops = [
1945+
op for op in operations
1946+
if isinstance(op, dict) and op.get("action") in {"add", "replace"}
1947+
]
1948+
else:
1949+
_mem_ops = (
1950+
[{"action": next_args.get("action"), "content": next_args.get("content")}]
1951+
if next_args.get("action") in {"add", "replace"} else []
19491952
)
1950-
except Exception:
1951-
pass
1953+
for _op in _mem_ops:
1954+
try:
1955+
agent._memory_manager.on_memory_write(
1956+
_op.get("action", ""),
1957+
target,
1958+
_op.get("content", "") or "",
1959+
metadata=agent._build_memory_write_metadata(
1960+
task_id=effective_task_id,
1961+
tool_call_id=tool_call_id,
1962+
),
1963+
)
1964+
except Exception:
1965+
pass
19521966
return _finish_agent_tool(result, next_args)
19531967
elif agent._memory_manager and agent._memory_manager.has_tool(function_name):
19541968
def _execute(next_args: dict) -> Any:

agent/anthropic_adapter.py

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ def _detect_claude_code_version() -> str:
372372

373373

374374
_CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude."
375-
_MCP_TOOL_PREFIX = "mcp_"
375+
_MCP_TOOL_PREFIX = "mcp__"
376376

377377

378378
def _get_claude_code_version() -> str:
@@ -2349,25 +2349,46 @@ def build_anthropic_kwargs(
23492349
text = text.replace("Nous Research", "Anthropic")
23502350
block["text"] = text
23512351

2352-
# 3. Prefix tool names with mcp_ (Claude Code convention)
2353-
# Skip names that already begin with the marker — native MCP server
2354-
# tools (from mcp_servers: in config.yaml) are registered under their
2355-
# full mcp_<server>_<tool> name and would double-prefix otherwise,
2356-
# breaking round-trip registry lookup in normalize_response. GH-25255.
2352+
# 3. Normalize tool names so NOTHING goes on the OAuth wire with a
2353+
# single-underscore ``mcp_`` prefix. Anthropic's subscription/OAuth
2354+
# billing classifier treats a single-underscore ``mcp_`` tool name as
2355+
# a third-party-app fingerprint and rejects the request with HTTP 400
2356+
# "Third-party apps now draw from extra usage, not plan limits"
2357+
# (verified empirically: a single ``mcp_foo`` tool flips a request
2358+
# from plan-billing to the extra-usage lane; ``mcp__foo`` is accepted).
2359+
#
2360+
# Two cases, both must land on the double-underscore ``mcp__`` form:
2361+
# a) bare Hermes-native tools (``read_file``) -> ``mcp__read_file``
2362+
# b) native MCP server tools registered under their full
2363+
# single-underscore ``mcp_<server>_<tool>`` name
2364+
# (``mcp_linear_get_issue``) -> ``mcp__linear_get_issue``
2365+
# Case (b) is the gap that the bare ``mcp_``->``mcp__`` constant swap
2366+
# left open: those tools were *skipped* and stayed single-underscore,
2367+
# so any session with an MCP server configured still tripped the
2368+
# classifier. normalize_response reverses both forms via registry
2369+
# lookup so the dispatcher still sees the original name. GH-25255.
2370+
def _to_oauth_wire_name(name: str) -> str:
2371+
if name.startswith("mcp__"):
2372+
return name # already correct, don't double-prefix
2373+
if name.startswith("mcp_"):
2374+
# single-underscore native MCP tool -> promote to double
2375+
return "mcp__" + name[len("mcp_"):]
2376+
return _MCP_TOOL_PREFIX + name # bare name -> mcp__<name>
2377+
23572378
if anthropic_tools:
23582379
for tool in anthropic_tools:
2359-
if "name" in tool and not tool["name"].startswith(_MCP_TOOL_PREFIX):
2360-
tool["name"] = _MCP_TOOL_PREFIX + tool["name"]
2380+
if "name" in tool:
2381+
tool["name"] = _to_oauth_wire_name(tool["name"])
23612382

2362-
# 4. Prefix tool names in message history (tool_use and tool_result blocks)
2383+
# 4. Apply the same normalization to tool names in message history
2384+
# (tool_use blocks) so replayed turns match the wire names above.
23632385
for msg in anthropic_messages:
23642386
content = msg.get("content")
23652387
if isinstance(content, list):
23662388
for block in content:
23672389
if isinstance(block, dict):
23682390
if block.get("type") == "tool_use" and "name" in block:
2369-
if not block["name"].startswith(_MCP_TOOL_PREFIX):
2370-
block["name"] = _MCP_TOOL_PREFIX + block["name"]
2391+
block["name"] = _to_oauth_wire_name(block["name"])
23712392
elif block.get("type") == "tool_result" and "tool_use_id" in block:
23722393
pass # tool_result uses ID, not name
23732394

agent/background_review.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ def summarize_background_review_actions(
300300
"target": args.get("target", "memory"),
301301
"content": args.get("content", ""),
302302
"old_text": args.get("old_text", ""),
303+
"operations": args.get("operations") or [],
303304
"name": args.get("name", ""),
304305
"old_string": args.get("old_string", ""),
305306
"new_string": args.get("new_string", ""),
@@ -353,6 +354,7 @@ def summarize_background_review_actions(
353354
content = detail.get("content", "")
354355
old_text = detail.get("old_text", "")
355356
skill_name = detail.get("name", "")
357+
operations = detail.get("operations") or []
356358
max_preview = 120
357359
if is_skill:
358360
change = data.get("_change", {})
@@ -376,6 +378,21 @@ def summarize_background_review_actions(
376378
actions.append(f"📝 Skill '{skill_name}' rewritten: {description}")
377379
else:
378380
actions.append(f"📝 {message}" if message else f"Skill {action}")
381+
elif operations:
382+
for op in operations:
383+
op = op or {}
384+
op_act = op.get("action", "")
385+
op_content = (op.get("content") or "")
386+
op_old = (op.get("old_text") or "")
387+
if op_act == "add" and op_content:
388+
preview = op_content[:max_preview] + ("…" if len(op_content) > max_preview else "")
389+
actions.append(f"{label}{preview}")
390+
elif op_act == "replace" and op_content:
391+
preview = op_content[:max_preview] + ("…" if len(op_content) > max_preview else "")
392+
actions.append(f"{label} ✏️ {preview}")
393+
elif op_act == "remove" and op_old:
394+
preview = op_old[:60] + ("…" if len(op_old) > 60 else "")
395+
actions.append(f"{label}{preview}")
379396
elif action == "add" and content:
380397
preview = content[:max_preview] + ("…" if len(content) > max_preview else "")
381398
actions.append(f"{label}{preview}")
@@ -391,6 +408,7 @@ def summarize_background_review_actions(
391408
"added" in message_lower
392409
or "replaced" in message_lower
393410
or "removed" in message_lower
411+
or "applied" in message_lower
394412
or (target and "add" in message.lower())
395413
or "Entry added" in message
396414
):

0 commit comments

Comments
 (0)