OpenViking now ships from Volcengine's own repo through the plugin catalog
(plugin-catalog/openviking.yaml, pinned in #131267). Delete the in-tree copy
and its tests; memory_provider_migration already lists openviking in
_LEFT_CORE, so homes configured for it get the catalog plugin installed on
hermes update / first agent start.
The catalog pin starts openviking-server with the full Hermes environment
(the bundled copy filtered it). The docs now say so and point at
volcengine/OpenViking#5553; start openviking-server yourself to avoid it.
- can_move: `archived` is a valid source (restore). The dashboard PATCH accepts
archived -> triage/todo/ready (measured live), so the manual table gains that
row and the matrix test now measures it too. A same-column drop returns False
instead of raising or meaning "allowed".
- `hermes kanban stats` text output iterated its own hardcoded status list,
which omitted `review`; it now loops over DEFAULT_WORKFLOW.keys().
- Tests: drop three asserts that could not fail (VALID_STATUSES is
DEFAULT_STATUSES; BOARD_COLUMNS is built from keys()); add one invariant:
can_move answers for every value tasks.status can hold.
Review of #130067: the event edges claimed every block lands in 'blocked',
but _route_block routes to todo / triage / blocked, and ~20 kernel status
writes are conditional (crash/reclaim return, failure breaker, parent
reopen, import). Rather than publish a partial contract, Phase 0 keeps only
what is checked now (columns, traits, manual matrix, derived copies); the
event model is designed with the kernel switch in Phase 1.
Also from review:
- can_move rejects unknown column keys instead of defaulting (archive of a
bogus key returned True).
- the matrix test only accepts 400/409 as a refusal; 5xx or 422 fails.
Groundwork for user-defined columns and workflow per board (#54818).
- hermes_cli/kanban_workflow.py: Column/Workflow + DEFAULT_WORKFLOW, today's
board written down as data (column order, traits, default kernel-event
edges, manual move allow-list). Not consulted by the kernel yet.
- VALID_STATUSES, dashboard BOARD_COLUMNS, the kanban_list status enum and
CLI status icons now derive from it. Fixes drift: the tool enum lacked
scheduled/review; CLI icons lacked triage/review.
- GET /workflow on the dashboard plugin API.
- Tests pin DEFAULT_WORKFLOW to real behavior: every default event edge vs.
its kernel verb, and all 64 PATCH src->dst moves vs. can_move.
Telegram echoes back the client-controlled inline_query.offset string on
every inline query. build_inline_results passed it through int() and
used it directly as a slice start, so a negative offset indexed the
catalog from the tail: small catalogs served their last few entries with
negative-prefixed result ids, and larger catalogs could return an empty
page with a misleading next_offset that skipped most of the catalog.
Clamp the parsed offset to >= 0. Malformed offsets still fall back to
page zero via the existing except path, and valid pagination is
unchanged.
Desktop Thinking Off can arrive as {effort: "none"} without enabled=False,
so V4 kept sending thinking:enabled and billed reasoning tokens.
Co-authored-by: Cursor <cursoragent@cursor.com>
`GET /api/plugins/kanban/board` returns an `assignees` list used by the
dashboard for the lane-by-profile sub-grouping in the running column.
Previously this came from a `SELECT DISTINCT assignee FROM tasks WHERE
... status != 'archived'` query, so a freshly-added profile only got
its own lane after being assigned its first non-archived task.
Switch the lookup to `kanban_db.known_assignees()`, the helper that
already backs the `/assignees` picker endpoint at plugin_api.py:1660.
It unions profiles configured on disk with profiles currently holding
non-archived tasks, so a new profile appears in the lane set
immediately. This also keeps the two endpoints from drifting —
previously they could disagree about who's "known."
Updates `test_board_empty` to reflect the new semantics: the implicit
`default` profile that the fixture's `HERMES_HOME` creates now shows
up in `assignees` even on an empty board.
Test plan:
- `scripts/run_tests.sh tests/plugins/test_kanban_dashboard_plugin.py`
— 94/94 pass (was 43/44 pre-test-update).
- Manual: add a new profile via `hermes profile create foo`, then open
the dashboard kanban; `foo` appears as an empty lane in the running
column without needing to assign it work first.
Platforms tested: Linux (WSL2). Pure Python, no platform-specific code.
Dashboard-side urllib readers (gateway health probe, ElevenLabs voices)
and self-hosted OIDC httpx readers (discovery, token exchange, revocation)
buffered remote response bodies with no size bound — a hostile or
compromised upstream could exhaust memory. Both halves now read with a
1 MiB hard cap: a shared _read_dashboard_json_response for urllib JSON
reads and a streaming _request_limited_response for IdP endpoints that
prechecks Content-Length and caps the chunked body regardless.
Adapted from #54809 and #55122 (credit @ooiuuii) to current main, where
the token exchange lives in plugins/dashboard_auth/_shared.py and the
health probe in hermes_cli/web_server_gateway.py.
Fixes#54808, Fixes#55121
Both dashboard-auth JWT verification paths (self-hosted OIDC ID token and the
Nous Portal access token, via the shared verify_jwt) called PyJWT jwt.decode
with the default leeway=0 while requiring iat. Any host whose clock lags the
issuer's — unsynced containers/VMs, NTP jitter — failed login with
ImmatureSignatureError, and the failure was folded into ProviderError, i.e.
HTTP 503 'provider unreachable', discarding already-rotated refresh tokens
and forcing unrecoverable re-logins.
- verify_jwt gains leeway (default 60s, RFC 7519 §4.1.4-4.1.6) and classifies
ImmatureSignatureError as InvalidCodeError (validation failure -> 401 /
refresh) instead of ProviderError/503.
- New parse_leeway helper: fail-closed parsing (unparseable / negative / NaN /
inf -> 0, never widens the window); 0 honours strict mode.
- Self-hosted OIDC: id_token_leeway kwarg + dashboard.oauth.self_hosted.id_token_leeway.
- Nous: token_leeway kwarg + dashboard.oauth.token_leeway.
- No new env vars (config.yaml-only knobs).
Fixes#47815
plugin-catalog/mem0.yaml (#131267) points at mem0ai/mem0's own
integrations/hermes-plugin-mem0 at a reviewed pin. The entry stays dormant
while a bundled copy exists (bundled providers win in find_provider_dir), so
deleting the in-tree copy is what lets hermes_cli/memory_provider_migration.py
install it for every home whose memory.provider is "mem0". The vendor copy
keeps the provider name, mem0.json, the MEM0_* keys and the setup wizard.
Also dropped: the hermes-agent[mem0] extra, its win-arm64 platform row and
exclude-newer row, the pm/extras.py anchor (uv lock removes only mem0ai,
posthog, qdrant-client and sqlalchemy), the mem0-specific doctor check (the
generic check now names `hermes plugins install mem0`), and the tests that
imported plugins.memory.mem0. Prose that lists the bundled set now names mem0
as a catalog provider. MEM0_* env hints and redaction stay: the catalog copy
still reads them.
The whole provider now lives in plugins/model-providers/solstice/ on the
generic seams:
- auth.py: NAS config discovery plus the brokered exchange/refresh, given
to the generic PKCE flow as token_request. A refresh that NAS rejects
(it folds every Google failure into 502) is treated as a dead grant
needing re-login.
- transport.py: a GeminiNativeClient subclass on the
generateContentPerUserQuota methods with Bearer auth (~30 lines, where
the PR duplicated a 257-line adapter).
- __init__.py: a hidden profile (create_client, fallback models,
default_aux_model).
Removed from core: hermes_cli/solstice.py, agent/solstice_adapter.py, and
the provider's branches in main.py, auth_commands.py, models.py,
runtime_provider.py, model_setup_flows.py and agent_runtime_helpers.py.
The unused SOLSTICE_CLIENT_ID env read is gone too.
Plugins can now call ctx.register_automation_blueprint(key, *, title,
description, schedule_template, prompt_template, category, slots,
deliver_default, skills, tags). The fields mirror the built-in
AutomationBlueprint, and slots are BlueprintSlot field dicts, so the call
also crosses the plugin-host boundary.
- One lookup, cron.blueprint_catalog.list_blueprints() / get_blueprint(),
merges the built-ins with the active profile's plugin blueprints at call
time. /blueprint (CLI/TUI/messengers), /api/cron/blueprints and the
instantiate route all use it. Each dashboard route resolves inside the
?profile= scope, off the event loop.
- Keys are namespaced <plugin>:<key>, so a plugin cannot shadow a built-in.
A ':' in the key, a duplicate key, an unknown placeholder, a bad slot type
or a schedule that does not parse is warned and ignored at registration.
- Catalog entries carry source/plugin. Desktop fetches the catalog for the
profile it creates jobs in, keyed per profile, and shows the plugin name
on the blueprint row and in the "Start from" select. The dashboard shows
a plugin badge.
- A created job is an ordinary cron job with the prompt and schedule already
filled in, so it keeps running if the plugin is later disabled.
- The ctx content registrars (register_skill, now joined by
register_automation_blueprint) move to hermes_cli/plugins_content.py.
The dashboard's blueprint types move to web/src/lib/automation-blueprints.ts.
Closes#132373. Requested by @enckequity.
The 10 s concurrent-load bound also hit slow but healthy first imports. A
second agent built while a provider's import took longer than 10 s got no
memory provider for its whole session, and with checkpoint_required every
compaction then failed closed.
The bound now applies only inside plugin_loader.bounded_load_wait(), which
the per-turn gateway memory identity signature uses. Other callers (agent
builds) wait for the in-flight load. Cycle detection still breaks a
cross-thread deadlock for every caller.
Review fixes for the per-module plugin load lock:
- A second caller waits at most 10 s for another thread's load of the same
module, then refuses with a named warning and returns None. While that
import is still stuck, later callers refuse at once. A hung provider import
no longer blocks every gateway turn, and there is no inline fallback load.
- The wait polls a wait-for graph spanning the loader locks and importlib's
own module locks. When thread A holds a helper's import lock and waits on
the plugin while thread B loads the plugin and imports that helper, the
cycle is detected and the waiter takes the partial module, the same way
importlib handles a concurrent circular import. Before, both threads hung.
- The lock is still a same-thread RLock with no worker thread, so recursive
loads and ContextVars are unchanged.
- conversation_compression.py: compact the fork-checkpoint exemption so the
file stays under its line cap after the rebase.
GLM-5.3 rejects thinking.type=disabled with HTTP 400 code 1210
(issue #85890, #96373). An explicit off preference now sends
enabled plus reasoning_effort=low. 4.5/4.6/5.2 still send disabled.
Discord was the hardest gateway to set up: the .env template had no Discord
section at all, the wizard saved whatever was pasted, and users had to find
the Message Content Intent toggle, build an OAuth2 invite URL by hand and turn
on Developer Mode just to learn their own user ID.
The wizard now reads GET /applications/@me with the pasted token: a rejected
token is re-prompted instead of saved, an intent that is off gets a direct
link to the toggle and a re-check, the invite link is printed with the
permissions the adapter uses, and the allowlist defaults to the application's
owner (or accepted team members). Offline, the token is still saved with the
manual intent reminder. The wizard moves from the 7k-line adapter into
plugins/platforms/discord/onboarding.py.
Add the clarify text-fallback invariant. It drives the real
TelegramAdapter send through text_fallback_coro and fails on base, where
the send carries disable_notification=True. Drop the runner metadata
test: it only pins that the metadata did not change from base. The
_notification_kwargs parametrize already fails on base for
is_approval_prompt, which keeps the stack at two invariant tests.
The salvaged change also put notify=True on both approval sends.
On A2A, `notify` marks the turn-final reply, and A2A has no native
approval renderer. A prompt that needs a human decision would therefore
complete the caller's task with the prompt text while the turn is still
waiting (@ehz0ah's review on #132527). Telegram already pushes the
prompt through the approval-aware _notification_kwargs, so only the
is_approval_prompt marker is needed. The card send goes back to the
verbatim thread metadata: Telegram's control-message path never silences
cards.
Tests: the runner test pins that the fallback metadata stays interim and
does not carry notify. The adapter tests become a single parametrize.
Fold the _notification_kwargs change back into the original one-line
shape, so adapter.py grows by one line instead of six.
In the default Telegram notification mode ("important") the plain-text
approval fallback marked only is_approval_prompt, which no notification
decision consults, so _notification_kwargs sent the prompt with
disable_notification=True: the phone got no push, the user never learned
a dangerous command was waiting, and the tool sat the full approvals.timeout
(default 300s) before failing closed (#132516).
Mirror the cron-delivery convention on both approval sends in
_approval_notify_sync: notify=True on the button card and the text
fallback, and make _notification_kwargs itself approval-aware
(is_approval_prompt defeats disable_notification) so every surface that
marks the prompt is covered in one place. Loosens no guard and
auto-approves nothing.
(cherry picked from commit 576fec2ab6a6cdca08f96968574ba741c47568c0)
Gate BR3 r1 reuse (Low): build_update_processor hand-rolled the int-or-
default parse that gateway.platforms._shared.coerce_port already does.
Neither caught OverflowError, so `max_concurrent_updates: .inf` in YAML
aborted connect(). coerce_port now treats non-finite values as unparseable
(as gateway.config._coerce_num already does), which also hardens its other
port/int callers; the telegram setting warns and falls back to 32.
Gate BR3 r1 quality (Low): PTB's SimpleUpdateProcessor already provides the
no-op initialize/shutdown the class copied, and config.extra is always a
dict, so the `(extra or {})` guard was dead. The wiring test only compared
PTB's concurrent_updates property with itself (true for any processor);
assert the PerChatUpdateProcessor type and DEFAULT_MAX_CONCURRENT_UPDATES
instead - now red when a plain SimpleUpdateProcessor(32) is wired in.
PerChatUpdateProcessor awaited the predecessor's tail future directly, so
cancelling a waiting same-chat update also cancelled that future (gate
BR3 r1 2c + quality, Medium). The running predecessor then raised
InvalidStateError from done.set_result() after its handler succeeded, and
the next waiter started while the predecessor still ran (probe order:
start A, start C). Shield the await and, for a cancelled waiter, release
its turn only once its predecessor finishes.
Plain PTB concurrency (concurrent_updates=32) unblocks other chats while one
chat's handler is slow, but also lets that chat's NEXT update dispatch and
deliver before the first one finishes (download/vision/callback work happens
inline, before text batching can merge them).
PerChatUpdateProcessor (update_admission.py, not the 7k-line adapter) chains
each update behind the previous update of the same chat while keeping the
bounded cross-chat pool. The builder keeps the instance, so the connect-retry
rebuild in _initialize_app_with_retries reuses it. An invalid
max_concurrent_updates now logs a warning instead of silently falling back
(as #74652 did), and the dispatch-stall watchdog comment no longer claims PTB
dispatches sequentially.
Co-authored-by: fortona-space <299084105+fortona-space@users.noreply.github.com>
Fold of gate finding (2c Medium regression): raising _INGRESS_DISPATCH_STALL_HEARTBEATS to 4
left the `ingress` row of test_only_pre_handoff_failure_reopens_admission looping 3 heartbeats,
which can never reach the threshold, so its "no healthy-but-deaf report" assertion was disarmed.
Loop the threshold itself so the leg tracks future bumps. Verified: with update_admission's
dispatch counting removed, the heartbeat leg now fails (it passed with range(3)).
An edit carries m.mentions twice: once inside m.new_content and once at the
top level. When a budget-sized edit names many distinct Matrix IDs, that
fixed part alone could pass the content budget. Shortening the fallback body
then had no room to work with, and the edit still went out at about 54 KB,
which an encrypted room would reject with M_TOO_LARGE.
When the edit is still over budget with an empty fallback, it now drops the
top-level mentions copy. That copy only drives the edit's own notifications,
and m.new_content keeps the full list.
The event-size tests move to tests/plugins/platforms/matrix/test_event_size.py,
mirroring the plugin source tree, and gain a mention-heavy text. The
tests/gateway/test_matrix_message_length.py file is back to main apart from the
env-override value that the new ceiling requires.
(cherry picked from commit 4b06bcb7dbffb732bef6e877cb66dea2fbff8c73)
The Firecrawl scrape() call was missing the `timeout` parameter, so the
server-side used its 30s default. The client-side asyncio.wait_for
expects 60s, causing SCRAPE_TIMEOUT on slow pages before the Python
deadline expires.
Fix: pass timeout=60000 (ms) to scrape() so both deadlines align.
Fixes#43272
The provider's own suite and the supermemory cases in the shared memory
suites (BOM-tolerant config reads, PM SDK admission, multiplex identity
scope, provider threads inheriting the profile, plugin-family secret scope,
cron agent_context) imported plugins.memory.supermemory, which is gone.
The other providers' cases in those suites are unchanged; the SDK-admission
test keeps its mem0 case.
Generated image/video output now writes under cache/generated/images|videos
instead of the swept inbound caches (cache/images, cache/videos). The hourly
gateway housekeeping deletes 24h-old files in those dirs; for base64-returning
providers the swept file was the only copy, breaking transcript references,
the Artifacts panel and later edits.
Rebased onto current main, where the writers delegate to agent/provider_media,
so the media kind is now the generated/ subpath. cache/generated is added to
tools/credential_files._CACHE_DIRS: without a mount/sync entry the Docker,
SSH and Modal backends never receive the bytes and the provider result loses
agent_visible_image. All callers consume the returned absolute paths, so no
call-site changes were needed; inbound download paths (get_image_cache_dir /
get_video_cache_dir) and the sweeper are untouched.
RED: tests/agent/test_generated_media_durable.py (9 failed on main) — 25h-backdated
generated files deleted by the real cleanup_image_cache / cleanup_video_cache, and
no mount mapping for cache/generated. tests/tools/test_image_generation_artifacts.py
::test_real_generated_image_is_agent_visible_under_ssh_env covers the sandbox
regression with the real writer. Green: 533 passed, 19 skipped.
Co-authored-by: Froraut <79215537+Froraut@users.noreply.github.com>
(cherry picked from commit fd23181f22232f66aec7076d6bf0868ed45661f2)
The kanban test's hook leg used to rewrite the pre-existing attachment, which
the created-paths snapshot already ignores, so the kanban exclusion was never
reached on that path. It now creates a NEW kanban file via the call and
asserts it survives session end. Without the kanban entry in
_NEVER_TRACK_TOP_LEVEL that file gets tracked and deleted.
Also moves the function-local `import os` to module level and replaces
iter(range(1, 10**6)) with itertools.count(1) for call IDs.
Keep the headline invariant (a call never tracks paths it did not
create) and the cross-session snapshot isolation case; the rewritten-args
case exercises the same fail-closed branch as the headline test.
The pre/post snapshot handshake had two ways to mark a user's existing
file as created, which queues it for deletion when the turn ends:
- Snapshots were keyed by tool_call_id alone. llama.cpp sends one
constant id for every call. When session B's pre hook ran between
session A's pre and post, A consumed B's snapshot. A's
already-existing test_user.py then looked "not in existing", and A
tracked it.
- Every pre_tool_call hook sees the original args. Another plugin's
`modify` directive is merged afterwards, and post_tool_call sees the
final args. A path swapped in by that hook was never snapshotted, so
"not in existing" held for it too. The file was tracked even when the
write was refused.
Snapshots are now keyed by (task_id or session_id, tool_call_id), the
identity both hooks receive from the dispatcher. They store the paths the
call named that did not exist yet, instead of the ones that did. The post
hook tracks a path only if its own snapshot saw it absent and it exists
now. Any path the snapshot did not cover fails closed.
I reproduced both end to end through model_tools.handle_function_call
with a second plugin's modify hook and two interleaved sessions. The
user's file was deleted before this fix and survives after it, and a
file the call really created is still cleaned up. The README hook
contract is updated to match.
(cherry picked from commit 8d0b853981f1ab0775549f4831a3d4f733a46106)
post_tool_call sent write_file/patch paths, every absolute path in a
terminal command AND every path in its output to dg.track(), gated only
on `p.exists()` plus the test_*/tmp_* name rule. Category 'test' is
deleted at age 0 by quick(), which on_session_end runs after every turn,
and directories are rmtree'd. So a user's cron script
$HERMES_HOME/scripts/test_uptime.py the agent merely patched, or a hook
dir $HERMES_HOME/hooks/test_notify it listed (`ls -la`, `find ... -name
'test_*'`), was irreversibly deleted when the turn ended — then the
emptied scripts/ and hooks/ dirs too. The plugin's contract is files
"created during Hermes sessions".
A pre_tool_call hook now snapshots which of the call's argument paths
already exist (keyed by tool_call_id; it never raises, since pre hooks
fail closed; entries expire after an hour if the post hook never
fires). post_tool_call tracks a path only when the call named it, it
did not exist before and exists now. Paths seen only in terminal output
carry no such proof and are no longer tracked; no snapshot means
nothing is tracked. Existing tests now drive the real hook order.
(cherry picked from commit e0e0e1f7450df0c75a8499fe57b63dc46c6d2af2)
The fail-closed change dropped the localhost_only() short-circuit from
is_trusted_peer. In no-token mode authenticate() always yields ip:<addr>,
so any install with A2A_TRUSTED_PEERS / a2a.trusted_peers configured
started 403-ing every local dispatch. Restore the short-circuit and pin it
in test_localhost_trusts_all.
Also:
- log the non-loopback/no-allow-list misconfiguration once in
A2AAdapter.connect() instead of an ERROR on every refused request;
- is_loopback_bind reuses agent.proxy_bypass.is_loopback_host instead of a
hand-rolled loopback check;
- fold the loopback backward-compat test into
test_no_allowlist_trusts_authenticated (now explicit A2A_HOST=127.0.0.1)
and drop implementation asserts on is_loopback_bind().
Keep non-loopback+empty allowlist -> closed (the #126756 headline) and
loopback+token+empty allowlist -> open (backward compat). Allowlist
membership and allow-all are already covered by existing TestTrustedPeers
cases.
The inlining step called ref.get() on every reference, so a legacy
image_style_references entry that is neither a string nor a dict raised
AttributeError out of generate() (main forwarded it to Krea). It now passes
through untouched.
The 3 MB budget was checked against stat() and the file read separately,
so a file that grew in between was sent in full. Each file is now read once,
at most one byte past the remaining budget, and the cap applies to those
bytes. The refusal test states each expected error_type and message
directly.
The salvaged inlining checked is_file() and summed st_size before
raise_if_read_blocked(), so a denied path (e.g. HERMES_HOME/.env) answered
"not found" or "over 3 MB" and revealed whether it exists and how big it
is. The guard now runs first, and its denial comes back as the plugin's
normal error dict instead of an exception escaping generate().
Local files are inlined after the managed-gateway LoRA/moodboard fail-fast,
so a request about to be refused no longer reads and base64s up to 3 MB.
The size message is built from the constant. The four salvaged tests
become one embed test and one parametrized refusal test (missing file,
total over the cap, guard-denied path).
- relay-deliver-budget: the ceiling/timeout composition is checked
against the imported constants (which the mirror tests already tie to
the Python defaults) instead of re-reading config_defaults.py.
- terminal env sync: the key recorder also traps get()/[] and is an
instance, the probe fails loudly if the bridge looks up no keys (so the
gateway-only-key check cannot go vacuous), one _cli_env_map() helper
serves both tests, and the docstring says what is actually driven.
- live_system_guard bypass reuses _live_system_guard_is_active().
- retaindb no-client prefetch asserts no thread is spawned through the
module's spawn_context_thread seam, not a private list.
Each changed assertion re-proven red by its production mutation.