Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
813a4e3
fix(openviking): implement on_session_switch hook (#28296)
harshitAgr May 19, 2026
a30b40c
fix(openviking): close session-boundary races on sync_turn and on_ses…
harshitAgr May 20, 2026
eddbf29
fix(openviking): close remaining session-boundary races on switch
harshitAgr May 21, 2026
91e9459
fix(openviking): track writers per-session so commit waits for all
harshitAgr May 21, 2026
f3b813c
test(openviking): preserve content/write memory writes
ehz0ah Jun 17, 2026
00c045b
fix(openviking): harden session writes and switch commits
ehz0ah Jun 17, 2026
3ac6551
fix(openviking): handle rewound session switches
ehz0ah Jun 17, 2026
99a20f8
test(openviking): update plugin expectations
ehz0ah Jun 17, 2026
4de4a4e
fix(tests): type-correct OpenViking skill-scaffolding test sentinels
kshitijk4poor Jun 17, 2026
1e6c4ba
Merge pull request #47973 from kshitijk4poor/fix/ov-skill-scaffolding
kshitijk4poor Jun 17, 2026
0c1e8d0
Merge remote-tracking branch 'upstream/main' into salvage-47662
kshitijk4poor Jun 17, 2026
b07b789
fix(desktop): keep streaming painting in unfocused secondary chat win…
xxxigm Jun 17, 2026
33b1d14
fix(desktop): pin Electron below the broken native extract-zip instal…
xxxigm Jun 17, 2026
c835448
fix(openviking): don't block the command thread on session switch; lo…
kshitijk4poor Jun 17, 2026
394cdf4
fix(logging): alias RotatingFileHandler to concurrent-log-handler (sa…
austinpickett Jun 17, 2026
22b6942
feat(search_files): headroom compression evaluation report + lossless…
teknium1 Jun 17, 2026
5a00bd1
fix(desktop): persist /title set before the first message instead of …
austinpickett Jun 17, 2026
ee41aa0
feat(desktop): add dismiss control to chat error banners (#47985)
austinpickett Jun 17, 2026
7fbb8c9
Merge pull request #48042 from kshitijk4poor/salvage-47662
kshitijk4poor Jun 17, 2026
fd674af
fix(photon): preserve text in mixed iMessage attachments (salvage #46…
austinpickett Jun 17, 2026
016bce1
fix(desktop): recover stranded session windows when resume fails (#47…
austinpickett Jun 17, 2026
f8098c6
fix(desktop): resolve electronDist to the actual electron install loc…
teknium1 Jun 17, 2026
6092be4
Harden hosted Docker install tree against self-modification (#47490)
shannonsands Jun 17, 2026
e9a2ce6
test: lock gateway adapter capability surface (relay phase 0)
benbarclay Jun 8, 2026
53d9b98
feat(relay): experimental CapabilityDescriptor schema
benbarclay Jun 8, 2026
3db4938
feat(relay): derive descriptor from PlatformEntry
benbarclay Jun 8, 2026
b0999c8
feat(relay): generic RelayAdapter advertising negotiated capabilities
benbarclay Jun 8, 2026
259e78e
feat(relay): transport protocol + test-only stub connector
benbarclay Jun 8, 2026
d0133fd
feat(relay): register RelayAdapter through platform registry (flagged…
benbarclay Jun 8, 2026
a3cdd8c
feat(relay): route mid-turn /stop over relay interrupt channel
benbarclay Jun 8, 2026
ab1a42f
docs: relay<->connector cross-repo contract (v1, experimental)
benbarclay Jun 8, 2026
c366466
test(relay): assert connector stub never leaks into production paths
benbarclay Jun 8, 2026
c803661
fix(gateway): register relay connection checker
benbarclay Jun 9, 2026
5feec8b
test(gateway): enforce relay contract-doc ⟷ Python conformance
benbarclay Jun 9, 2026
e74577e
test(gateway): Telegram relay round-trip (Phase 1 generalization proof)
benbarclay Jun 9, 2026
c28a02b
test(gateway): shed platform crypto from the relay path (A2 invariant)
benbarclay Jun 10, 2026
3db9b3e
feat(gateway): token-less follow_up outbound op (A2 capability action)
benbarclay Jun 10, 2026
6e20c19
docs(gateway): rewrite contract §6 to the A2 trust-boundary model
benbarclay Jun 10, 2026
6b03874
feat(gateway): production WebSocketRelayTransport + descriptor negoti…
benbarclay Jun 10, 2026
237fa7d
feat(gateway): register relay adapter from config; drop HERMES_GATEWA…
benbarclay Jun 10, 2026
acc8916
test(gateway): live ws-transport round-trip + config-driven registration
benbarclay Jun 10, 2026
c1f9eb0
fix(desktop): resolve electronDist dynamically + self-heal blocked in…
OutThisLife Jun 17, 2026
9ba4615
fix(dump): show commit date instead of release date in hermes debug (…
teknium1 Jun 17, 2026
86f2946
fix(dashboard): recover the Chat tab when the agent session ends (NS-…
benbarclay Jun 18, 2026
020e59d
fix(agent): dampen empty-name phantom tool-call loop (#47967) (#48109)
teknium1 Jun 18, 2026
4b7a186
fix(desktop): retry the self-update rebuild once so the app relaunche…
OutThisLife Jun 18, 2026
6f89e17
fix(xai): OAuth Responses native web_search, incomplete guard, grok-c…
Jun 11, 2026
c5eb64b
fix(xai): scope native web_search to swap-only + reconcile composer c…
teknium1 Jun 17, 2026
fcf6cb3
fix(docker): supervised gateway uses --replace to take over stale hol…
benbarclay Jun 18, 2026
c276b01
feat(relay): connector⇄gateway channel auth + signed-HTTP inbound rec…
benbarclay Jun 18, 2026
3769dff
fix(approval): honor glob command allowlist entries (#43051)
helix4u Jun 18, 2026
2029adc
Merge remote-tracking branch 'upstream/main' into HEAD
alt-glitch Jun 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*.pyc*
__pycache__/
.venv/
.venv
.vscode/
.env
.env.local
Expand Down
50 changes: 25 additions & 25 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df228
FROM node:26-bookworm-slim@sha256:79723b41edbedf595f62e943a9f8b0ba9af5b1e61045c5f8f59c2c02c1212a16 AS node_source
FROM debian:13.4

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

# Store Playwright browsers outside the volume mount so the build-time
# install survives the /opt/data volume overlay at runtime.
Expand Down Expand Up @@ -196,36 +199,31 @@ RUN cd web && npm run build && \

# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.
COPY --chown=hermes:hermes . .
COPY . .

# ---------- Permissions ----------
# Make install dir world-readable so any HERMES_UID can read it at runtime.
# The venv needs to be traversable too.
# node_modules trees additionally need to be writable by the hermes user
# so the runtime `npm install` triggered by _tui_need_npm_install() in
# hermes_cli/main.py succeeds (see #18800). /opt/hermes/web is build-time
# only (HERMES_WEB_DIST points at hermes_cli/web_dist) and is intentionally
# not chowned here.
# /opt/hermes/gateway is runtime-writable: Python may create __pycache__ and
# gateway state artifacts beneath the package after services drop privileges,
# especially when the hermes UID is remapped at boot (#27221).
# The .venv MUST remain hermes-writable so lazy_deps.py can install
# remaining optional platform packages and future pin bumps at first use.
# Without this, `uv pip install` fails with EACCES and adapters silently
# fail to load. See tools/lazy_deps.py.
# Link hermes-agent itself (editable). Deps are already installed in the
# cached layer above; `--no-deps` makes this a fast egg-link creation with no
# resolution or downloads.
RUN uv pip install --no-cache-dir --no-deps -e "."

# Keep /opt/hermes immutable for the runtime hermes user. Hosted/container
# instances must not be able to self-edit the installed source or venv; user
# data, skills, plugins, config, logs, and dashboard uploads live under
# /opt/data instead. Root can still repair the image during build/boot, but
# supervised Hermes processes drop to the non-root hermes user.
USER root
RUN chmod -R a+rX /opt/hermes && \
chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/gateway /opt/hermes/node_modules
RUN mkdir -p /opt/hermes/bin && \
cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \
chmod 0755 /opt/hermes/bin/hermes && \
chown -R root:root /opt/hermes && \
chmod -R a+rX /opt/hermes && \
chmod -R a-w /opt/hermes
# Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
# the data volume. Each supervised service then drops to the hermes user via
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
# run as the default hermes user (UID 10000).

# ---------- Link hermes-agent itself (editable) ----------
# Deps are already installed in the cached layer above; `--no-deps` makes
# this a fast (~1s) egg-link creation with no resolution or downloads.
RUN uv pip install --no-cache-dir --no-deps -e "."

# ---------- Bake build-time git revision ----------
# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
# container always returns nothing — meaning `hermes dump` reports
Expand All @@ -245,8 +243,9 @@ RUN uv pip install --no-cache-dir --no-deps -e "."
# every published image has it.
ARG HERMES_GIT_SHA=
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
chmod u+w /opt/hermes && \
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \
chown hermes:hermes /opt/hermes/.hermes_build_sha; \
chmod a-w /opt/hermes /opt/hermes/.hermes_build_sha; \
fi

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

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

# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the
Expand Down
69 changes: 67 additions & 2 deletions agent/codex_responses_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,26 @@ def _responses_tools(tools: Optional[List[Dict[str, Any]]] = None) -> Optional[L
return converted or None


# Provider-executed built-in tool *declaration* types accepted on the
# Responses ``tools`` array. These are declared by ``type`` alone (no
# client-side name/parameters schema) and run server-side — the provider
# owns the implementation and reports progress via the matching ``*_call``
# output items. Hermes injects xAI's native ``web_search`` for the xAI
# transport (see agent/transports/codex.py); the rest are listed so the
# preflight validator passes them through rather than rejecting them as
# "unsupported type". Mirrors the ``*_call`` item-type set used in
# _normalize_codex_response.
_RESPONSES_BUILTIN_TOOL_TYPES = {
"web_search",
"web_search_preview",
"file_search",
"code_interpreter",
"image_generation",
"computer_use_preview",
"local_shell",
}


# ---------------------------------------------------------------------------
# Message format conversion
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -802,7 +822,22 @@ def _preflight_codex_api_kwargs(
for idx, tool in enumerate(tools):
if not isinstance(tool, dict):
raise ValueError(f"Codex Responses tools[{idx}] must be an object.")
if tool.get("type") != "function":

tool_type = tool.get("type")

# Provider-executed built-in tools (xAI native web_search, code
# interpreter, etc.) are declared by ``type`` alone and carry no
# ``name``/``parameters`` schema — the provider owns the
# implementation. Pass them through verbatim instead of forcing
# them through the function-tool validation below (which would
# otherwise reject them with "unsupported type"). See
# agent/transports/codex.py for where xAI's native web_search is
# injected.
if tool_type in _RESPONSES_BUILTIN_TOOL_TYPES:
normalized_tools.append(dict(tool))
continue

if tool_type != "function":
raise ValueError(f"Codex Responses tools[{idx}] has unsupported type {tool.get('type')!r}.")

name = tool.get("name")
Expand Down Expand Up @@ -1086,6 +1121,33 @@ def _normalize_codex_response(
saw_final_answer_phase = False
saw_reasoning_item = False

# Server-side built-in tool calls (xAI's native web_search, code
# interpreter, etc.) are executed by the provider and reported as
# discrete ``*_call`` output items. xAI's /v1/responses surface
# (e.g. grok-composer-2.5-fast on SuperGrok OAuth) routinely leaves
# these items at ``status="in_progress"`` even when the overall
# ``response.status == "completed"`` — the search ran to completion
# server-side, the per-item status simply isn't reconciled. These
# are NOT a signal that the model's turn is unfinished, so they must
# not flip ``has_incomplete_items``. Only the response-level status
# and genuine model output items (message/reasoning/function_call)
# govern the incomplete verdict. Without this guard, any turn where
# grok-composer invokes server-side search is misclassified as
# ``finish_reason="incomplete"`` and burns 3 fruitless continuation
# retries before failing with "Codex response remained incomplete
# after 3 continuation attempts". client-side function/custom tool
# calls keep their own in_progress handling below (they are skipped,
# not awaited).
_SERVER_SIDE_TOOL_CALL_TYPES = {
"web_search_call",
"file_search_call",
"code_interpreter_call",
"image_generation_call",
"computer_call",
"local_shell_call",
"mcp_call",
}

for item in output:
item_type = getattr(item, "type", None)
item_status = getattr(item, "status", None)
Expand All @@ -1094,7 +1156,10 @@ def _normalize_codex_response(
else:
item_status = None

if item_status in {"queued", "in_progress", "incomplete"}:
if (
item_status in {"queued", "in_progress", "incomplete"}
and item_type not in _SERVER_SIDE_TOOL_CALL_TYPES
):
has_incomplete_items = True
saw_streaming_or_item_incomplete = True

Expand Down
26 changes: 24 additions & 2 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -3756,8 +3756,30 @@ def _perform_api_call(next_api_kwargs):
assistant_msg = agent._build_assistant_message(assistant_message, finish_reason)
messages.append(assistant_msg)
for tc in assistant_message.tool_calls:
if tc.function.name not in agent.valid_tool_names:
content = f"Tool '{tc.function.name}' does not exist. Available tools: {available}"
_tc_name = tc.function.name
if _tc_name not in agent.valid_tool_names:
# A blank/whitespace-only name is not a typo the
# model can fuzzy-correct toward a real tool — it is
# almost always a weak open model echoing tool-call
# XML/JSON it saw in file or tool output (#47967:
# <tool_call>/<invoke name=...> payloads in a file
# prime mimo/nemotron-class models to emit empty
# structured calls). Dumping the full tool catalog
# in that case feeds the priming loop more names to
# mimic and inflates context 3-4x across retries, so
# send a terse error that tells the model in-context
# tool-call syntax is DATA, not a call to make.
if not (_tc_name or "").strip():
content = (
"Tool call rejected: the tool name was empty. "
"If tool-call XML or JSON appeared in file "
"contents or tool output, that is data — do "
"not re-emit it as a tool call. To call a "
"tool, use a valid name from your tool list; "
"otherwise reply in plain text."
)
else:
content = f"Tool '{_tc_name}' does not exist. Available tools: {available}"
else:
content = "Skipped: another tool call in this turn used an invalid name. Please retry this tool call."
messages.append({
Expand Down
4 changes: 4 additions & 0 deletions agent/model_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,10 @@ def _save_model_metadata_disk_cache(data: Dict[str, Dict[str, Any]]) -> None:
# via a custom provider. Values sourced from models.dev (2026-04).
# Keys use substring matching (longest-first), so e.g. "grok-4.20"
# matches "grok-4.20-0309-reasoning" / "-non-reasoning" / "-multi-agent-0309".
# OAuth-only slug; absent from GET /v1/models. xAI publishes a 200k
# usable context window for Composer 2.5 on Grok Build (SuperGrok /
# Premium+); /v1/responses additionally enforces a ~262144 input+output
# budget, but the usable context (what we track here) is 200k.
"grok-composer": 200000, # grok-composer-2.5-fast (Grok Build CLI)
"grok-build": 256000, # grok-build-0.1
"grok-code-fast": 256000, # grok-code-fast-1
Expand Down
59 changes: 59 additions & 0 deletions agent/transports/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,65 @@ def build_kwargs(
reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort)

response_tools = _responses_tools(tools)

# xAI server-side web search.
#
# grok models on xAI's /v1/responses surface (notably
# grok-composer-2.5-fast on SuperGrok OAuth) have a *native*,
# server-executed web search. When the model is handed a
# client-side function literally named ``web_search``, it routes
# the intent to that native engine — but because the tool is
# declared as a plain ``function`` rather than xAI's first-class
# ``{"type": "web_search"}`` built-in, the server-side search is
# dispatched but never reconciled: the response streams reasoning
# + ``web_search_call`` progress items, the searches never reach
# ``status="completed"`` in the assembled output, no final
# message is emitted, and ``_normalize_codex_response`` correctly
# sees reasoning-with-no-answer and reports ``incomplete``. The
# turn then burns 3 continuation retries and fails with "Codex
# response remained incomplete after 3 continuation attempts".
# Verified live against grok-composer-2.5-fast (2026-06).
#
# Fix: when the agent HAS a client-side ``web_search`` function (i.e.
# the user enabled the web toolset), declare xAI's native
# ``web_search`` built-in instead so the search actually runs to
# completion server-side and the model streams a real answer. The
# Responses API rejects two tools sharing the name ``web_search``
# (HTTP 400 "Duplicate tool names"), so we drop the client-side
# ``web_search`` function for the xAI path and let the native tool
# satisfy it. All other client-side tools (read_file, terminal,
# web_extract, MCP tools, …) are untouched and continue to dispatch
# through Hermes's agent loop.
#
# Scope: we ONLY swap in the native built-in when the client
# ``web_search`` was actually present. We do NOT force-enable Grok
# server-side search on turns where the user never had web enabled —
# that would silently route around Hermes's web-provider config and
# tool-trace/citation plumbing for every xai-oauth turn. The swap is
# a 1:1 replacement of an already-requested capability, not an
# additive grant.
#
# NOTE: for the swapped case this routes ``web_search`` to Grok's
# native search engine for xAI sessions instead of Hermes's
# configured web provider (Tavily/etc.), and those results bypass
# Hermes's tool-trace / citation plumbing (they arrive baked into the
# model's answer rather than as a tool result the loop observes).
# Scoped to ``is_xai_responses`` deliberately; narrow to specific
# models if a future grok variant should keep the client-side
# function.
if is_xai_responses and response_tools:
has_client_web_search = any(
isinstance(t, dict) and t.get("name") == "web_search"
for t in response_tools
)
if has_client_web_search:
filtered = [
t for t in response_tools
if not (isinstance(t, dict) and t.get("name") == "web_search")
]
filtered.append({"type": "web_search"})
response_tools = filtered

# ``tools`` MUST be omitted entirely when there are no functions to
# expose: the openai SDK's ``responses.stream()`` / ``responses.parse()``
# eagerly call ``_make_tools(tools)`` which does ``for tool in tools``
Expand Down
47 changes: 46 additions & 1 deletion apps/bootstrap-installer/src-tauri/src/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ async fn run_update(app: AppHandle) -> Result<()> {
emit_stage(&app, "rebuild", StageState::Running, None, None);
let started = Instant::now();
let rebuild_args: Vec<String> = vec!["desktop".into(), "--build-only".into()];
let rebuild = run_streamed(
let mut rebuild = run_streamed(
&app,
&hermes,
&rebuild_args,
Expand All @@ -295,6 +295,33 @@ async fn run_update(app: AppHandle) -> Result<()> {
Some("rebuild"),
)
.await?;

// Retry-once: the first `--build-only` can return nonzero on a still-settling
// post-update tree or a network-blocked Electron fetch that our self-heal
// repaired mid-run. A second attempt then builds clean off the healed dist
// (the content-hash stamp makes it a near-no-op when the first actually
// succeeded). Without this the updater bails here and never reaches the
// relaunch below — the app updates but doesn't restart. Matches the
// retry-once `hermes update` already does above, and `hermes update`'s own
// desktop rebuild in cmd_update.
if rebuild_needs_retry(rebuild.exit_code) {
emit_log(
&app,
Some("rebuild"),
LogStream::Stdout,
"[rebuild] first desktop rebuild failed; retrying once (a self-healed \
Electron download builds clean on the second run)…",
);
rebuild = run_streamed(
&app,
&hermes,
&rebuild_args,
&install_root,
&child_env,
Some("rebuild"),
)
.await?;
}
let rebuild_ms = started.elapsed().as_millis() as u64;

if rebuild.exit_code != Some(0) {
Expand Down Expand Up @@ -533,6 +560,14 @@ fn is_locked(path: &Path) -> bool {
}
}

/// Whether the `desktop --build-only` rebuild should be retried once. Any
/// non-success exit qualifies: the common cause is a transient first-attempt
/// failure (still-settling tree / self-healed Electron download) that a clean
/// second run resolves.
fn rebuild_needs_retry(exit_code: Option<i32>) -> bool {
exit_code != Some(0)
}

/// Spawn `hermes <args>` from `cwd`, stream stdout/stderr as Log events on the
/// bootstrap channel, and return the exit code. Mirrors powershell::run_script
/// but for an arbitrary command (no install.ps1 -File wrapping).
Expand Down Expand Up @@ -970,6 +1005,16 @@ mod tests {
assert_eq!(update_branch_from_args(["--update"]), None);
}

#[test]
fn rebuild_retries_only_on_failure() {
assert!(!rebuild_needs_retry(Some(0)), "a clean rebuild must not retry");
assert!(rebuild_needs_retry(Some(1)), "a failed rebuild retries once");
assert!(
rebuild_needs_retry(None),
"a killed/signalled rebuild (no exit code) retries once"
);
}

#[test]
fn parses_only_app_targets() {
assert_eq!(
Expand Down
Loading