3215 Commits
Author SHA1 Message Date
Ben Barclay ca43606920 fix(remote-config): restore the caller's home when the token scope build fails
Build the scope inside the try so a failing build still resets the home
override and is reported as a PlaneCredentialError. Cover the launch
profile's frozen env-only override and a secondary never borrowing the
launch profile's env override.
2026-11-05 23:01:15 -05:00
Ben Barclay 87746862af fix(remote-config): resolve the Nous plane token under its profile's secret scope
Under gateway multiplexing the Remote Config poller (a bare thread) resolved
the Nous token with no secret scope. The Portal override read failed closed,
default routing applied, and a non-production agent's refresh token was sent
to the production Portal, which rejected it and quarantined the login.

_nous_token now binds the owning home's secret scope (owner_secret_scope:
the launch profile's own mapping for the launch home, the profile's files
otherwise) for the resolution and resets it afterwards.
2026-11-05 23:01:15 -05:00
Jan-Stefan Janetzky f02ba87b8c feat(agent): fork sub-agents inherit the memory provider under checkpoint_required
Four call sites fork a second agent with skip_memory=True, which leaves the
child with _memory_manager = None: a fork is not the user, and its scratch
transcript is not the user's memory. With compression.checkpoint_required
armed that saving turns into a kill. The gate is armed globally per agent
instance from config, not from whether the agent has a provider, so the child
arms the same gate its parent did and then meets its first compaction with
nothing to checkpoint against. It raises CompressionCheckpointUnavailable --
"no active provider implements checkpoint API v2" -- and the delegate turn
dies with status=error and a null summary while the parent runs on. The gate
is not bypassed there; it is armed against a provider that was never loaded.

- The four sites (delegate_tool, curator, batch_runner, feishu_comment) pass
  skip_memory=not checkpoint_required_from_config(). Gate off is the default
  and stays byte-identical: skip_memory=True, no provider init, no hook. Same
  shape and the same reason as the two gateway disposable-agent sites that
  already read the key this way (run_turn_hygiene.py,
  slash_commands_session.py). delegate_tool reads it inside the delegated
  profile scope, where the child reads it.
- The detached review and /btw forks (build_cache_parity_fork) are not
  touched: they persist nothing, and _pre_compress_memory_context already
  exempts exactly that shape (decabd79f1), so the gate never fails them.
- checkpoint_required_from_config() in conversation_compression_checkpoint,
  next to the gate's init refusals. One resolver rather than four parses, and
  the call sites need it before an agent exists:
  agent.compression_checkpoint_required is assigned inside the child's own
  init, and the parent's says nothing about the child. It reads
  load_config_readonly() -- the call init_agent makes to arm the gate on that
  child, so parent and child answer from one source and a fork is given a
  provider precisely when the child would refuse to compact without one. An
  unreadable config reads as "not armed", matching init_agent's own except
  around the same load.
- New memory_agent_context kwarg on AIAgent/init_agent, handed to the
  provider's initialize() as agent_context; unset, the platform rule decides
  as before (cron/subagent, else "primary"). The platform rule alone cannot
  scope these forks: the curator runs as platform "curator", the batch runner
  takes its platform from the caller's config, and the Feishu responder sets
  none, so each would file its transcript as the user's own memory in every
  provider that scopes writes by that key. All four pass "subagent" -- the
  value memory_provider.py documents and supermemory gates on. A fresh
  per-site string ("curator", "batch", ...) would drop out of every
  provider's non-primary membership test and re-enable writes the provider
  had deliberately disabled, which is a fail-open on the one path this commit
  opens. platform still separates the shapes.

Costs, taken deliberately rather than hidden: one provider initialize() per
fork (a round trip for a network-backed store, on a path that made none), and
fork transcripts reaching the archive as checkpoints. Both are named at the
call site and in the contract doc, where a provider author reads them.

Ten tests (tests/agent/test_fork_memory_under_checkpoint_gate.py). The
four site probes drive the real spawn functions with the smallest parent stub
they accept and record the child constructor kwargs -- red at all four against
the previous commit with the literal skip_memory=True. The gate-off probes are
the control in the other direction: without them "inherit the provider" could
have shipped as "always inherit". The recorder aborts with a BaseException
because the sites wrap the spawn in except Exception and would have swallowed
an ordinary failure into a green test. The provider-initialize test drives a
real AIAgent init, the only way to observe the keyword at initialize().

