Skip to content

feat: add Devin ACP provider support - #2

Closed
nkowne63 wants to merge 728 commits into
mainfrom
codex/devin-acp
Closed

feat: add Devin ACP provider support#2
nkowne63 wants to merge 728 commits into
mainfrom
codex/devin-acp

Conversation

@nkowne63

Copy link
Copy Markdown
Owner

Adds Devin ACP provider support and wires it through auth/runtime/streaming paths.

Verification:

  • uv run pytest tests/hermes_cli/test_api_key_providers.py -q
  • uv run hermes chat -q 'Reply with exactly OK and nothing else.' --provider devin-acp --ignore-user-config --source tool -Q

The second check returned: OK

austinpickett and others added 30 commits June 11, 2026 15:39
…4383)

* fix(discord): recover from runtime gateway task exits

Salvaged from NousResearch#39416 (AMEOBIUS) — cherry-picked only the task-exit
recovery; the original PR was 1081 commits behind with 28 unrelated
commits.

A post-ready discord.py WebSocket crash left the gateway split-brained:
producers stayed active while Discord stopped responding. After this fix
the adapter calls _set_fatal_error(retryable=True) + _notify_fatal_error()
so the existing GatewayRunner reconnect watcher replaces the dead adapter.

Also adds _wait_for_ready_or_bot_exit() so startup failures (SOCKS/proxy
errors, invalid tokens) surface fast instead of burning the full ready
timeout. Because connect() no longer waits via asyncio.wait_for on that
path, test_connect_releases_token_lock_on_timeout is updated to trigger
the timeout through the new helper (same lock-release contract).

3 tests pass (2 new runtime-failure tests + the updated timeout test);
test_discord_connect.py and test_discord_slash_commands.py green.

Co-Authored-By: ameobius <ameobius@local.host>

* fix(test): patch _wait_for_ready_or_bot_exit in timeout cancel test

connect() no longer uses asyncio.wait_for for the ready handshake, so
test_connect_timeout_cancels_bot_task was hanging for 30s in CI.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: ameobius <ameobius@local.host>
Co-authored-by: Cursor <cursoragent@cursor.com>
…ry doesn't

