ACP v0.9 has no per-session destroy, so the adapter's stdio shutdown is
the session end — but nothing ever wrote it: session rows created with
source="acp" kept ended_at NULL forever, the ended-session guard in
hermes_state_maintenance (prune/archive share it) could never select
them, and the desktop recents list accumulated one auto-titled row per
editor wake.
- SessionManager.end_all_sessions(): best-effort end_session("acp_disconnect")
for every live session; called from entry.py's finally so EOF, SIGINT and
a crash all stamp the rows.
- _restore() reopens a row ended by a previous adapter process before
resuming it — the same contract the TUI gateway's cold resume uses, so
load/resume across editor restarts keeps working.
- Desktop sidebar: 'acp' joins SIDEBAR_EXCLUDED_SOURCES (recents) and
LOCAL_SESSION_SOURCE_IDS (keeps it out of the messaging slice); the
conversations live in the editor, not the app's recents.
The prune/archive "open session(s) also match these filters" warning the
issue asks for already exists on main (count_open_prune_matches in
_cmd_prune_or_archive).
Conflict resolutions and semantic fixups:
- tools/environments/base.py: main's hard-exit kill fence (kill a spawn the
fence missed, deregister from _live_foreground in a finally) wrapped around
pm-clean's output collector.
- pyproject.toml: pm-clean's marker list plus main's new `live` marker.
- hermes_cli/main.py: pm-clean runs startup recovery from hermes_bootstrap, so
the old early-recovery block stays gone; main's interrupted-pull restore
(auto-merged above it) runs right after bootstrap, as on main.
- hermes_cli/update_cmd.py: main's interrupted-pull marker now guards
pm-clean's first tree mutation (release-tag detach, ff-only, or reconcile)
and is cleared once git is done. The marker's target is the ref git actually
moves to (a release tag, not always origin/<branch>), since the restore
compares against it.
- hermes_cli/_early_recovery.py: restore `import subprocess`, which pm-clean
had dropped and main's auto-merged restore needs (NameError on the first
launch after a killed update; test_update_interrupted_pull red -> green).
- apps/desktop/src/i18n/{de,es,fr}.ts: main's new locales carry the full
settings.about block; trim it to `updates` as pm-clean's type and the other
overlays do (tsc: 27 errors -> 0).
- main's new e2e tests: `import yaml` -> hermes_yaml; wake-word import table
names pyopen_wakeword (pm-clean's wake-openwakeword extra); the anthropic
key-leak switch leg needs the SDK, and the api_server two-tenant test needs
aiohttp, both PM runtime extras the test env does not carry.
On a custom branch the updater runs `git merge --no-edit origin/<branch>` inside the marker window.
Its files are the merge of both sides, a blob that is neither pre nor target, so the restore took
them for user edits: it put the upstream-only files back to pre, kept the merged ones and spent the
marker, leaving a mixed tree (a real random-kill of that merge: 104 of 500 trials broken).
- _early_recovery: when pre and target diverge, `git merge-tree --write-tree pre target` gives the
tree the merge was writing; its blobs (and prefixes of them, for a file cut short) count as git's
like the target's. Conflicted paths, and on git < 2.38 every path both sides changed, count as
git's whatever their content. Paths with a newline are hashed one by one (`--stdin-paths` is
newline-delimited). The docstring lists the by-design limits.
- run_agent (`hermes-agent`) and acp_adapter.entry (`hermes-acp`) never import hermes_cli.main, so
they now run the same restore right after hermes_bootstrap (run_agent only when hermes_cli.main is
not loaded, since it is also a library module).
- Tests: the second test kills inside a clean custom-branch merge (merged file, upstream-only file,
a cut-short file, a user edit); the first pins that each console script's entry module imports
no other checkout module before the restore runs. Both red on the previous head.
- evals/update_pipeline/interrupted_pull_ab.sh gains scenario E: a kill inside the custom-branch
merge, then the `hermes-agent` import.
Conflict resolutions and semantic fixups:
- utils.py / hermes_yaml.py: main widened ruamel's round-trip emitter so a long
double-quoted scalar is never folded after an escaped backslash. pm-clean builds
every rt emitter through hermes_yaml.roundtrip_yaml(), so the width lives there
(ROUNDTRIP_YAML_WIDTH moves with it); xai_retirement imports it from hermes_yaml.
- hermes_cli/banner.py: keep pm-clean's removal of the banner update check. Main's
GIT_NO_LAZY_FETCH fix for it applies to its replacement, source_check: every
read-only probe (source_git_env) now refuses promisor lazy fetches, and the
partial-clone test targets that probe (red without the flag).
- .github/workflows/tests.yml: keep setup-pm; main's uv pin bump does not apply.
Main's WAL-capable SQLite gates are kept, run against $HERMES_PYTHON (the
PM-pinned interpreter, SQLite 3.53.1). The e2e step takes main's
--include-integration invocation.
- apps/desktop: package.json has no build block here, so main's macOS locale-marker
restore joins the darwin branch of the existing after-pack.mjs, and its test
loads the hook from electron-builder.config.cjs and imports PlatformPackager
from app-builder-lib's root (electron-builder 27 exports no ./out paths). The
win32 row is dropped: this hook sanitizes and signs PE trees on win32 by design.
- reconciliation.ts: main's rowId hydration (#119326) was merged into the first of
pm-clean's split helpers only; the resolver is now one helper both halves use.
- en.ts: both sides' keys kept. tests/tools/test_lazy_deps.py stays deleted.
- Tests main added with `import yaml` use hermes_yaml, like the rest of the tree.
A fresh ACP agent appended mcp-<server> for every enabled config MCP server
unconditionally, so a platform_toolsets.acp allowlist of server names and the
no_mcp sentinel were ignored on ACP while the gateway honoured both.
The MCP half now comes from the same _get_platform_tools(config, "acp") call
as the base toolsets: its server names (default every enabled server, a listed
allowlist, or none for no_mcp) are keyed as mcp-<server>. Editor-provided
session/new servers are unchanged.
Docs: `hermes tools` has no ACP platform entry, so drop the claim that it
configures platform_toolsets.acp; document the MCP rules with a config example.
A fresh ACP agent hardcoded enabled_toolsets=["hermes-acp"], so
platform_toolsets.acp never narrowed the editor tool surface, unlike the
gateway, cron and api_server which all resolve via
hermes_cli.tools_config._get_platform_tools. Resolve the ACP base the same
way (ACP keeps appending its own mcp-<server> entries), and treat only None,
not an explicit empty list, as "use the hermes-acp default" in the /tools
and MCP-refresh rebuilds so a deny-all list cannot re-widen mid-session.
With the unconfigured default the resolved tool definitions are
byte-identical to the hermes-acp composite, so existing sessions keep the
same tool list and prompt cache.
The fresh-session assertion in test_make_agent_prefers_passed_toolsets_over_config_servers
now checks membership of the config MCP entry: the resolver returns the
expanded toolset keys rather than the bare composite name.
Refs #74582, #79516. Credit: #64045 (@israellot), #80309 (@thatssoheil),
#106834 (@nicolasramos) proposed the resolver routing.
Review follow-up: _cmd_tools rebuilt its listing without
state.agent.disabled_toolsets, so a config-disabled toolset was filtered
from execution but still advertised by /tools. Pass it through, matching
the session tool-surface rebuild.
New tests exercise the real get_tool_definitions (no patching) and assert
a disabled toolset is absent from both the /tools listing and the rebuilt
valid_tool_names — with a baseline assertion that the toolset is present
when nothing is disabled, so the check cannot pass vacuously.
The CLI (cli.py: CLI_CONFIG['agent'].get('disabled_toolsets')) and the
gateway (gateway/run.py) both read agent.disabled_toolsets from config
and pass it to AIAgent. The ACP adapter's _make_agent never did, so
state.agent.disabled_toolsets was always None and the ACP tool-registry
rebuild (acp_adapter/server.py -> get_tool_definitions) included every
tool in the enabled toolsets — a toolset the user disabled in config
(todo, browser, ...) remained fully executable in editor/ACP sessions.
Observed live: a 'todo' tool call executed from a profile whose config
lists todo in agent.disabled_toolsets.
Read agent.disabled_toolsets in _make_agent and pass it through,
mirroring the CLI and gateway paths.
Claude-Session: https://claude.ai/code/session_01YNvCUipheR7yx4VorUL2jW
Every entry point wrapped `import hermes_bootstrap` in
`except ModuleNotFoundError: pass` for a partial update that left the
bootstrap unregistered. It also swallowed a module the bootstrap itself
failed to import, and since the bootstrap now owns PM activation that
silently ran the tree on stale dependencies: exactly how a pre-PM
editable venv hid its unreachable `pm` until it crashed on ruamel.
Re-raise unless the missing module is hermes_bootstrap, at all six entry
points. The stale "only Windows UTF-8 stdio suffers" comments go with it.
Runtime identity resolved through hermes_cli.__version__ (a static 0.0.0
on source installs, rewritten by release stamping) leaked v0.0.0 into
About, /api/health, User-Agents, and plugin compat, and source updates
showed "couldn't reach update server" because identity and channel
authority disagreed with the checkout.
Now: get_version_info() resolves install stamp -> live git -> unknown,
never pyproject metadata, never a package constant. Source checkouts
derive identity from their reachable release tag; the completion tail of
every successful install/update/historical takeover atomically rewrites
install-stamp.json with that identity; a stale source stamp whose commit
no longer matches HEAD defers to live git. ACP/TUI use derived_version
for display and base_version for protocol fields; all ~44 runtime
__version__ consumers migrated; hermes_cli.__version__ and generated
_version.py are gone; release stamping only touches the native manifests
external builders consume (nix/tauri/cargo) and passes release identity
straight into write_install_stamp.py; pyproject.toml stays inert 0.0.0.
Desktop no longer synthesizes a competing install-stamp.json: the
checkout owns its stamp, and desktop-bootstrap classification keys on
the bootstrap-complete marker. verify-bootstrap-version-stamp.py now
cross-checks the checkout's stamp (baseVersion + commit == HEAD).
Validation: 31-file focused suite green (version identity, stamping,
adoption, providers, gateway, acp/tui runtime identity, api server via
extras env, release graph); desktop tsc + 25 vitest green; real-repo
probe: base=unknown derived=git.0635606.dirty source=git on this
checkout; clean-env imports resolve entirely from this tree; windows
footgun + compat-pointer scans clean.
* fix(mcp): ACP, `hermes tools` and the desktop connector card use the one enabled reader
#119567 left four readers of `mcp_servers.<name>.enabled` on their own rules.
@webtecnica's #119560 found the ACP one:
- `acp_adapter/session.py::_make_agent` used `is not False`, so an ACP
session kept a server with `enabled: "false"` or `enabled: 0` in its
toolset while the MCP client skipped it.
- `hermes tools` MCP picker (`tools_config_mcp._configure_mcp_tools_interactive`)
read `enabled: 0` as on and crashed on a non-dict entry.
- Desktop connector card (`connectors/data/join.ts`) and the MCP health sweep
(`store/mcp-health.ts`) used `enabled !== false`.
All four now call `mcp_server_enabled` (Python) or `serverEnabled` (renderer),
which share one case table.
Co-authored-by: webtecnica <webtecnica@gmail.com>
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
---------
Co-authored-by: webtecnica <webtecnica@gmail.com>
`hermes` (hermes_cli.main) and the TUI gateway call install_truststore()
at startup; the two other console scripts did not, so bare requests /
urllib calls made before the first model client (which installs it
lazily) verified against OpenSSL's compiled-in paths instead of the OS
store — a corporate root would fail there and nowhere else.
Branch semantics kept where main and PM disagree: update_cmd_deps.py,
constraints-termux.txt, the Electron update-api-check module and the
post-swap hand-off test stay deleted; the pending-fleet-restart catch-up
and the local_runtime tag/download ladder stay retired (PM owns engines).
Ported from main onto the branch's shape: profile_scoped_chore for the
auto-archive and plugin-update housekeeping chores, the local-runtime
cross-process boot lock and residency cap, the checkpoint tmp_pack sweep,
the cua daemon-liveness status probe, the remote-served Desktop update
flag (posix.sh / windows.ps1), sign-in for env-pinned remote gateways
(urlDisabled on RemoteSetupFields), the uvloop extra split (uvicorn
without [standard]), and the umask-scoping spawn test.
uv.lock regenerated with pm.build_env --lock-only; new utf-8 reads from
main switched to utf-8-sig (check-windows-footguns).
set_session_model's `finally` awaited _drain_queued_prompts, so a prompt queued
during a failed (or slow) switch_model ran a whole agent turn inside the
session/set_model request; the client received the RequestError only after
that turn ended, and the drained turn streamed with no open prompt request.
Schedule the drain via _schedule_soon so it runs right after the response is
queued, on both the success and the error path.
prompt() dispatches slash commands on a worker thread before claiming the
turn, so /reset and /compress could clear or rebind state.history (and null
agent._session_db) underneath a live run_conversation. /model and
session/set_model could likewise swap state.agent mid-turn, which also made
_finish_turn emit a spurious compression-rotation update.
Mutating commands now hold a per-session command_op flag for their whole
run: they are rejected while a turn or another op is in flight, prompts
arriving mid-op queue instead of claiming the turn, and the queue drains
through the same helper _finish_turn uses. Gateway parity: these commands
are idle-only there.
backfill_acp_session_cwd had no production caller, so rows minted before
the column was written stayed unassigned in Desktop until someone ran it by
hand. The manager now runs the idempotent UPDATE once per process on first
DB use (injected or acquired), with a test through create_session().
Also maps the contributor email for attribution.
ACP sessions stored their workspace only inside the model_config JSON blob
(a correct choice when the cwd column did not exist yet), so Desktop, the
Projects sidebar and hermes sessions list showed every editor session as
unassigned. create_session now passes cwd, an existing row (the live path,
since the agent flushes the transcript incrementally) gets the column
promoted via update_session_cwd, update_cwd() moves it on reopen, and git
branch/root are probed off the interactive path under the same generation
contract tui_gateway/session_workdir.py uses. backfill_acp_session_cwd
promotes model_config.cwd for rows minted before this change.
Squashed from the five commits of #115707; the accidentally committed
Windows cache files under %SystemDrive% are dropped.
Multi-file V4A proposals set EditProposal.path to a comma-joined display
string, and should_auto_approve_edit evaluated it as one path: the
sensitive-name check saw only the last segment and the workspace check
resolved the joined string under the session cwd. A patch touching .env
plus a normal file auto-approved under 'session', and one carrying an
absolute path outside the workspace auto-approved under
'workspace_session' — both without the interactive prompt.
EditProposal now carries a paths tuple with every real target; the
sensitive check runs any() and the workspace check all() across them,
falling back to (path,) for single-file proposals. The joined string
remains for display only.
Fixes#115213
_finish_turn persisted, emitted provenance/final text, then released
is_running/current_prompt_text with a bare block before draining queued
prompts. Any exception mid-tail skipped the release, wedging the session
running and stranding the queue. Indent the tail into try; the finally
releases is_running/current_prompt_text first, then drains the queue.
Regression test raises on the final-text session_update and asserts the
session goes idle and the queued prompt still drains. (#115588)
Resolved toward the branch: PM provisions uv/python (main's install.ps1 uv-shim
salvage + its test and workflow steps dropped), the shim re-exec stays retired,
package.json carries no electron-builder block (afterExtract identity stamp wired
into electron-builder.config.cjs instead; after-pack.mjs keeps signing only),
Desktop workspace-deps helpers stay retired. Main's scratch-dir bootstrap
(export_scratch_tmp_env) is taken and re-run after profile resolution.
Hermes now routes scratch space through HERMES_HOME/cache/scratch (exported as
TMPDIR), so every production path that still spelled out /tmp bypassed that and
kept teaching the agent the habit. Fallbacks in tool_result_storage,
code_execution_tool, process_registry, the ACP child HOME, mini_swe_runner's
local cwd, and the CI/profiling scripts now use tempfile.gettempdir(); shell
installers fall back to $TMPDIR (then HERMES_HOME) when mktemp is missing, and
repro/eval shells use `mktemp -d -t`. User-facing help text and sample payloads
(hermes send, approvals test, hooks test, voice-mode WSL hints, meet_bot debug
line) no longer suggest /tmp.
Container-side paths (mini_swe_runner docker cwd, sandbox base env, remote
sync tarballs) keep the literal because they name the sandbox filesystem,
not the host.
resolve_runtime_provider() selects a provider-scoped credential pool and returns
it as runtime["credential_pool"]; oneshot and the gateway hand it to AIAgent, but
acp_adapter/session.py::_make_agent dropped it, so a long-lived ACP process had
_credential_pool=None and could not refresh/rotate on HTTP 401 after OAuth
token expiry — the only recovery was restarting the ACP process (#70292).
Forward the pool by identity like the other surfaces. The pool is already
provider-scoped and its selected entry matches the agent's initial api_key, so
the existing account-isolation guards are preserved rather than bypassed.
Salvaged from PR #70293 (the cherry-pick claimed in #77029 never reached
acp_adapter/session.py); regression test asserts the pool object is retained.
Fixes#70292
`SessionManager._make_agent` and the Feishu doc-comment agent built their
`AIAgent` without `reasoning_config`, so `agent.reasoning_effort: none`
never reached those sessions: the transport applied its default effort,
which non-reasoning models such as gpt-4o-mini reject with HTTP 400 and
which silently re-enables thinking everywhere else. Both surfaces now go
through `hermes_constants.resolve_reasoning_config`, the same chokepoint
the CLI, gateway, TUI, cron and `hermes -p` already use, resolved against
the model the session actually runs so per-model overrides apply.
Ported from PR #85164 by @Chinmayrawat15 (oneshot hunk already on main).
Fixes#85153
The warm-up imported only the provider module. holographic / mnemosyne import
numpy at module top, but hindsight defers the ML stack to is_available() ->
_check_local_runtime() (importlib of hindsight / sentence_transformers), which
ran later on a to_thread worker racing acp-mcp-discovery — the reported hindsight
stack was still reachable. The deadlock partner is numpy's lazy _core init in
every reporter's dump, and a plain `import numpy` up front was every reporter's
workaround, so import_memory_provider_module now also imports numpy (best-effort)
once the provider module is in.
Also: import_memory_provider_module() defaults to the configured memory.provider,
so entry.py drops its duplicate config resolver and outer try; the "ONLY thread"
comment is reworded — hermes_cli's plugin-discovery thread is already running
when hermes acp dispatches.
prompt / cancel / set_session_model / set_session_mode / set_config_option still
called session_manager.get_session inline. For an id not in memory that runs
_restore -> _make_agent (config, memory-provider import, SessionDB) on the loop —
the hang class session/new just left — and, since restores are single-flight, it
also parks the loop on _restore_lock while an off-loop session/load is in flight.
Route the five sites through asyncio.to_thread like new/load/resume/fork.
Test: a parametrized invariant over the five handlers with a slow DB restore;
ticks=0 on the previous head, green now.
Every faulthandler dump in the thread shows session/new stuck in numpy's
create_module on the main thread while another thread (MCP discovery / ACP
stdin reader) sits in the same lazy import chain — a first-time native
extension import racing another thread deadlocks on Windows (holographic,
mnemosyne and hindsight all reproduce it; a sitecustomize `import numpy`
before any thread exists resolves it every time).
hermes acp now imports the configured memory.provider's module on the main
thread before the MCP-discovery thread and asyncio.run() start (Windows only —
the deadlock is Windows-specific and the import is paid once either way).
plugins.memory.import_memory_provider_module imports the module without
constructing a provider or running register(); the agent build later finds it
in sys.modules.
Trimmed from #91775 (@tigercraft4): same placement and gating; reuses the
existing plugin loader instead of a second module-import routine.
Co-authored-by: tigercraft4 <tigercraft4@tigercraft4.com>
session/new, session/load, session/resume and session/fork constructed a full
AIAgent (config load, memory-provider import, SessionDB) inline in the request
coroutine, freezing the loop that serves every JSON-RPC request — a host saw an
agent that answered initialize and then nothing, with no error anywhere.
Run the construction through asyncio.to_thread. Because restores can now
overlap, SessionManager.get_session serializes the DB-restore path under a
lock and re-checks the in-memory map, so two session/load for one id share one
agent build.
Slimmer redo of #85001 (@SHL0MS): same direction, without the async wrapper
layer and future map — the lock + re-check gives the same single-flight.
Co-authored-by: SHL0MS <SHL0MS@users.noreply.github.com>
The ACP permission and edit-approval bridges self-denied after a fixed 60 s
while the editor's approval card was still waiting (raised on #73403 by an ACP
host maintainer). Default the bridges' timeout to the existing approvals.timeout
config knob (300 s, same resolver as CLI/gateway prompts), read per request.
The `except ValueError` in set_session_model wrapped both the switch_model rejection and
the _make_agent rebuild, so a rebuild ValueError (provider disabled in config, context
window below the floor) was reported as -32602 by accident. _switch_model now raises a
dedicated ModelRejected(ValueError) at the rejection site and set_session_model catches
only that; rebuild ValueErrors keep the -32603 internal-error path. The slash /model path
still sees the rejection text via str(exc).
set_session_model already validates through hermes_cli.model_switch.switch_model
(11576390fe), so an unadvertised modelId is refused before the session mutates
(#72439's main atom). The rejection surfaced as JSON-RPC -32603 "Internal error"
though, which clients attribute to the agent rather than to the request; it is now
RequestError.invalid_params (-32602) carrying the switch_model reason. _switch_model
also assigned state.model before the rebuild, so an agent-build failure left the
session persisted on a model the live agent did not run; the assignment now follows
the successful build.
_make_agent swallowed a resolve_runtime_provider failure at debug and built a bare
AIAgent, which dies with the first-run "No LLM provider configured. Run `hermes
setup`" text on a configured machine (#91090's residual ask). The fallback stays, but
when the bare build fails the swallowed resolution error (revoked OAuth, disabled
provider, ...) is raised instead, chained to the fallback failure.
Direction credited to @z0zero (#72579: -32602 + atomic session state) and
@webtecnica (#91100: do not swallow the resolution failure).
Co-authored-by: z0zero <z0zero@users.noreply.github.com>
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
A turn's hard interrupt fans out only to `_active_children`; background
delegate_task units are detached from the parent at dispatch
(`_dispatch_background` / honor_parent_interrupt=False), so /stop left them
running to completion and their result arrived minutes later as a parked
wake. Every stop surface now also calls
`tools.async_delegation.interrupt_for_session` for the session's units:
- gateway: `_interrupt_and_clear_session` (busy /stop, /new share it — /new
already did this in `_handle_reset_command`, the second request is
idempotent) and the idle `_handle_stop_command` tail, which replied
"No active task to stop." while a background child was running; it now
stops them and replies "Stopped".
- tui_gateway `session.interrupt` (Desktop Stop / TUI stop) — own UI sid +
spawner id only, so a viewer tab never kills gateway work.
- acp_adapter `cancel`.
- CLI `/stop` already used `interrupt_all` (process-wide); unchanged.
The stop recurses: depth>0 delegations are always synchronous
(`_model_background_value`), so the child's hard interrupt reaches its
workers through its own `_active_children` fan-out, and each level's
interrupted partial result rolls up as that child's completion.
An interrupted child's entry now carries what it actually had: the loop's
`final_response` is the "Operation interrupted." placeholder (also appended
as the closing assistant row), so `_build_result_entry` takes the child's
last real assistant text as `summary` and keeps the placeholder as `error`.
The unit finalizes normally and re-enters at once as its completion notice
(status=interrupted, "Partial output: ...", "Subagent Task Interrupted" on
TUI/Desktop) instead of the chat waiting for the child's budget to run out.
Docs: delegate_task description, tools/AGENTS.md, delegation.md,
gateway-session-lifecycle.md.
Part of #114456
The closer in await_permission() only recognised an allow when the response
outcome was the SDK AllowedOutcome class, while the edit-approval requester
duck-types (outcome == "selected"). A client answering with a plain selected
outcome therefore had its edit applied but the edit-approval-N bubble closed
"failed". Duck-type the closer on the wire discriminator so its terminal
status always matches the decision. Live-pass side-effect on this PR.
request_permission carries a synthetic perm-check-N / edit-approval-N
ToolCallUpdate in status pending; clients materialise it as its own
bubble and nothing ever moved it on, so it spun forever. await_permission
now takes an optional send_update and, once the outcome is known, closes
that id: completed when an allow option was selected, failed on deny,
timeout or a failed request. The server wires it for both the command
approval callback and the edit approval requester.
tool.completed carries is_error, but the ACP bridge dropped it and
re-derived status from the result text alone, so a tool cancelled by a
user interrupt (plain-text result) or one returning an error dict closed
as completed. Pass the flag through close_tool_call and OR it into
build_tool_complete's failed predicate; the text heuristic stays as the
fallback for the step-closer path.
The turn-end sweep ran on the loop thread, where _send_update's
future.result(timeout=5) stalls the loop for 5s per open call and the
terminal update only lands after the response. Flush in the executor
body's finally instead, which also covers the executor-exception path
with one call site. Test drives prompt() with an open tool.started on
both paths: red on origin/main (no close) and on the previous head
(loop stalled), green now.
Follow-up to the salvaged #114442 (@hteo1337), which closes each ACP tool call
from its own ``tool.completed`` (the mechanism PR #50741 by @liuhao1024 filed in
June, and PR #27854 by @godlin-gh filed first in May via ``tool_complete_callback``) and fails whatever is still open at turn end, draining
``tool_call_ids`` and ``tool_call_meta`` together.
Live repro of the fallback path exposed a third mechanism the issue did not
name: ``make_step_cb`` passed ``prev_tools[i]["arguments"]`` — the wire JSON
*string* — as ``function_args`` into ``build_tool_complete``, whose content
builders index it as a dict. The ``.get`` on a str raised inside the swallowed
step callback, so a ``write_file`` close never reached the client even when a
next step existed. Coerce with ``coerce_tool_args`` (meta args when absent).
Tests: the contributor's six tests folded into two invariants — a call is
closed exactly once from ``tool.completed`` with the step closer standing down;
the step fallback survives JSON-string arguments and the turn-end flush fails
the remaining call while emptying BOTH per-turn dicts.
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: godlin <ganlinbupt@gmail.com>
The step callback only fires on the next step, so a turn's last tool calls stayed in_progress forever, and a blocked or permission-denied call projects no tool.completed at all. Close each call from its own tool.completed event, stand the step-callback fallback down once completions arrive, and fail whatever is still open when the turn ends.
Terminal-failure paths (HTTP-200 content-policy refusal, ``_Trunc.end_turn``, retry
exhaustion, interrupt before any assistant text) persist the accepted user row and return
before ``finalize_turn``, so ``user`` stays the durable conversation tail. The next prompt
appends a second user row, ``repair_message_sequence`` merges the pair, and the provider
is asked to act on the failed request again. The gateway compensates with
``_hmwa_close_failed_turn`` (#108033); standalone ACP, the CLI and the TUI/Desktop hand
``result["messages"]`` straight back as history and had no closer.
Close it once, at ``agent/conversation_loop.py::run_conversation`` — the seam every
envelope leaves through — with a Hermes-authored assistant boundary
(``agent/turn_failure_copy.py::FAILED_TURN_NOTICE`` / ``PARTIAL_FAILED_TURN_NOTICE``,
which the gateway now aliases instead of keeping its own copy). Idempotence is keyed on
``SessionDB.latest_conversation_role`` (durable state, not content), so a redelivery or a
tail another writer already closed is a no-op and the gateway's closer no-ops in turn.
The context-pressure classes (``compression_exhausted``, ``compression_deferred``,
``failure_reason == "context_overflow"``) are excluded: appending to an oversized session
is the #1630 growth loop; their repair is rotation.
Adjacent defect from the same report: ``acp_adapter/server.py::_finish_turn`` called
``final_response.startswith`` on ``None`` for an interrupted turn — the same one-line fix
PR #64471 by @israellot filed first (its wider prompt()-restructure is superseded by the
current ``_finish_turn`` shape).
Slimmer redo of #114168 by @kendrickkester (same seam and invariants; the +1023-line
PR carried a new copy module, an accepted-turn re-anchoring scan and an 859-line suite).
Two invariant tests: the real ACP path (loopback provider, refusal then a new prompt) and
the durable-tail idempotence / overflow exclusion.
Co-authored-by: Kendrick Kester <kendrick.kester@gmail.com>
Co-authored-by: Israel Lot <israel.lot@gmail.com>
Review follow-up: asyncio.to_thread already runs its callable inside
contextvars.copy_context(), so wrapping _register_pinned in a second copy was a
no-op. The cwd pin set inside the worker still does not leak back to the caller
(tests/acp_adapter/test_server.py pin assertion unchanged, still green).
The default stdio child cwd now reads agent.runtime_cwd.resolve_context_cwd()
at spawn time (previous commit), but ACP new_session/load_session register the
client's MCP servers outside the per-turn cwd pin, so a hosted ACP session with
logical cwd /workspace/a — the reporter's exact scenario — still spawned its
stdio servers in the hermes-acp process directory. Run register_mcp_servers in
a copied context with set_session_cwd(state.cwd); run_coroutine_threadsafe
carries that context onto the MCP loop task and the long-lived server task
copies it, so reconnects respawn in the same directory.
Also: trim the salvaged tests to the two invariants (session pin becomes the
default; explicit config cwd wins) — the TERMINAL_CWD fallback and the
missing-directory→None cases are runtime_cwd's own contract, pinned in
tests/agent/test_runtime_cwd.py, and the native default (no anchor → None) is
already pinned by test_start_preserves_native_default_cwd. Document the `cwd`
key and its default in the MCP docs.
Shared-process caveat recorded in the PR body: MCP connections are per
process/registry scope, not per chat session, so the anchor is read once at
connect time (profile-level terminal.cwd for gateway/cron; the owning session
for ACP-provided servers).
Reconcile plugin declarations and validation through PM's atomic generation publication; preserve external runtimes, target markers, and conflict refusal. Keep one source-update completion owner and port upstream lifecycle changes to the PM desktop/runtime paths.
Closes the remaining atoms of #112600.
A) The CLI startup path already passes `-m` as target_model (c358a6fba0), but
its siblings still resolved credentials against config's `default`: the CLI
auth-fallback rung, `--resume` credential re-resolution, the gateway
provider-override helper (channel overrides, persisted /model switches,
API-server provider refresh), the gateway fallback chain, the TUI /model
switch-from runtime and ACP agent construction. With a `*-free` default the
OpenCode free-tier rung fired first and a Go-only model was built against
the keyless Zen relay ("Model mimo-v2.5 is not supported"). Each now passes
the effective model; `_resolve_runtime_agent_kwargs_for_provider` grows an
optional `target_model` and the two test stubs of it accept the kwarg.
B) normalize_opencode_base_url rewrote the /zen vs /zen/go segment for ANY
provider matched by opencode_provider_family, including custom providers
merely named after a family (`opencode-go-bridge`, #85589) whose relay the
user declared explicitly in `providers:`. The family heal now applies to the
built-in canonical providers only; custom prefix-named providers keep their
per-model api_mode routing and /v1 handling. Documented in the providers
guide.
C) Same function: the official-host check uses parsed.hostname (a port no
longer defeats the heal) and only the path is edited, so query/fragment
round-trip instead of being dropped.
Fixes#112600
Salvage of #93452 (@outpoints). Keep one invariant per fix:
resolver-level "automatic title never remaps a strategy session",
integration "provider routes by logical workspace, not process cwd",
agent-level "title provenance + cwd reach the provider", deferred
Desktop/TUI build threads the session cwd, seeded branch titles are
derived, and the workspace-move E2E. Drop the plumbing/legacy-shape
tests that re-assert the same contract.
acp_adapter: AIAgent(cwd=...) now stamps session_cwd itself, so the
direct assignment after construction was a duplicate.