Live probe (on the August base), isolated HERMES_HOME with a stub API-v2
provider and the gate armed: before, the child aborts with failure_class
"exception:CompressionCheckpointUnavailable" and delegate_task returns
status=error, summary=null. After, the child initializes the provider
(agent_context "subagent", platform "subagent"), its own session fires
on_pre_compress, no CompressionCheckpointUnavailable appears, and the parent
returns the subagent's answer.

(cherry picked from commit 31af59c54ebff4c6c1861961111700b8e42dba76)
(cherry picked from commit 9bf3feaf8c98dff6e90eadf0f533204adc150a8c)
2026-11-05 19:55:33 -06:00
unsupportedpastels 01888fc206 fix(webapp): the gated Webapp accepts the deployment session token
On the gateway's Webapp behind the sign-in gate, a provider token worked
on its own (bearer header, socket ?token=) only when this listener gave
it to a native app's sign-in. The deployment session token
(HERMES_DASHBOARD_SESSION_TOKEN, literal or file:<path>) that the bundled
session-token provider vouches for was therefore refused with 401 /
close 4401, although the docs promise it authorizes the gated listener.

The bearer, peek_session and socket ?token= checks now call
access_token_works_alone: token_works_alone (looked up at call time) or
the token the registered session-token provider read at startup,
compared in constant time by the provider's matches_token, which its
verify_session also uses. With the provider not registered
(dashboard.session_token_auth enabled: false) the token is refused as on
main. token_works_alone and the refresh route are unchanged from main:
the deployment token has no refresh.
2026-11-05 18:26:06 -05:00
kshitijk4poor e090092285 feat(memory): profile lifecycle and config-storage seams for memory providers
A catalog memory provider (Honcho today) has no way to own its per-profile setup or its
dashboard config file, so core carries that code for it: profile create/rename edit Honcho's
host blocks and the dashboard has a Honcho-only storage kind that imports the plugin's private
modules. These seams let a provider do that work itself; core's Honcho code is untouched here
and is removed in a follow-up once the plugin implements them.

- MemoryProvider.on_profile_created(profile_name, hermes_home) / on_profile_renamed(old, new,
  hermes_home): called from create_profile (CLI, dashboard, Desktop/TUI) and rename_profile for
  the provider the profile's effective config selects, with that profile's home and its own
  .env secrets bound. A failing hook or unreadable config is logged and never fails the create
  or rename. No-ops by default.
- storage=STORAGE_PROVIDER in config_schema.py, with read_config_fields(hermes_home) /
  write_config_fields(values, hermes_home): the provider owns its file layout. Core keeps
  value coercion, placeholders, write-only secrets, the memory.provider switch and the 400 on
  a refused write, and saves secrets to .env only after the provider accepted them.

_record_profile_rename moves to profile_identity.py (profiles.py line budget).
2026-11-05 13:08:14 +05:30
Teknium 7ab170ea6d fix(matrix): a reaction on a settled approval card reports that it expired
Review minor (unsupportedpastels): Matrix ignored a stale card's reaction
silently; it now retires the card and posts platform.matrix.approval.expired,
the same path the local deadline already takes.
2026-11-04 21:40:32 -08:00
Teknium 5ab58da1f6 fix(gateway): a stale approval card no longer approves the next request
Discord already binds its approval view to the tools.approval request id
(4f86e97791, 50f57a131f) and the runner passes ExecApprovalPrompt.request_id
since cfed2ca71f. Every other native-button surface still resolved by
session key alone, FIFO: when the request a card was rendered for had
already settled (timeout, typed /approve), a press on that card resolved
the NEXT pending approval in the session, one the user had not seen.