web_search/web_extract are documented to work with zero setup via the bundled
keyless Parallel free-MCP backend, but that only holds when the bundled
plugins/web/* providers are registered. The dispatch relied entirely on the
general plugin sweep to do that; when the sweep finishes without registering
them (its exception swallowed as a warning, a packaged layout where it ran
before the bundled tree was importable, or a stale empty-discovery cache), the
registry is empty and BOTH tools dead-end on "No web {search,extract} provider
configured" — despite needing no setup at all.

_ensure_web_plugins_loaded now verifies the keyless default landed after the
sweep and, if not, registers the bundled web providers directly against the
registry. Idempotent, a no-op on the healthy path (one dict lookup), and honors
an explicit plugins.disabled entry.
Pins the invariant that _ensure_web_plugins_loaded registers the keyless
Parallel default (and the wider bundled set) even when the general plugin
discovery raises, that the direct-registration fallback honors plugins.disabled,
and that it stays a no-op on the healthy path.
Root-cause hardening for the stranded-empty-registry failure behind
'No web search/extract provider configured': discover_and_load() set
_discovered=True before scanning, so a sweep that raised partway was
swallowed by callers as a warning and every later call early-returned
against an empty registry for the process lifetime. The flag now acts
only as a re-entrancy guard and is reset when the sweep raises, so the
next call retries discovery.
…ousResearch#44453)

A WSL2 user reported the top two left-sidebar items being unclickable
while the rest of the UI works. That symptom shape matches an
-webkit-app-region:drag hit-test band eating clicks, not GPU/compositing:
the shell's titlebar drag strips (app-shell.tsx) span the top 34px and
the nav group clears them by only 6px, and drag regions win hit-testing
over DOM regardless of pointer-events. Linux WCO (Electron >=32) is the
newest implementation and has known region quirks (electron#43030).

Apply the same no-drag carve-out the codebase already uses for sticky
user bubbles (USER_BUBBLE_BASE_CLASS in thread.tsx) to the sidebar nav
buttons. Harmless on every platform: the rows were never meant to be
draggable surface.
The prompt consolidation above retires the carveout-era prefix. Without a
frozen copy in _HISTORICAL_SUMMARY_PREFIXES, summaries persisted by
pre-upgrade builds would lose detection (_is_context_summary_content) and
renormalization (_strip_summary_prefix) — the exact regression class the
tuple exists to prevent. Adds contract tests covering every frozen prefix.

Refs NousResearch#41607 NousResearch#38364 NousResearch#42812
…erences

Legibility pass on the consolidated prefix: collapse the topic-overlap rule
from three overlapping sentences into one WINS sentence + one discard/no-wrap-up
sentence (same constraints, less dilution), fix the module docstring to
describe the headings that actually shipped, and correct the NousResearch#10896 comment's
heading name (Historical Pending User Asks).
…44470)

* docs: finish Automation Blueprints terminology rebrand

Replace leftover "Automation Templates" wording from the Cron Recipes
rebrand, rename the copy-paste cookbook guide to Automation Recipes, and
point the marketing gallery link at the blueprints catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: use Automation Blueprints instead of Recipes in guide

Rename the cookbook guide from automation-recipes to
automation-blueprints so sidebar and copy match the product term.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: rename automation-blueprints-catalog to automation-blueprints

Drop the -catalog suffix from the reference page slug and title, and
move the copy-paste cookbook to automation-blueprint-examples so the
main Automation Blueprints doc is unambiguous.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Revert "docs: rename automation-blueprints-catalog to automation-blueprints"

This reverts commit 605f1ee.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
…ousResearch#44500)

The deploy-site skills index crawl was capped at ~3k ClawHub entries
because CATALOG_WALK_BUDGET_SECONDS applied to max_items=0 walks too.
Only enforce the wall-clock budget for bounded browse requests and pass
limit=0 from build_skills_index so CI walks the full catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
…Research#44512)

The desktop chat surface talks to the dashboard's in-process /api/ws
gateway, which builds agents through tui_gateway.server._make_agent. That
path only snapshots the existing tool registry — MCP discovery is started
by tui_gateway/entry.py (the stdio TUI), which the dashboard process never
runs. So a profile's configured MCP servers never connect under the
desktop app and sessions show no MCP tools.

Start a shared background MCP discovery thread at dashboard startup (via
hermes_cli.mcp_startup, bounded so a slow/dead server can't block boot),
and have _make_agent briefly join that thread in addition to the existing
entry-owned TUI thread before snapshotting tools.

Carved out of NousResearch#44478.

Co-authored-by: AJ <yspdev@gmail.com>
…search#44510)

Desktop spawns its dashboard backend with `--profile <name>` and
`HERMES_DESKTOP=1`. cmd_dashboard's unified-launch routing treats any
named profile as a request for the shared machine dashboard: it re-execs
as the default profile (dropping HERMES_HOME) or, when one is already
listening, prints "Machine dashboard already running ... Managing profile
'<name>'" and exits 0. Either way the desktop-spawned child exits before
the app sees a ready backend, so Desktop retries forever — the Windows
named-profile boot loop in the post-mortem.

Skip the machine-dashboard reroute when HERMES_DESKTOP=1 so desktop pool
backends stay per-profile (which is what the pool expects). Carved out of
NousResearch#44478.

Co-authored-by: AJ <yspdev@gmail.com>
…ousResearch#44493)

`@assistant-ui/store`'s index-keyed child-scope lookup (`tapClientLookup`)
throws — rather than returning undefined — when a subscriber reads an index
the message/parts list no longer has. During high-frequency store replacement
(switching sessions mid-stream, gateway reconnect replay) a subscriber from
the previous, longer list is still in React's notification queue and reads one
slot past the new, shorter array before it can unmount. The throw
(`Index N out of bounds (length: N)`, the classic index === length off-by-one)
unwinds all the way to the root error boundary and blanks the entire window,
even though the store self-heals on the very next consistent snapshot.

Wrap each virtualized message group in a tiny boundary that swallows ONLY this
transient lookup race and auto-recovers when the message signature changes
(the existing list-mutation key). Any other error re-throws to the root
boundary, so genuine bugs still surface.

Upstream-tracked and unresolved: assistant-ui/assistant-ui#4051, NousResearch#3652.

Co-authored-by: mollusk <mollusk@users.noreply.github.com>
(cherry picked from commit f8209f9)
(cherry picked from commit 72290f0)
The served-token fallback adopts whatever token the dashboard HTML
injects. That is correct when our own child regenerated the token (env
pin lost across a shell-wrapped spawn), but wrong when the readiness
probe answered from a process we did not spawn: /api/status is public,
so an orphaned dashboard squatting the port passes waitForHermes while
our child dies on the bind conflict. Silently adopting that process's
token would authenticate the renderer against a foreign backend,
possibly on the wrong profile.

Discriminate on child liveness: the desktop pins
HERMES_DASHBOARD_SESSION_TOKEN on every spawn, so a live child always
serves our token. Served-token mismatch + dead child = foreign backend;
fail the boot loudly instead of connecting. Mismatch + live child keeps
the adopt-served-token salvage from NousResearch#43720.
…collision

Two fixes to the Electron desktop launch path, with the port-reservation logic extracted into a unit-tested module:

1. hermes:bootstrap:reset ("Reload and retry") only cleared connectionPromise, leaving the live backend alive; the orphan kept binding PORT_FLOOR (9120) so the next startHermes() hit EADDRINUSE / "Object has been destroyed" and the window looped. Await teardownPrimaryBackendAndWait() so the reset stops the old backend before restarting.

2. pickPort() probes-then-closes a socket before the real bind happens in a separate Python child, so two concurrent spawns (primary + pool backend) could both be handed PORT_FLOOR and one died with EADDRINUSE. The reservation bookkeeping is extracted into electron/port-pool.cjs (PortPool): pickPort() reserves the chosen port until the child exits and releases it on every exit/error/throw-before-spawn path, closing the TOCTOU window.

PortPool is dependency-injected (probe passed in) and socket-free, unit-tested in electron/port-pool.test.cjs (8 cases) and wired into the test:desktop:platforms script.

(cherry picked from commit d413394)
When a tirith content-security warning is present the approval backend
forces allow_permanent=False and silently downgrades an "always" choice to
session scope (the persistence loop in check_all_command_guards only honors
"always" → permanent when no tirith finding exists). But the gateway notify
payload that drives the TUI and the Electron desktop app never carried that
flag, so both surfaces always rendered "Always allow" — offering a permanent
allow the backend would quietly refuse to persist.

Plumb allow_permanent end-to-end:
- tools/approval.py: include `allow_permanent: not has_tirith` in the gateway
  approval_data the notify callback emits as `approval.request`.
- ui-tui: thread `allowPermanent` through the event handler, gateway types,
  and ApprovalReq; ApprovalPrompt drops the "always" option (and renumbers the
  quick-pick keys) when it's false.
- apps/desktop: thread `allow_permanent` through the gateway payload type, the
  per-session approval store, and the inline ApprovalBar, which now hides the
  "Always allow…" dropdown item when permanent allow is disallowed — reusing
  the existing DropdownMenu / confirm-Dialog UI.

The desktop/TUI render path for approvals already landed in NousResearch#38578 (the root
cause of approvals not surfacing in the GUI); this completes the salvage of
NousResearch#37856 by carrying allow_permanent across both surfaces. NousResearch#37856's original
thread-local _block() approach is dropped: desktop/TUI approvals resolve via
approval.respond → resolve_gateway_approval (the per-session queue), not the
_block()/request_id correlation, so a worker-thread callback waiting on _block
would never be released by the real UI.

Tests: gateway notify payload carries allow_permanent (True without tirith,
False with a tirith warning); ui-tui approvalAction reduced option set +
event-handler allowPermanent propagation; desktop store round-trip + the
ApprovalBar showing/hiding "Always allow".

Supersedes NousResearch#37856
Closes NousResearch#37812

Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
…al into one helper

Both spawn paths (startHermes, spawnPoolBackend) duplicated the same
resolve -> log-fallback -> foreign-check -> throw dance. Collapse it into
adoptServedDashboardToken(baseUrl, spawnToken, {childAlive, label}) in
dashboard-token.cjs; childAlive is a thunk so liveness is sampled after
the fetch. Drop the redundant backendPool.delete in the pool's throw
path (the child exit/error handlers already own pool eviction).

Validated end-to-end against a real web_server.py backend, not just
units: token-injection regex vs the actual served index.html, foreign
refusal (dead child + live squatter), benign drift adoption, and the
401-vs-200 token auth split on /api/sessions.
Node >=18 / Electron 40 ship fetch; the hand-rolled http/https.request
plumbing buys nothing. AbortSignal.timeout replaces the socket timeout,
protocol guard and >=400 rejection semantics preserved. 13/13 unit
tests and the live web_server.py repro both green over the new
transport.
… opt set

Collapse the verbose multi-line rationale comments across the TUI/desktop/
backend approval surfaces into single-line "why" notes, and derive
APPROVAL_OPTS_NO_ALWAYS from APPROVAL_OPTS instead of re-listing it.
No behavior change.
…44528)

Cherry-picked from NousResearch#39840 by @flyinhigh and rebased cleanly on main.

- Defer config fetch in createGatewayEventHandler until gateway.ready to
  avoid render-phase RPC that can mutate transcript state and trigger
  React error 301 in embedded dashboard PTYs.
- Use undici WebSocket fallback when globalThis.WebSocket is unavailable
  (Node attach mode and sidecar mirror sockets).
- Add regression tests for both fixes.

Co-authored-by: flyinhigh <flyinhigh@users.noreply.github.com>
…llow-permanent

fix(approval): carry allow_permanent to TUI + desktop approval prompts
…ousResearch#43896)

The desktop "Local / custom endpoint" onboarding never collected an API
key and /api/model/set silently dropped one, so an auth-gated endpoint
(e.g. a hosted vLLM behind a key) could never enumerate models — and
Settings' "Set up custom endpoint" routed `custom` into a non-existent
OAuth flow, booting the user back to the first screen (the reported loop).

Backend (web_server.py):
- /api/providers/validate accepts an optional api_key and sends it as a
  Bearer header when probing a custom endpoint's /v1/models.
- /api/model/set accepts api_key, persists it to model.api_key (same
  switch/preserve lifecycle as base_url), and registers a named
  custom_providers entry via _save_custom_provider — matching the
  `hermes model` CLI flow so the endpoint shows up as a ready picker row.

Desktop:
- ApiKeyForm shows an optional API key field for the local/custom option;
  the key is threaded through saveOnboardingLocalEndpoint → validate +
  setModelAssignment.
- New onboarding `localEndpoint` intent + startManualLocalEndpoint(); the
  Settings "Set up custom endpoint" button now opens the local-endpoint
  form (URL + key) instead of the OAuth dead-end.
- Added localApiKeyPlaceholder i18n key (en + types + zh).

Tests: api_key lifecycle on _apply_main_model_assignment, key persistence
+ custom_providers registration on /api/model/set, Bearer-header probe;
onboarding store forwards + persists the key.
…ofile-fallout

fix(desktop): close out the multi-profile desktop fallout — WS auth + cross-profile session reads
this makes the agent frequently edit files in the wrong worktree.
what the agent doesn't know can't hurt it.
…onnect (NousResearch#44550)

Port from anomalyco/opencode#31271: only call tools/list when the server
advertises the 'tools' capability in InitializeResult.capabilities.

Previously, _discover_tools() unconditionally called session.list_tools()
right after initialize. Prompt-only / resource-only servers (which omit
the tools capability per the MCP spec) raise McpError(-32601 Method not
found), which aborted the connection — burning all 3 initial-connect
retries and permanently failing the server even though its prompts and
resources were perfectly usable. The 180s keepalive had the same problem:
it probed with list_tools(), so even a successfully connected prompt-only
server would be torn down on the first keepalive cycle.

Changes:
- MCPServerTask._advertises_tools(): capability check with a legacy
  fallback (no captured InitializeResult -> behave as before)
- _discover_tools(): skip tools/list for non-tool servers
- keepalive: use the universal ping request for non-tool servers
- _refresh_tools(): guard against tools/list_changed from non-tool servers

E2E verified with a real stdio prompt-only FastMCP-style server: on main
it fails all 3 connection attempts with Method-not-found; with this fix
it connects, lists prompts, answers ping keepalives, and shuts down
cleanly.
…ee-code

fix(coding): don't expose primary worktree path in coding context
teknium1 and others added 16 commits June 13, 2026 15:01
…trol (NousResearch#45853)

* fix(desktop): jump-to-approval pill for off-screen approvals

A blocked approval's only response surface is the inline Run/Reject bar on
the pending tool row. When that row is scrolled out of view the session looks
stalled with no visible action. Surface a composer-anchored "Approval needed"
pill only when an approval is pending AND its inline bar is scrolled away;
clicking scrolls the bar back into view. Preserves the deliberate inline (not
modal) approval design — the pill never duplicates the approve/reject controls.

The inline bar mirrors its own viewport visibility via IntersectionObserver
(tracks scroll/resize/layout) and registers a scroll-into-view handler the pill
fires, mirroring the existing thread-scroll jump-button bridge.

Supersedes NousResearch#45828.

* fix(desktop): morph jump-to-bottom into approval prompt; drop scroll bridge

Collapse the separate "jump to approval" pill into the existing
scroll-to-bottom control: when scrolled away from the bottom while an approval
is pending, it relabels to "Approval needed". A parked approval's inline
Run/Reject bar is always the bottom-most content, so the existing
scroll-to-bottom action lands the user right on it — one control, no collision.

This also fixes the layout corruption from the first cut: the pill called
native el.scrollIntoView(), which scrolls every scrollable ancestor including
the overflow:hidden chat shell containers. Those have no scrollbar to scroll
back and don't remount on session switch, so the composer stayed shoved and
the breakage persisted across sessions. Reusing requestScrollToBottom() (the
use-stick-to-bottom path) only touches the one designated scroll container.

Removes the now-unused approval-scroll store + IntersectionObserver wiring.
A stale certifi CA bundle after a partial `hermes update` used to crash
the agent on the first outbound HTTPS call with a raw traceback and
trap the gateway in a retry loop.

This patch:

* Adds `agent/errors.py` with a typed `SSLConfigurationError`
* Adds `agent/ssl_guard.py` with a `verify_ca_bundle()` pre-flight
  that asserts the bundle exists, is non-trivial in size, and can build
  a working SSLContext. On macOS, it falls back to the system trust
  store when the bundle is empty but the system store is healthy
  (covers corporate proxies / MDM setups).
* Wires the guard into `run_agent.py` and `gateway/run.py` right
  after the `hermes_bootstrap` import, inside a try/except so a bug
  in the guard itself can never prevent startup.
* Adds a `SSL / CA Certificates` section to `hermes_cli doctor` so
  users can detect the failure with one command.
* Adds unit tests covering the healthy, missing, empty, skip-env, and
  macOS-fallback paths.
* Adds an RCA document describing the failure mode and the recovery
  path (`pip install -e .`).

When the bundle is broken the user sees:

    \u26a0\ufe0f SSL certificate bundle issue detected.
       Run: pip install -e .

`HERMES_SKIP_SSL_GUARD=1` disables the check for sandboxed
environments that ship their own trust store.
…ason

The previous test patched ssl.create_default_context globally with a bare
SSLContext that has zero CA certs. Both verify_ca_bundle() and the macOS
fallback got the same mocked context, so the test verified nothing useful:
both paths produced empty get_ca_certs() and the assertion that no
exception escaped was vacuously satisfied.

Only mock the fallback call (no cafile) — let the certifi call hit the
real SSL stack and fail with SSLError on the broken PEM. The mock
fallback returns a context with load_default_certs() so the test now
verifies the real scenario: broken certifi → SSLConfigurationError,
macOS system trust store → success.

Also pads the broken PEM past the 1 KB size guard so the size check
doesn't short-circuit before ssl.create_default_context(cafile=...) runs.

Reported by @liuhao1024 in PR review.
…h#28479)

Both the regular and execute_code dispatch paths forward task_id into
registry.dispatch via middleware _dispatch lambdas but silently dropped
session_id. Dispatch-layer hooks (e.g. set_enforcement_fn) that correlate
calls with the active session received "" for every invocation.

Pass session_id=session_id at both _dispatch call sites inside
handle_function_call, matching the existing task_id pattern. Hooks
already received session_id; this closes the registry.dispatch gap.

Rebased onto current main where dispatch is wrapped by
run_tool_execution_middleware — the old direct-dispatch sites from
NousResearch#28479 no longer exist.

test(dispatch): add tests for session_id forwarding (NousResearch#28479)

Covers standard and execute_code paths through the middleware wrapper.
Verifies task_id forwarding is not broken by the change.
@github-actions

Copy link
Copy Markdown

🔎 Lint report: codex/devin-acp vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 10462 on HEAD, 9569 on base (🆕 +893)

🆕 New issues (676):

Rule Count
unresolved-attribute 351
unresolved-import 105
invalid-argument-type 88
invalid-assignment 39
unsupported-operator 36
not-subscriptable 23
invalid-method-override 10
invalid-parameter-default 5
no-matching-overload 4
invalid-return-type 4
unresolved-reference 4
not-iterable 2
call-non-callable 2
unused-type-ignore-comment 1
unresolved-global 1
+1 more rules
First entries
gateway/slash_commands.py:3479: [unresolved-attribute] unresolved-attribute: Object of type `Self@_handle_deny_command` has no attribute `adapters`
hermes_cli/cli_agent_setup_mixin.py:378: [unresolved-attribute] unresolved-attribute: Object of type `Self@_init_agent` has no attribute `_on_thinking`
optional-skills/security/godmode/scripts/auto_jailbreak.py:383: [invalid-parameter-default] invalid-parameter-default: Default value of type `None` is not assignable to annotated parameter type `str`
gateway/slash_commands.py:424: [unresolved-attribute] unresolved-attribute: Object of type `Self@_handle_status_command` has no attribute `_session_db`
hermes_cli/cli_agent_setup_mixin.py:368: [unresolved-attribute] unresolved-attribute: Object of type `Self@_init_agent` has no attribute `_provider_require_params`
gateway/slash_commands.py:376: [unresolved-attribute] unresolved-attribute: Object of type `Self@_handle_kanban_command` has no attribute `_active_profile_name`
gateway/slash_commands.py:91: [unresolved-attribute] unresolved-attribute: Object of type `Self@_handle_reset_command` has no attribute `_cleanup_agent_resources`
gateway/slash_commands.py:1172: [invalid-argument-type] invalid-argument-type: Argument to function `list_authenticated_providers` is incorrect: Expected `dict[Unknown, Unknown]`, found `None | Unknown`
tests/gateway/test_compression_concurrent_sessions.py:26: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/gateway/test_matrix_approval_reaction_fail_closed.py:62: [unresolved-attribute] unresolved-attribute: Unresolved attribute `TrustState` on type `ModuleType`
hermes_cli/cli_commands_mixin.py:1453: [unresolved-attribute] unresolved-attribute: Object of type `Self@_handle_background_command` has no attribute `_providers_only`
tests/tools/test_blueprints.py:12: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
hermes_cli/cli_commands_mixin.py:1457: [unresolved-attribute] unresolved-attribute: Object of type `Self@_handle_background_command` has no attribute `_provider_require_params`
tui_gateway/server.py:3409: [invalid-argument-type] invalid-argument-type: Argument to `AIAgent.__init__` is incorrect: Expected `list[str]`, found `Unknown | None`
gateway/slash_commands.py:2494: [unresolved-attribute] unresolved-attribute: Object of type `Self@_handle_compress_command` has no attribute `_sync_telegram_topic_binding`
hermes_cli/cli_commands_mixin.py:286: [unresolved-attribute] unresolved-attribute: Object of type `Self@_handle_paste_command` has no attribute `_attached_images`
tests/gateway/test_42039_duplicate_user_message.py:33: [unresolved-attribute] unresolved-attribute: Unresolved attribute `load_dotenv` on type `ModuleType`
tests/gateway/test_telegram_overflow_partial.py:137: [unresolved-attribute] unresolved-attribute: Attribute `kwargs` is not defined on `None` in union `_Call | None`
gateway/slash_commands.py:1601: [unresolved-attribute] unresolved-attribute: Object of type `Self@_handle_goal_command` has no attribute `_clear_goal_pending_continuations`
gateway/slash_commands.py:119: [unresolved-attribute] unresolved-attribute: Object of type `Self@_handle_reset_command` has no attribute `_set_session_reasoning_override`
gateway/platforms/whatsapp_cloud.py:1352: [unresolved-attribute] unresolved-attribute: Attribute `Request` is not defined on `None` in union `Unknown | None`
gateway/slash_commands.py:1538: [unresolved-attribute] unresolved-attribute: Object of type `Self@_handle_retry_command` has no attribute `session_store`
tests/hermes_cli/test_resolve_last_session.py:148: [unresolved-attribute] unresolved-attribute: Attribute `execute` is not defined on `None` in union `None | Connection`
tests/gateway/test_slack_plugin_action_handlers.py:136: [invalid-argument-type] invalid-argument-type: Argument to bound method `PluginContext.register_slack_action_handler` is incorrect: Expected `(...) -> Unknown`, found `Literal["not a function"]`
tests/tools/test_discord_tool.py:637: [unsupported-operator] unsupported-operator: Operator `not in` is not supported between objects of type `Literal["discord"]` and `str | list[str] | list[Unknown] | bool`
... and 651 more

✅ Fixed issues (130):

Rule Count
invalid-argument-type 56
unresolved-attribute 41
unsupported-operator 11
unresolved-import 8
invalid-assignment 5
invalid-return-type 3
call-non-callable 2
unresolved-reference 2
invalid-parameter-default 1
invalid-type-form 1
First entries
gateway/run.py:6846: [invalid-argument-type] invalid-argument-type: Argument to bound method `PlatformRegistry.create_adapter` is incorrect: Expected `str`, found `Literal["local", "telegram", "discord", "whatsapp", "slack", ... omitted 17 literals] | set[Unknown]`
tests/gateway/test_whatsapp_group_gating.py:372: [invalid-argument-type] invalid-argument-type: Argument to function `WhatsAppAdapter._is_broadcast_chat` is incorrect: Expected `str`, found `None`
tests/hermes_cli/test_destructive_slash_confirm_gate.py:32: [unresolved-attribute] unresolved-attribute: Attribute `get` is not defined on `str`, `list[Unknown]`, `list[str]`, `int` in union `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 30 union elements`
tests/cli/test_cli_status_bar.py:163: [unresolved-import] unresolved-import: Cannot resolve imported module `prompt_toolkit.utils`
tools/process_registry.py:1098: [invalid-argument-type] invalid-argument-type: Argument to bound method `ProcessRegistry._reconcile_local_exit` is incorrect: Expected `ProcessSession`, found `ProcessSession | None`
tests/gateway/test_version_command.py:11: [invalid-argument-type] invalid-argument-type: Argument to function `GatewayRunner._handle_version_command` is incorrect: Expected `GatewayRunner`, found `None`
gateway/run.py:17504: [invalid-argument-type] invalid-argument-type: Argument to bound method `BasePlatformAdapter.edit_message` is incorrect: Expected `bool`, found `dict[str, Any] | None | str`
tests/hermes_cli/test_portal_cli.py:13: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/tools/test_web_providers.py:218: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["search_backend"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 30 union elements`
tests/hermes_cli/test_web_server.py:628: [unresolved-attribute] unresolved-attribute: Attribute `commit` is not defined on `None` in union `Connection | None`
gateway/run.py:11199: [invalid-argument-type] invalid-argument-type: Argument to function `list_picker_providers` is incorrect: Expected `dict[Unknown, Unknown]`, found `None | Unknown`
tests/tools/test_browser_lightpanda.py:242: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["engine"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 30 union elements`
tests/tools/test_browser_console.py:341: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["record_sessions"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 30 union elements`
gateway/config.py:544: [invalid-argument-type] invalid-argument-type: Argument to bound method `PlatformRegistry.get` is incorrect: Expected `str`, found `Literal["local", "telegram", "discord", "whatsapp", "slack", ... omitted 17 literals] | set[Unknown]`
gateway/run.py:15773: [invalid-argument-type] invalid-argument-type: Argument to function `set_session_vars` is incorrect: Expected `str`, found `Literal["local", "telegram", "discord", "whatsapp", "slack", ... omitted 17 literals] | set[Unknown]`
hermes_cli/config.py:4655: [unresolved-attribute] unresolved-attribute: Attribute `get` is not defined on `str`, `list[Unknown]`, `list[str]`, `int` in union `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 30 union elements`
tests/hermes_cli/test_aux_config.py:37: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["title_generation"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 30 union elements`
gateway/platforms/yuanbao.py:2695: [invalid-argument-type] invalid-argument-type: Argument is incorrect: Expected `MessageType`, found `Literal[MessageType.DOCUMENT] | Any | None`
tests/gateway/test_version_command.py:11: [invalid-argument-type] invalid-argument-type: Argument to function `GatewayRunner._handle_version_command` is incorrect: Expected `MessageEvent`, found `None`
tests/cli/test_reasoning_command.py:552: [invalid-argument-type] invalid-argument-type: Argument to bound method `TestCase.assertIn` is incorrect: Expected `Iterable[Any] | Container[Any]`, found `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 30 union elements`
cli.py:12070: [invalid-argument-type] invalid-argument-type: Argument to function `_wrap_panel_text` is incorrect: Expected `str`, found `str | list[str] | int | Queue[Unknown] | Unknown`
hermes_cli/web_server.py:3130: [invalid-argument-type] invalid-argument-type: Argument to bound method `set.add` is incorrect: Expected `str`, found `Literal["telegram", "discord", "whatsapp", "slack", "signal", ... omitted 16 literals] | set[Unknown]`
cli.py:12047: [unsupported-operator] unsupported-operator: Operator `+` is not supported between objects of type `str | list[str] | Unknown` and `Literal["..."]`
gateway/run.py:18637: [invalid-assignment] invalid-assignment: Object of type `object` is not assignable to attribute `session_id` on type `SessionEntry & ~AlwaysFalsy`
hermes_cli/web_server.py:8177: [unresolved-attribute] unresolved-attribute: Attribute `execute` is not defined on `None` in union `Connection | None`
... and 105 more

Unchanged: 4787 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

nkowne63 pushed a commit that referenced this pull request Jun 26, 2026
…bound/outbound round-trip (NousResearch#48828)

* fix(relay): enable RELAY platform + normalize dial URL so hosted gateways actually connect

Three bugs blocked a self-provisioned hosted gateway from ever establishing its
inbound relay WS (found while standing up the live staging end-to-end). Each
masked the next; all three are needed for inbound to work.

1. RELAY platform never enabled in config.platforms (gateway/config.py).
   register_relay_adapter() puts the adapter in the platform_registry, but
   start_gateway()'s connect loop iterates self.config.platforms — which never
   contained Platform.RELAY. So the adapter was "registered" but never connected
   (logs showed "relay adapter registered" then "No messaging platforms
   enabled"). Fix: _apply_env_overrides now enables Platform.RELAY (mirroring
   relay_url into extra for the connected-checker) when GATEWAY_RELAY_URL (env)
   or gateway.relay_url (yaml) is set. Absent -> no RELAY entry (direct/
   single-tenant gateways unaffected).

2. URL scheme not converted for the WS dial (gateway/relay/ws_transport.py).
   The relay URL is configured once as the http(s):// base (used as-is for the
   provision POST), but websockets.connect rejects http(s):// with "scheme isn't
   ws or wss". Fix: _ws_dial_url converts https->wss / http->ws.

3. /relay path not appended (same helper). The connector mounts its
   WebSocketServer at path "/relay" and returns HTTP 400 on an upgrade to any
   other path. GATEWAY_RELAY_URL is the base (no /relay), so the dial hit "/"
   -> 400. Fix: _ws_dial_url ensures the path ends in /relay. Idempotent — a URL
   already carrying ws(s):// and/or /relay is unchanged, so provision's
   _provision_url (which derives /relay/provision from either form) still works.

Why the cross-repo E2E missed #2/#3: the stub connector binds ws://host:port and
its websockets.serve accepts ANY path, so neither the scheme nor the /relay path
was exercised. Real connector needs both.

Verified live on staging hermes-agent-stg-automated-perception-5054: after the
fixes the gateway logs "Connecting to relay..." -> "✓ relay connected" ->
"Gateway running with 1 platform(s)" against
wss://gateway-gateway.staging-nousresearch.com/relay, stable.

Tests: added _ws_dial_url scheme+path+idempotency cases (test_ws_transport.py)
and RELAY-platform-enablement cases for env + yaml + absent (test_config.py).
Full gateway/relay + config suites green (191 passed).

Relay-adapter lane. EXPERIMENTAL.

* fix(relay): re-attach guild_id to outbound so connector egress resolves the tenant

The final bug in the hosted-relay round-trip. Inbound worked end to end (Discord
-> connector -> bus -> agent WS -> agent runs -> reply), but the reply's egress
was declined by the connector: "discord egress declined: target not routed to an
onboarded tenant".

Cause: the connector's routedEgressGuard resolves the owning tenant from the
OUTBOUND action's metadata.guild_id (Discord's routing discriminator). The
gateway's generic delivery path builds outbound metadata via
run.py _thread_metadata_for_source, which only carries thread_id (and returns
None entirely for a non-threaded message) — so guild_id never reached the
connector, tenant resolution failed, and the shared bot refused to post.

Fix (relay-adapter-local, no perturbation of the generic delivery path or other
platforms): RelayAdapter learns chat_id -> guild_id from each inbound event
(_capture_scope) and re-attaches it to the outbound action's metadata in send()
(_with_scope) when not already present. No-op for chats we never saw inbound
(e.g. DMs) and never overwrites an explicit guild_id.

Verified live on staging hermes-agent-stg-automated-perception-5054: an
@mention in #general now produces a visible bot reply — full multi-tenant relay
round-trip (real Discord -> shared connector bot -> tenant routing -> agent WS ->
reply egress -> Discord).

Tests: _capture_scope/_with_scope reattach, no-scope no-op, explicit-guild_id
preserved (test_relay_adapter.py). Full relay + config suites green (160 passed).

Relay-adapter lane. EXPERIMENTAL.
nkowne63 pushed a commit that referenced this pull request Jun 26, 2026
When context compression rotates a session, the original is ended and the
continuation is auto-numbered (e.g. "name" -> "name #2"). The session list
projects the ended root behind its live tip, so the user never sees the
predecessor. But set_session_title's uniqueness check compared against ALL
sessions, so renaming the visible tip back to "name" dead-ended with
"Title 'name' is already in use by session <id the user can't find>".

When the conflicting title is held by a compression ancestor of the session
being renamed, transfer the title instead of raising: clear it from the
ended predecessor and apply it to the continuation. Uniqueness is preserved
(still exactly one session carries the title) and the parent-link lineage is
untouched, so resume-by-title and tip projection keep working. Genuine
conflicts with unrelated sessions, and with non-compression children
(delegate/branch), still raise as before.
nkowne63 pushed a commit that referenced this pull request Jun 26, 2026
…id (NousResearch#38763)

Context compression today rewrites the message list AND rotates the
session id — it ends the session, forks a parent_session_id child, and
renumbers the title (name -> name #2). That moving identity key is the
root cause of a whole bug cluster: /goal lost (NousResearch#33618), pending response
lost at the split (NousResearch#14238), orphan sessions (NousResearch#33907), TUI sid desync
(NousResearch#36777), FTS search gaps + duplicate sidebar entries (NousResearch#45117), null
continuation cwd (NousResearch#42228), and title-rename dead-ends (NousResearch#48989). It also
forced a large defensive apparatus (compression lock, contextvar/env/
logging triple-sync, orphan finalization, gateway SessionEntry
re-propagation, tip projection) whose only job is surviving a
mid-conversation id change.

Add a compression.in_place config flag (default False during rollout).
When True, compaction rewrites the transcript and rebuilds the system
prompt but keeps the SAME session_id: no end_session, no child row, no
title renumber, no contextvar/logging re-sync, no memory/context-engine
session-switch. The conversation keeps one durable id for life, like
Claude Code / Codex. Compaction is lossy by design — the pre-compaction
transcript is summarized away, not archived.

The rotation path is unchanged when the flag is off (moved verbatim into
an else branch). Staged rollout: this PR ships the option behind a
default-off flag for live validation; a follow-up flips the default and
deletes the now-redundant rotation machinery, superseding the 14 open
band-aid PRs in this area.

- hermes_cli/config.py: add compression.in_place (default False), documented
- agent/agent_init.py: resolve the flag -> agent.compression_in_place
- agent/conversation_compression.py: branch compress_context() on the flag
- tests/run_agent/test_in_place_compaction.py: in-place invariants +
  rotation regression guard + config default

The pre-flush of current-turn messages (NousResearch#47202) runs in BOTH modes, so no
boundary data loss. Prompt-cache invariant preserved: the system-prompt
rebuild is the same single sanctioned invalidation that already happens
during compaction — no NEW invalidation. Message alternation preserved.
@nkowne63

Copy link
Copy Markdown
Owner Author

このPRは古い fork main / stacked branch をベースにしてしまい、意図しない大量差分を含んでいます。fork main を upstream に追従させたうえで、Devin ACP の変更だけを含む小さいPRとして作り直します。

@nkowne63 nkowne63 closed this Jun 26, 2026
nkowne63 pushed a commit that referenced this pull request Jun 26, 2026
…eation snapshot (NousResearch#44585)

An unpinned cron job follows the global default provider (config.yaml
model.default + resolve_runtime_provider). If that global state is changed
after the job is created — e.g. a temporary switch to a paid provider like
nous/claude-fable-5 — the job silently inherits it on its next tick and spends
real money. This is the reported $7.73 incident: a job created under a
free/default provider later inherited a temporary paid switch.

Fix (ask #1 only) preserves the legitimate "unpinned job should follow
model.default" use case by detecting *drift* rather than freezing the model:

- create_job (cron/jobs.py): for UNPINNED, agent-backed jobs (no explicit
  provider, not no_agent), snapshot the provider that resolution WOULD pick
  right now into a new optional `provider_snapshot` field, resolved via the
  same resolve_runtime_provider() path the ticker uses. Fail-open to None on
  any resolution error so job creation never breaks.

- run_job (cron/scheduler.py): right after runtime resolution, if the job has
  a provider_snapshot AND is unpinned AND the currently-resolved provider
  DIFFERS from the snapshot, fail closed for that run — make no paid call and
  deliver a loud, actionable alert naming both providers and telling the user
  to pin explicitly (`cronjob action=update job_id=.. provider=..`).

Back-compat: jobs with no snapshot (pre-existing jobs, no_agent jobs, or any
job whose creation-time resolution failed) behave exactly as before — the
guard only engages when a snapshot exists. Explicitly-pinned jobs (job.provider
set) are unaffected since they don't drift with global state.

Tests: tests/cron/test_cron_provider_pin.py covers snapshot-matches (runs),
snapshot-differs (fail closed, no agent constructed), no-snapshot back-compat,
None-snapshot back-compat, explicitly-pinned (runs regardless), plus create_job
snapshot capture/skip/fail-open. The fail-closed case is load-bearing (fails
without the guard).

Issue NousResearch#44585 asks #2-4 (hard-stop a running job, gateway-stop containment,
fail-closed on provider mutation) are out of scope for this change.
nkowne63 pushed a commit that referenced this pull request Jul 2, 2026
…_id signature churn

Two independent bugs evicted the cached gateway AIAgent on every turn,
preventing the prompt cache from ever warming:

1. Model normalization mismatch: the post-run fallback-eviction check
   compared _agent.model (stripped in AIAgent.__init__) against the raw
   _resolve_gateway_model() config string. For vendor-prefixed config on
   native providers (e.g. 'deepseek/deepseek-v4-pro' vs 'deepseek-v4-pro')
   this was always unequal, so the agent was evicted after every
   successful run. Normalize _cfg_model the same way (skip aggregators).

2. Discord triggering message_id leaked into the cached system prompt via
   build_session_context_prompt()'s Discord IDs block. message_id changes
   every turn, so the agent-cache signature (computed from the ephemeral
   prompt) changed every Discord turn -> rebuild every message. The id is
   now injected per-turn into the user message (where per-turn content
   belongs and does not touch the cache signature); the cached IDs block
   carries a static pointer to it, preserving reply/react/pin via the
   discord tools.

Adapted from NousResearch#28846. Bug #1 fix is the contributor's; bug #2 reworked to
be non-destructive (keeps the triggering-id capability instead of deleting
it). Redundant auto-reset eviction (already on main via NousResearch#9893/NousResearch#48031) and
the wrong-premise reset_context_note plumbing from the original PR were
dropped.

Co-authored-by: Hermes Agent <hermes@nousresearch.com>
nkowne63 pushed a commit that referenced this pull request Jul 2, 2026
… fail on '(empty)' sentinel

Two related bugs caused subagent delegation to silently return empty summaries
with 0 tokens when the user configured delegation.provider=bedrock alongside
delegation.base_url=https://bedrock-runtime.<region>.amazonaws.com.

Root cause #1 — misrouting in _resolve_delegation_credentials():
  The configured_base_url branch unconditionally forced provider='custom' and
  api_mode='chat_completions', only specializing for chatgpt.com, anthropic,
  and kimi hosts. Bedrock (and other native-SDK providers) fell through as
  'custom' + chat_completions, which then POSTed OpenAI-shaped JSON at
  Bedrock's native API. Bedrock rejected the payload and returned nothing,
  which looked like an empty LLM response to the child agent.

  Fix: when provider is one of {bedrock, vertex, google, google-genai}, skip
  the base_url short-circuit and fall through to resolve_runtime_provider(),
  which knows how to construct the proper SDK client. base_url can still be
  forwarded through that path for regional overrides.

Root cause #2 — '(empty)' sentinel accepted as success:
  After N retries of empty LLM responses, run_agent.py emits the literal
  string '(empty)' as final_response. _run_single_child then hit
  `elif summary:` — '(empty)' is truthy, so status became 'completed' and
  the parent surfaced a blank result with no error. Users saw api_calls=4,
  tokens=0, duration~0.4s, status=completed.

  Fix: treat final_response.strip() == '(empty)' as a failure so the parent
  surfaces it instead of silently accepting zero-content 'success'.

Both paths were reproduced in a live Hermes TUI session on us-west-2 Bedrock
(provider=bedrock, model=us.anthropic.claude-sonnet-4-6) and are covered by
new tests in tests/tools/test_delegate.py.
nkowne63 pushed a commit that referenced this pull request Jul 17, 2026
Completes the review's ask for "adapter-to-session-key integration coverage
for Discord and a non-Discord platform" on NousResearch#20096.

Drives a concrete adapter's real BasePlatformAdapter.build_source with an
injected gateway_runner, asserts the matched route's profile is stamped on
the source, and that build_session_key scopes the key under agent:<profile>:
(versus the shared agent:main: namespace). Covers Discord and Telegram — the
Telegram case is the bug-#2 path that previously fell through to default.
Adds a regression anchor: without gateway_runner, profile stays None and the
key lands in agent:main (the silent fallback the fix removes for non-Discord).

Co-Authored-By: Claude <noreply@anthropic.com>
nkowne63 pushed a commit that referenced this pull request Jul 17, 2026
…st (NousResearch#65214)

Moves the fireworks entry in CANONICAL_PROVIDERS from its old slot
(after GMI Cloud) to directly below Nous Portal, ahead of OpenRouter.
Order propagates automatically to hermes model, the setup wizard,
Telegram /model, and the desktop provider catalog.
nkowne63 pushed a commit that referenced this pull request Jul 17, 2026
…etry

Combines the two salvaged fixes so they compose instead of conflict:
_persist_session_title (NousResearch#50575) now writes through set_auto_title_if_empty
(NousResearch#51483) when the store provides it — the collision-dedup retry and the
manual-/title race protection apply together. Predicate failure (a manual
title landed while generation was in flight) returns None: nothing written,
no callback. Legacy stores without the atomic method keep the plain
set_session_title path, including the vanished-session RuntimeError.

Tests cover both store shapes plus the race-skip path; E2E verified against
a real SQLite SessionDB (collision -> 'Weekly Report #2', manual title
preserved, cron dedup, blank guard). AUTHOR_MAP entry for rasitakyol.
nkowne63 pushed a commit that referenced this pull request Aug 5, 2026
… a broken chat

A completely unconfigured install previously booted into a working-looking
chat (banner showed model 'unknown'), accepted a message, spun ~30s, then
failed with 'Set OPENROUTER_API_KEY' — a provider the user never chose —
and never offered setup.

- HermesCLI.run() now probes provider readiness at startup (TTY only) and
  offers the shared provider picker (hermes model flow, which fronts Quick
  Setup / Nous Portal OAuth) when nothing is configured. Decline is
  respected; picker state re-syncs into the live CLI so the next turn works
  without a restart.
- New silent probe _runtime_credentials_ready(): no printing, no state
  mutation; handles keyless local endpoints and callable bearer providers.
- The empty-api-key error is provider-aware: names the actual resolved
  provider and points at 'hermes model' / 'hermes setup' instead of
  hardcoding OPENROUTER_API_KEY.
- Banner: unconfigured installs render 'no model configured — run /model'
  in red instead of the silent 'unknown' model slug.

Consumer-onboarding audit finding #2 (sev 5), Aug 2026.
nkowne63 pushed a commit that referenced this pull request Aug 5, 2026
A wedged adapter transport (network hang, dead websocket) previously
blocked _check_session_stalls forever: sibling candidates in the same
pass were never evaluated and the watcher stopped ticking. Wrap the
send in asyncio.wait_for (15s); on timeout log a WARNING and do NOT
latch, so the next tick retries. Regression uses a never-resolving fake
adapter and proves the pass completes, a healthy sibling candidate is
still notified in the same pass, and the watcher ticks again
(sabotage-verified against the unbounded send).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.