Telegram, Slack, Feishu, Matrix, WhatsApp Cloud, Teams and the relay
adapter now remember prompt.request_id with the card and resolve through
tools.approval.resolve_gateway_approval_card, which targets only that
request. A card sent without an id keeps the FIFO behaviour.
2026-11-04 21:40:32 -08:00
Teknium c46c34cfcd feat(video-gen): add Wan 2.7 FAL video family (t2v + i2v)
Adds Alibaba's Wan 2.7 to FAL_FAMILIES: fal-ai/wan/v2.7/{text,image}-to-video.
1080p/720p, integer duration 2-15s, negative prompts, seed, native auto audio
(no generate_audio key), i2v drops aspect_ratio (schema omits it).
Schema verified against fal.ai llms.txt + OpenAPI. $0.10/s (720p), $0.15/s (1080p).
2026-11-04 21:31:17 -08:00
Teknium 1206ecf03d fix(feishu): shared picker card, race-safe stale prune, full labels for one-line titles
Review nits: snapshot _choice_picker_state before pruning (the SDK callback thread pops concurrently); send and settle the picker with config.update_multi so a group settle reaches everyone; a one-line title (usage-limit offer) lists the full choice labels as body instead of repeating the header.
2026-11-04 21:29:10 -08:00
Teknium 14cfad0a6b fix(feishu): multi-select clarify card takes a typed free-form answer
Review nit (hint_multi): the hint offers "your own answer" but typed prose was rejected and cancelled the clarify. The card now calls mark_awaiting_text like the base text prompt.
2026-11-04 21:29:10 -08:00
Teknium 04d45f0d7f fix(feishu): clarify/picker card taps answer in webhook mode
Review finding (MAJOR, webhook transport): _handle_webhook_request turned the whole payload into SimpleNamespaces, so action.value failed the dict route check and the settled card was dropped by the fixed {code:0} reply. card.action.trigger now keeps the button value a dict and returns the callback card as the HTTP body; token/signature checks still run first.
2026-11-04 21:29:10 -08:00
Teknium f94194ba73 feat(feishu): interactive clarify cards + native choice picker
Feishu already rendered exec approvals and update prompts as interactive
cards, but the clarify tool fell back to the base numbered-text prompt and
finite-choice slash commands (/reasoning, /fast, usage-limit switch) fell
back to text status cards. This implements the generic adapter contracts
from BasePlatformAdapter instead of a Feishu-only path:

- send_clarify(): multiple-choice questions become a card with one numbered
  button per option plus "Other (type answer)"; a tap answers through
  resolve_gateway_clarify and the callback response settles the card inline
  for every client; "Other" calls mark_awaiting_text. Open-ended questions
  use the base text prompt. Answers come from the pending clarify entry, so
  a tap after a timeout or /new reports the prompt expired.
- retire_clarify_card(): the gateway's hook for a clarify that ended without
  a tap (timeout, reset, superseding prose, typed answer) PATCHes the card
  button-less; the card is a shared card (update_multi) so the update
  reaches every recipient.
- send_choice_picker(): one-tap picker card; the tap settles the card and
  runs on_choice_selected on the adapter loop, then sends its confirmation.
- Every click passes the approval path's _validate_card_action (operator
  authorization incl. the gateway pairing check, callback-vs-card chat
  match); state pops are first-writer-wins, so a double or stale tap is
  refused.

Behaviour lives in plugins/platforms/feishu/adapter_choice_cards.py (mixin);
adapter.py stays at main's length (card-action routing became a table).
New platform.feishu.clarify/picker strings in all 17 locales, reusing each
locale's existing Telegram/Discord wording.
2026-11-04 21:29:10 -08:00
Teknium 4e93f80e47 fix(gateway): end bot-to-bot exchanges: no runtime notices to bots, a runtime hop cap, replies need no answer
Widens #137037 from Mattermost to the whole class (#137027):

- Runtime notices never reach a bot peer: every busy-path reply (ack, redirect,
  drain), lifecycle/status and credits notices, tool progress, interim and thinking
  bubbles, the long-running heartbeat, the auto-reset notice, and the runtime failure
  copy ("not processed", retry hints, generic failure apology) on a failed or empty
  turn. The model's own reply is still delivered.
- Chain rules in gateway.bot_loop_guard (config.yaml): a human message starts a chain,
  each bot message after it is one hop, max_hops (default 4) caps them, a bot repeating
  the same text in one chain is dropped, and with bot_replies_expect_reply false (the
  default) a bot answering the reply this bot just gave it starts no turn. A bot
  message also counts as not expecting a reply, so a silence marker ends it quietly.
- Bot authorship everywhere it is knowable: a generic per-adapter bot_users opt-in
  (config.yaml platform extra) for platforms whose events carry no bot flag,
  Mattermost props.from_bot, QQ guild author.bot.
- Bot Mode message_agent: agent/bot_chain.py owns a per-session hop count carried in
  the delivery's author envelope (runtime-written, parse_turn_author keeps it), refuses
  sends past the same max_hops and identical repeated asks, sends a reply to the bot
  that messaged this turn notify-only (expects_reply false, no wake), and refuses a
  message back to a bot whose reply said it needs no answer. tools/bot_mode_dm.py gets
  only the gate call and the wake flag (LANE dm owns that file; same wake names as #144789).
2026-11-04 21:01:10 -08:00
KoNit-K cb52533765 fix: contain Mattermost bot peer loops
(cherry picked from commit b21f2e9fdc45335c55d200c2812f895d81ec28fd, #137037;
conflicts resolved onto main: ALLOW_BOTS moved to the Mattermost gateway_env AUTHZ
entry, status suppression moved to gateway/run_turn_progress.py)
2026-11-04 21:01:10 -08:00
Victor Kyriazakos cad0aaaf0d fix(slack): tag thread-context senders against the live message's allowlist
Slack thread context authorized each prior sender as chat_type="thread". The
gateway authz chain only treats group/forum/channel as group chats, so a
channel thread's senders were judged on the DM allowlist: group_allow_from
was never read and every human was tagged [unverified] even under
group_allow_from: ["*"], while a DM-only allow_from user read as verified in
a channel. The live message is authorized as "group" (channel) or "dm"
(IM/MPIM); thread-context senders now get that same chat type plus the
thread id, so the tag marks exactly the senders the live message's allowlist
would refuse. Authz is unchanged, so the Discord "thread" callers keep their
behaviour.

Origin: 0198713c33 (CWE-863 thread-context tagging, #55979).
2026-11-04 11:36:46 -05:00
Maciej Lisiewski 3d4d73e0fd fix(deps): bump pyasn1 to 0.6.5
Moves the google extra pin and the Google Chat staleness probe
(_REQUIRED_PACKAGES) from 0.6.4 to 0.6.5 so a half-synced interpreter
is repaired onto the patched release. pyasn1-modules 0.4.2 allows
<0.7.0. 0.6.5 was published 2026-10-27, past the 3-day release-age
check.

Advisories: GHSA-2qm8-x737-gmw5, GHSA-9m75-vc3g-4x39,
GHSA-fm9c-5rcf-2q5p, GHSA-mm57-4v3p-rchx
2026-11-03 21:06:29 +01:00
Maciej Lisiewski b9621cd0b2 fix(deps): bump zod to 4.6.7
Moves the tests-js direct pin (which the root lock hoists for
@assistant-ui/react and eslint-plugin-react-hooks) and the photon
sidecar's transitive copy from 4.6.5 to 4.6.7, the newest 4.6 patch.
4.6.7 was published 2026-10-12, past the 14-day npm release-age gate.

Advisory: GHSA-mrhx-c7xc-m3w5
2026-11-03 21:06:29 +01:00
Maciej Lisiewski e3085ec023 fix(deps): bump undici to 6.29.1 and 7.30.1
Moves the ui-tui direct pin and the root undici@^6/@^7 overrides, plus
the photon sidecar's direct pin and override, to the patched releases
in each line. Both were published 2026-10-15, past the 14-day npm
release-age gate.

Advisories: GHSA-2m5v-pch6-74hx, GHSA-4qv8-2p9j-2685,
GHSA-4w7w-5xrc-9h26, GHSA-55wc-3pg8-jfw6, GHSA-9fm3-97px-rjf6,
GHSA-f72v-6w45-q472, GHSA-qrxm-fqhp-jwvm, GHSA-xjj6-f33q-929c
2026-11-03 21:06:29 +01:00
kshitijk4poor 5ce74340f9 fix(discord): probe for libopus once, off the event loop
connect() called ctypes.util.find_library("opus") on the gateway loop
whenever opus was not loaded. On Linux find_library runs ldconfig, gcc
and ld subprocesses, and on a host without libopus (the Hermes Cloud
image) it never succeeds, so every Discord connect and reconnect
repeated that blocking work on the loop. On a memory-starved 2 GB
instance both loop-liveness watchdog dumps that restarted the gateway
had the loop thread inside this probe.

Move the opus helpers into adapter_opus.py and probe at most once per
process under a lock; connect() runs it with asyncio.to_thread. A libopus
installed later needs a restart, and the "Opus codec not found" warning
now logs once instead of on every reconnect.

The per-connect probe came in with 0ff1b4ade2 (#1299).
2026-11-03 21:58:19 +05:30
hackartist 53308b1947 feat(slack): add opt-in thread_followup_silence for unaddressed thread replies
A plain unaddressed follow-up in a channel thread the bot participates in
gets reply_expected=None, so a bare [SILENT] is rejected and the gateway
posts the unexpected-silence notice. That is noisy when humans in the
thread talk to each other without @mentioning.

thread_followup_silence (config.extra / SLACK_THREAD_FOLLOWUP_SILENCE,
default false) makes such non-DM thread replies reply_expected=False so the
silence marker stands. DMs, @mentions and flat channels are unchanged,
and the default keeps the #110952 behaviour.

Move the Slack config getter factories into adapter_config_readers.py to
keep adapter.py within its code-health line cap.
2026-11-02 14:54:09 -08:00
Teknium 38bfb94b5c fix(image_gen): OpenRouter's Grok image row is x-ai/grok-imagine-image-2.0
OpenRouter's Image API catalog (GET /api/v1/images/models) no longer lists
x-ai/grok-imagine-image-quality; it lists x-ai/grok-imagine-image-2.0. Swap
the curated _IMAGE_API_MODELS row, with every knob taken from the live
catalog entry: the same 14 aspect ratios, 1K/2K, n=1, 3 references, plus a
quality enum (low|medium) the old row never declared.

The direct xAI backend keeps grok-imagine-image-quality: xAI's authed
/v1/image-generation-models still serves it.
2026-11-02 07:57:26 -08:00
kshitijk4poor df128a1cb4 refactor(telegram): read a video note through the video branch at each site
The video-note support added a second, line-for-line copy of the video
branch in _observed_media_source and _handle_media_message, differing only in
the attribute read and the log label. Take `msg.video or msg.video_note` once
in each place instead, keeping the "video note" label for circles, and rejoin
the video_note entry of the _media_message_type table onto the next line.

adapter.py is over the 2,000-line cap, where units may only shrink: this
brings the file back to its origin/main line count (7380), so the PR adds
no lines to it. Behaviour is unchanged; the video and video-note cases of
test_native_video_is_cached and test_reply_to_a_video_note_caches_it still
pass, and each still fails with its branch removed.
2026-11-01 19:35:16 +05:30
kshitijk4poor 840d104031 fix(telegram): an uncaptioned video note keeps the group silence notice
group_reply_expected treats an uncaptioned voice note or audio file as spoken
(its words are not in the message text), so it returns None and the silence
notice stays. Video notes now reach the agent too, and a circle without a
caption is the same case: the question is in the audio. Without this it came
out reply_expected=False, so in a group with require_mention off a spoken
question in a circle could vanish behind a bare silence marker.

Tests: the new video-note row of
test_reply_expected_follows_who_the_group_message_addresses fails without the
change (False != None) and passes with it. Docs updated in the same sentence
that lists voice notes and audio files.
2026-11-01 19:35:16 +05:30
kshitijk4poor ed960fe960 fix(telegram): localize the download-failure reply for video notes
The video-note branch passes the label "video note" to
_surface_media_cache_failure, which translates the chat reply through
_MEDIA_KIND_KEYS. The label had no entry there, so a German or Japanese user
whose circle failed to download got a translated sentence with the English
words "video note" in it. Map it to the existing kind_video key ("video file"
in every catalog). The agent-visible note keeps the English "video note".

Scope: no new catalog key; a circle is reported as a video file in the reply,
which every locale already translates.

Test: test_video_note_failure_reply_is_localized (added with the test fold
later in this stack) fails with this entry removed.
2026-11-01 19:35:16 +05:30
Horizont367 016bc1df65 feat(telegram): support video note messages 2026-11-01 19:35:16 +05:30
kshitijk4poor 94bd083712 fix(mattermost): tidy the user-ID setup hint and move the plugin test
Follow-up to the cherry-picked setup-text fix (salvage of #147215).

- Wizard step 4 and the plugin's security hint now share the same lines.
  Before this they had drifted: only the wizard said where on the page
  the ID is. Step 4 now says what the step is for instead of opening
  with "the profile dialog does not show your user ID".
- Add a route for non-admins: a logged-in browser tab can open
  /api/v4/users/me with its session cookie and get the user's own ID.
  Mattermost reads the session cookie on API requests and only checks
  CSRF on non-GET requests, so a plain GET works.
- The allowlist help text points back at step 4 again rather than
  repeating one of the lookups.
- Docs (en + zh-Hans): indent the curl block under its list item so the
  list no longer splits in two, add the browser route, note that
  `mmctl user list` needs `--all` past 200 users, and use the canonical
  docs URL (the .html form redirects).
- Move the plugin setup test into tests/gateway/test_mattermost_plugin_setup.py
  next to the existing plugin setup tests and its _patch_setup_io helper,
  so it no longer writes a real .env. Drop the assertions that pinned the
  new wording; keep the ones that pin the old, wrong advice and the two
  documented lookups.

Both setup tests fail on origin/main and pass here.
2026-11-01 19:24:04 +05:30
xxxigm 4e89a6d585 fix(mattermost): stop sending setup to a profile dialog that has no user id
Mattermost documents the id on the System Console user page and on
GET /api/v4/users/username/<username>. /users/me with the bot token is the bot.
2026-11-01 19:24:04 +05:30
Teknium ab8bd7ba4f docs(teams): correct which cards carry the command in their button data
Only cards sent by an older build carry cmd/desc in the button data; cards from
this build carry just the prompt id.
2026-10-31 22:19:18 -07:00
Teknium ce0602e8a9 fix(telegram): catch NetworkError, not TelegramError, after a kept-prompt edit fails
TelegramError is absent from the shared Telegram test stubs, so the import
broke test_slash_confirm_forum_callback_followup_keeps_existing_thread_behavior
with an ImportError. NetworkError covers the same transient cases (timeouts
and network failures subclass it in python-telegram-bot); BadRequest is still
caught first and keeps the decision-only fallback.
2026-10-31 22:19:18 -07:00
Teknium 44678e5647 fix(telegram): network error on a resolved-prompt edit keeps the prompt
Review nit: edit_keeping_prompt suppressed every exception and fell back to the decision-only text, so a transient network error after an edit that went through replaced the kept prompt. Only a BadRequest falls back now.
2026-10-31 22:19:18 -07:00
Teknium 631b00c3f6 refactor(feishu): shared card sender no longer parses approval text
Review finding: _send_interactive_card read card elements and stripped the approval deadline line on every card, including update-prompt cards. _send_exec_approval_prompt now builds the stored body from prompt.text and passes it as extra state.
2026-10-31 22:19:18 -07:00
Teknium 288c0c1cae fix(teams): approval card stays under the size cap for long non-ASCII commands
Review finding: every button's data carried the 2000-char command preview, so a long CJK command made a ~30 KB card, over Teams' ~28 KB limit, and the send fell back to the plain-text prompt. The preview now stays in the adapter keyed by a per-prompt id (bounded to 256 entries); the buttons carry only that id. Cards sent before a restart still read cmd/desc from their button data.
2026-10-31 22:19:18 -07:00
Teknium 02a790293d fix(gateway): timed-out and resolved approval cards keep the full request
- Timeout edit: the card now keeps why the command was flagged next to the
  command (the reason was dropped; the command was already kept).
- Teams: button data carries the card's own command preview instead of a
  200-char stub, so a resolved or expired card shows the whole command.
- Telegram, Slack and Feishu resolved cards drop the "If you don't answer
  within N minutes it will NOT run" line, which no longer applies once
  someone decided (Teams never carried it; timed-out cards already replace
  the body).
2026-10-31 22:19:18 -07:00
Teknium 92c86afb1f fix(gateway): resolved approval cards keep the request on Telegram, Feishu and Teams
Clicking a button on an approval card replaced the whole message with only
the decision ("✅ Approved once by Ana"), so a shared chat lost the record
of WHAT was approved. Discord and Slack already kept the body.

- Telegram: exec-approval and slash-confirm resolutions keep the prompt's
  own rendering (Message.text_html rebuilds it from the entities, code
  block included) and append the decision; a prompt with no readable body,
  or one the appended line pushes past the length cap, still gets the
  decision alone so the buttons never stay live. New sibling module
  telegram_resolved_prompt.py; the adapter facade does not grow.
- Feishu: the card body is kept in the per-card state at send time and
  rendered above the decision on the replacement card.
- Teams: a click after the approval ended replaced the card with only
  "expired"; it now keeps the command and reason like the resolved card.
2026-10-31 22:19:18 -07:00
Teknium 95d0629df4 fix(photon): a misshapen lines payload keeps the iMessage line lookup best-effort
The lookup moved before the opt-in wait and its except was narrowed; an
unexpected `lines` shape then raised AttributeError and aborted setup before
access was configured. Catch AttributeError/TypeError again, as main did.
2026-10-31 22:09:38 -07:00
Teknium ce02e4686d fix(photon): photon status routing row gives up after 5 s on an unreachable API
The live opt-in row reused list_users' 30 s timeout, so a black-holed API stalled
`hermes photon status` (previously offline-only) for 30 s. Thread a timeout through
list_users/find_user_by_phone and use 5 s for the status row only.
2026-10-31 22:09:38 -07:00
Teknium a97f13ff3f fix(photon): setup without a phone number ends on saved-not-live, not "complete"
routing_live defaulted to True, so skipping the phone prompt still printed
"Photon setup complete. Start the gateway" although nothing can route. Start from
False (only an opted-in number flips it) and give the no-phone path its own ending
naming the command that finishes setup.
2026-10-31 22:09:38 -07:00
Teknium 9bc94eb21d fix(photon): look up the iMessage line before the opt-in wait; skip the wait when there is none
A user row without assignedPhoneNumber started a 5-minute wait that told the user only
"text the line Photon assigns": the dedicated-line lookup ran after the wait. Look the
line up first; with still no line, skip the wait and say how to finish (text the line
once it shows up, then `hermes photon status`).
2026-10-31 22:09:38 -07:00
Teknium d47a49d46c fix(photon): setup only says "complete" once the number can route; bound the opt-in wait
The opt-in step let setup end on "✓ Photon setup complete" even when the
wait timed out, was interrupted with Ctrl-C, or was skipped on a non-TTY
run, so the user was told to start the gateway for a number Photon still
refuses ("Target not allowed for this project").

- setup: when the number is not opted in, the closing line says the setup
  was saved but routing is NOT live, names the line to text, and points at
  `hermes photon status`; exit code stays 0 (config was written).
- wait_for_opted_in_user: add a wall-clock deadline next to the attempt
  cap, so slow list calls (30 s HTTP timeout each) cannot stretch the
  advertised 5-minute wait to half an hour.
2026-10-31 22:09:38 -07:00
Teknium 5d9b27cd23 feat(photon): gate setup success on iMessage opt-in via first message
Photon's delivery plane only routes numbers whose user row carries
meta.opt_in, and that flag is set server-side when the human sends ONE
message from their phone to the row's assignedPhoneNumber. A registered
row without it looks configured everywhere but never sends or receives:
outbound fails with 'Target not allowed for this project'. Client-supplied
meta is ignored on create, so the API cannot produce the flag.

- auth.py: user_opted_in(), find_routable_user() (an opted-in duplicate
  wins over list order; find_user_by_phone uses it, so setup re-runs and
  status refreshes reuse the routable row), wait_for_opted_in_user() poll
  (rides out transient list failures).
- cli.py setup step [4/5]: prints the assigned line to text and waits for
  the opt-in to land (Ctrl-C skips; non-TTY runs skip the wait).
- status: live 'routing' row (opted in / not opted in, with the line to
  text), emitted from auth.py like the credential summary.
2026-10-31 22:09:38 -07:00
Teknium 3a73078f3a feat(slack): show emoji reactions in thread context
In channels where people answer by reacting instead of replying, the
thread-context block the agent sees on a cold start dropped reactions, so a
message with twelve check-marks and no replies read as "nobody responded".

conversations.replies already returns each message's reactions array under
the history scopes the adapter requires: no extra API call, no new scope.
Each context line now ends with a compact marker such as
"[reactions: :white_check_mark:x12 👀]" (up to 8 distinct emoji, then
"+N more"). Emoji names are reduced to Slack's emoji-name charset so a
crafted name cannot forge context structure, and the marker stays out of
parent_text, which drives mention wake checks.

The helper lives in the adapter_reactions_context sibling so the adapter
facade only gains the call site. Read-side only: it does not route
reaction_added events (#10583).
2026-10-31 22:08:19 -07:00
kshitijk4poor c419cf7c86 fix(telegram): replies to a channel or anonymous-admin post keep the notice
Telegram fills from_user with a service account (777000 for a linked
channel's auto-forward, GroupAnonymousBot for an anonymous admin) and sets
sender_chat on posts signed by a chat. Those counted as replies to another
member, so in a discussion group with require_mention off a question
commenting on a channel post (including the bot's own channel's post) came
out reply_expected=False and could vanish behind a bare silence marker. A
reply to a chat-signed post is now treated like a reply to an unknown author
(None, the notice stays). Only a reply to another known member is chatter.

_replies_to_other_member had a single caller whose earlier branches already
settle its reply-to-bot and unknown-author checks, so the reply decision is
now one branch in group_reply_expected; _quoted_reply gains its return type.
Behaviour for every existing test row is unchanged.

Tests: the channel auto-forward and anonymous-admin rows fail on the previous
commit and pass here (68 passed with test_telegram_thread_fallback.py).
2026-10-31 15:33:49 +05:30
kshitijk4poor 61794f3f17 fix(telegram): a reply to an unknown author keeps the notice without a "?"
A group reply whose quoted message has no from_user was not counted as a
reply to another member, but then fell through to the chatter rule, so a
statement like "agreed" came out reply_expected=False and a bare silence
marker sent nothing. The Telegram docs (and the WhatsApp rule in #133672)
promise the notice whenever the quoted author is unknown. Such replies now
return None before the chatter rule. The forum-topic-root check moves into a
small _quoted_reply helper both paths share; reply-to-bot and topic-root
behaviour is unchanged.

Found by the automated review on this PR. Test: the new statement row fails on
the previous commit and passes here; the existing "agreed?" row passed on both
because the question rule hid the gap.
2026-10-31 15:33:49 +05:30
kshitijk4poor 5bb0e7d87d fix(telegram): uncaptioned voice notes and audio files keep the silence notice in groups
group_reply_expected judges the message text or caption, and a voice note or
audio file without a caption has none: no "?", so it came out
reply_expected=False. In a group with require_mention off, a spoken question
to the bot could then vanish behind a bare silence marker. Voice and audio
without a caption now return None (the notice stays); a captioned one is
judged by its caption, and photos, stickers, documents and locations are
unchanged.

Tests: the voice-note and audio-file rows fail on the previous commit and pass
here; the captioned-voice and uncaptioned-photo rows are controls.
2026-10-31 15:33:49 +05:30
Diego Sánchez e97cdd16fe fix(telegram): match the WhatsApp rule on unknown reply authors and indented commands
Review follow-ups from a standards audit:
- A reply whose quoted author is unknown is no longer treated as a reply to
  another member, so a question there keeps the notice, as in #133672.
- A command with leading whitespace counts as addressed to the bot, matching
  how the gateway parses commands.
- _merge_into_pending keeps the original seed semantics (get/None) instead of
  setdefault, while staying at the adapter's line cap.
- The photo-batch test now runs the merged turn through the runner's delivery
  path: unaddressed silence is not sent, the addressed batch gets the notice.
- Docs note the unknown-identity, unknown-author and forum-root cases.
2026-10-31 15:33:49 +05:30
Diego Sánchez dc6519da4a fix(telegram): let a silence marker stand on group messages not addressed to the bot
The Telegram adapter never set MessageEvent.reply_expected, so silence_allowed()
always saw None and every bare [SILENT] / NO_REPLY on a group message became the
"returned only a silence marker" notice, posted to the whole group (#120051).

group_reply_expected (telegram_context.py) sets the per-message signal from #123271,
with the same rule the WhatsApp adapter gets in #133672:
- True for DMs and for a group message addressed to this bot: @mention, reply to
  the bot, wake word (mention_patterns) or /command, in text or a caption. The
  #110952 fallback stays.
- False for a group message addressed to someone else (a reply to another member,
  or one that opens by @mentioning someone else) and for plain chatter. A forum
  topic's root anchor is not a reply to a person.
- None for channel posts, an unknown bot identity, and an unaddressed message with
  a "?", which may be a question for the bot.

Photo bursts and albums merge through absorb_reply_expected, so a batch that
includes a photo addressed to the bot stays True.
2026-10-31 15:33:49 +05:30
Jeffrey Quesnelle 50d2adf1cd Merge pull request #146715 from NousResearch/fix/credential-stores-top-level
fix(secrets): move secret caches and the Photon token to the profile home root
2026-10-30 23:49:30 -04:00
emozilla f413c2ee9a fix(secrets): delete old secret-store copies instead of moving them
The agent can write cache/ and runtime/, so a file there may be a link or a
forged record. Hermes now deletes a Bitwarden or 1Password cache in cache/, or
a Photon sidecar record in runtime/, on first use without reading it, and
reads and writes only the copy at the top of the profile home. All four stores
are regenerated on use.

- Remove utils.adopt_legacy_file and the paths built on it.
- home_data_layout: split SECRET_CACHE_STORES into SECRET_CACHE_FILES and
  LEGACY_SECRET_CACHE_PATHS.
- Backups leave out the four top-level names; the old slashed entries never
  matched a root entry.
- The dashboard Files API takes its hidden names from the shared list.
2026-10-30 13:46:07 -04:00
Ben Barclay e924f75176 fix(redact): the log formatter reads no config
RedactingFormatter resolved a routed profile's redaction policy per
record from config, under the logging handler's lock. It now reads only
in-memory state: the live secret scope, and the policy recorded when that
profile's .env or config is loaded, or when log routing is set up for it
(resolve_profile_policy). Unknown means redact, the secure default.

This removes load_config_readonly_nowait() and the no-boot read scope.
2026-10-30 11:13:02 -04:00
Ben Barclay 8ae88edc8a fix(redact): the log formatter never waits for config
RedactingFormatter reads a routed profile's redaction policy from config
while holding the logging handler's lock. A thread holding _CONFIG_LOCK
(for example one booting a remote profile) that logs then waits for the
handler lock: a lock-order cycle. On the booting thread itself the read
re-entered the boot and ConfigBackendUnavailable escaped the formatter.

load_config_readonly_nowait() returns None instead of waiting: when
_CONFIG_LOCK is held by another thread, or when the read would start a
remote boot fetch (config_backend.without_boot()). The formatter then
redacts (the secure default) and does not cache that answer.

The remote backend's lock-order tests move to their own file.
2026-10-30 11:13:02 -04:00