5212 Commits

Author SHA1 Message Date
Victor Kyriazakos
b08bb5afb8 fix(gateway): a bare silence marker on a turn not addressed to the bot stays silent
Since 5ea8fb2b78 (#111624, for #110952) the gateway rejects a bare silence
marker on any human turn and delivers "The model returned only a silence
marker for a message that needed a reply" instead. That protects a human
who asked this bot something and got nothing back. It also fires on every
human message the adapter admitted without the bot being addressed at all:
a free-response channel, a thread follow-up under
`thread_require_mention: false`, or a message @-mentioning another person
or bot with `ignore_other_user_mentions: false`. A bot whose SOUL declines
peer-addressed turns with a deliberate marker now posts that notice on
every such message. A fleet running several bots in shared Slack threads
reported it as spam on v2026.9.21. #37940 established that intentional
silence must not be re-inflated. Both contracts hold once the turn knows
whether a reply was expected.

`MessageEvent.reply_expected` (True, False, None) is set by the adapter
where the message is admitted. Slack (`slack_reply_expected`): a 1:1 DM,
an @mention of this bot or a command is True, anything else it admits is
False. Other adapters leave None, which keeps today's behaviour, so nothing
changes for them until they are ported. `response_filters.silence_allowed`
holds the one rule (machinery turn, or reply not expected) and both call
sites use it: the live turn in `run_turn._hmwa_shape_agent_response` and
the crash-recovery redelivery from #120377 (1136f135dd), which reads the
flag back from the persisted turn metadata. The suppressed case logs one
DEBUG line naming platform and chat.

Operator workaround until this lands: `platforms.slack.extra.
ignore_other_user_mentions: true` drops peer-addressed messages before a
turn exists.

(cherry picked from commit 094439776ab898cccde303a1c2c911c8ab5bfb75)
2026-09-26 07:21:49 +05:30
kshitijk4poor
e13b5e71ef fix(gateway): bind the secondary callback scope without on-loop secret hydration
The adapter auth check is synchronous and runs on the adapter's event loop:
once per inline-button tap, and once per keystroke for Telegram inline
queries. Entering `_profile_runtime_scope` with its default
`hydrate_secrets=True` there calls `hydrate_profile_secret_sources`, which
takes the process-global secret-source lock and may resolve external secret
backends. That lock contention is the heartbeat-starvation class #99519
moved off the loop.

Startup and the per-profile message path already hydrate this profile's
sources off-loop, so the callback now binds the scope with
`hydrate_secrets=False` (the reconnect-retry precedent in the same module):
`build_profile_secret_scope` still re-reads the profile's `.env` per call,
so allowlist edits keep reaching the next tap.

The regression test also seeds the default profile's allowlist (file and
live os.environ) with a different user and asserts the secondary's bot
refuses them; on main that user was admitted on the secondary's buttons.
2026-09-26 06:41:34 +05:30
liuhao1024
3e8fe26a2e fix(gateway): enter the secondary profile scope per callback, not from a configure-time snapshot
Review feedback (#120650): the prebuilt secret scope froze the profile's
`.env` at configure time, so an operator's runtime TELEGRAM_ALLOWED_USERS
edit reached the next text message (per-message scope re-read) but not the
next button tap, and it skipped hydrate_profile_secret_sources. Enter the
owning profile's runtime scope per call via _scope_or_null — the exact
pattern every sibling secondary handler already uses — so taps and messages
share one freshness and hydration story, and an unresolvable profile home
still binds nothing (fail-closed).
2026-09-26 06:41:34 +05:30
liuhao1024
b483f0f65b fix(gateway): authorize a secondary bot's callbacks under its own profile scope
Under multiplex_profiles, a secondary profile that owns its own Telegram bot
gets its auth callback from _make_adapter_auth_check(profile_name=...), but
that branch ran _is_user_authorized with NO profile scope installed: the
callback is built at configure time and invoked from the adapter's event
loop, outside _profile_runtime_scope. The gate's scoped env read therefore
fell back to os.environ — the default profile's env — so an allowlisted
caller tapping an inline button (exec approval ea:) was refused with the
'bot is private' toast, while plain text messages from the same user in the
same DM were authorized (their handler runs inside the scope). This is the
per-credential secondary-bot row of the transport matrix; the shared
primary row was fixed in 74775df53f (#86296).

The secondary branch now re-enters the owning profile's runtime scope per
call, mirroring _make_profile_message_handler. The secret scope is prebuilt
at configure time so entering it in the callback never does file IO on the
event loop; an unresolvable profile home keeps the fail-closed behavior of
every other secondary handler. (#120639)
2026-09-26 06:41:34 +05:30
Flownium
9d81deb0f3 fix(gateway): publish API server heartbeat and metrics after bind
The api_server platform wrote runtime status exactly once at bind (the
_connected mark) and never again: last_heartbeat stayed at boot time and
metrics_today froze at zero, so the dashboard showed stale API Server
activity until a full app restart (#52323).

The adapter now keeps daily request/message/token counters and a bounded
latency sample, publishes a metrics-bearing snapshot at bind, records
metrics after each completed _run_agent turn and /v1/runs run, and a
30-second heartbeat loop re-publishes while connected. gateway.status
gains a platform_metrics field on the platform payload, and
/health/detailed serves the live adapter metrics alongside the
persisted platform map.

Salvaged from #52345 by @itsflownium (Flownium) — reworked onto current
main (run-worker submission, bind-retry loop, readiness work counts).

Fixes #52323

Co-authored-by: Flownium <157689911+itsflownium@users.noreply.github.com>
2026-09-25 17:53:43 -05:00
brooklyn!
34343e79ab fix(gateway): stop anchoring synthetic process notifications to the stale trigger message (#123097)
Background-process completions and watch events re-enter the gateway as
synthetic MessageEvent(internal=True) turns carrying the id of the message
that STARTED the process (captured at spawn time via
HERMES_SESSION_MESSAGE_ID, persisted as watcher_message_id, replayed on the
queued event). By delivery time that message is old news: the user has
typically continued elsewhere, and _reply_anchor_for_event made the finished
job's reply quote it on every reply-anchoring platform — on Discord the
completion visibly answered a stale DM message from a different topic
(#52694).

The synthetic event is not a reply to the trigger message. Drop the id from
the event and from the restored origin source (the same strip
run_goals._synthetic_prompt_event applies to goal/loop prompts), keep it in
event metadata as original_trigger_message_id for debugging, and leave
routing untouched: thread/topic lanes carry thread_id and the anchor-less
synthetic-send branches are already covered (#87051).

Fixes #52694

Co-authored-by: Hermes Agent <agent@hermes.local>
2026-09-25 21:28:55 +00:00
Hermes Agent
ec5c9c738a fix(processes): persist_on_release keeps background jobs alive across lifecycle kill sweeps (#41225)
Background processes spawned with terminal(background=true) are killed from
three agent-lifecycle sweeps: agent release()'s kill_all, a gateway turn
timeout's kill_started_since, and agent close's owned-process loop. Jobs the
user explicitly wants to outlive the session (overnight batches, watchful
daemons) had no way to opt out.

Add terminal(background=true, persist_on_release=true):
- ProcessSession.persist_on_release, stamped by spawn_local/spawn_via_env,
  carried in crash-recovery checkpoints and exposed via list_sessions()
- kill_all skips persisted sessions only for lifecycle sources
  (_LIFECYCLE_KILL_SOURCES: kill_all, gateway_turn_timeout, agent_close);
  explicit operator stops (process_manage kill, /stop slash + RPC mirror,
  CLI /stop) now pass distinct sources so they still reach persisted jobs
- the agent_close owned-process loop in _close_task_resources skips
  persisted sessions the same way
- gateway shutdown keeps killing persisted jobs (source=gateway_shutdown):
  the host is going away and survivors would become PPID=1 orphans

Co-authored-by: salvaged from #109846 (persist_on_release plumbing) and
extended to the turn-timeout and agent_close paths.
2026-09-25 13:49:31 -05:00
Austin Pickett
fae9e5677a fix(update): stop reading gateway identity off the Windows restart watcher's argv (#107002) (#121635)
* fix(update): stop reading gateway identity off the restart watcher's argv (#107002)

The detached restart watcher is spawned as
`python -c <watcher source> <old_pid> <python> -m hermes_cli.main gateway run`.
Its trailing argv is the command it will spawn LATER, but the canonical matchers
read identity straight off the joined command line, so the watcher itself was
classified as a live `gateway run` process — the documented "never infer process
identity from argv substrings" bug class, on the exact surface `hermes update`
uses to verify a post-update relaunch.

Also budget the post-relaunch liveness poll against the watcher's own deadline:
the watcher respawns the gateway only after the PID it was handed exits, so a
30 s window can expire before the relaunch it verifies was scheduled to start.

* test(windows-live): run the gateway-ancestor harness parent from a script file

A `python -c <src>` parent is an interpreter running inline source and carries
no readable Hermes identity, so it is no longer a gateway to any classifier —
the harness's own comment already said a realistic gateway argv is not a -c blob.

* test(windows-live): share one sleeper SCRIPT across the live process-topology fixtures

Four live Windows E2E files stood processes up as `python -c "sleep" <hermes argv tail>`.
That shape no longer carries a readable Hermes identity, so the fixtures stopped standing
in for the gateways they simulate. One shared sleeper script replaces the -c spelling.

* fix(gateway): drop the duplicated _INLINE_SOURCE_FLAG_RE definition

The constant was emitted twice around command_line_runs_inline_source. Same
pattern both times, so behaviour is unchanged — but one definition is enough.

* test(stderr-timestamp): run the gateway-lookalike children from script files

Both lookalikes stood a gateway child up as `python -c <src> <gateway tail>`.
That shape no longer carries a readable Hermes identity (#107002), so the
wrapper correctly stopped treating them as gateway spawns and the tests failed.
A `-c` tail is data for a program the inline source may spawn LATER, never the
child's own identity — the real wrapper child is `python -m hermes_cli.main
gateway run`, which has no `-c`. Running the stand-ins from a real script file
restores what the tests mean to assert without depending on the misread.

* test(windows-live): restore the tempfile import dropped with the local sleeper helper

* test(windows-live): wait on the sleeper SCRIPT name, not its source text

The live fixtures proved argv visibility by waiting for `time.sleep(120)` in the
spawned process's command line. That string only ever appeared there because the
sleeper was spelled `python -c "import time; time.sleep(120)"`; now that it runs
from a file the source is in the file, so the probe timed out ("sleeper argv
never visible") even though the argv was perfectly visible.

Wait on the script name instead, exported as SLEEPER_MARKER next to the script
so the probe and the spelling cannot drift apart again.

* fix(tests,gateway): keep the live-system guard blocking -c-wrapped gateway spawns

The #107002 identity fix made _gateway_command_subcommand return None for
'python -c <src> … -m hermes_cli.main gateway run'. tests/_fixtures/live_system_guard.py
shares that matcher, so the autouse guard stopped blocking the detached restart
watcher: real gateways leaked out of the e2e run and squatted the webhook port.

Add gateway.status.gateway_spawn_intent_subcommand — the spawn-intent mirror of the
identity matcher, peeling the inline-source wrapper token-wise and re-running the same
canonical matcher on each suffix (still no substring matching) — and point the guard at
it. Read-only subcommands stay spawnable.

* fix(gateway): make the inline-source option walk value-aware so -X utf8 -c is not read as a gateway

The walk that decides whether a command line is an interpreter running inline
source (`python -c <src> ...`) treated every token starting with `-` as a flag
and the first non-flag token as the end of the option block. CPython options
that take a SEPARATE operand (-X/-W/-Q, --check-hash-based-pycs, --jit) break
that model: the operand was mistaken for the end of the block, so the walk
never reached the -c behind it and the watcher was read as a live gateway
again -- exactly the #107002 misclassification, one shape further out.

- Reuse the canonical operand sets from hermes_state_holders rather than
  hand-rolling a second copy (AGENTS.md: parser-derived flag sets).
- Walk case-preserving tokens: operand-taking -Q/-W/-X must not be conflated
  with operand-less -q/-b, so callers no longer lowercase before the walk.
- Handle clustered short options precisely (-uc is inline source, -Xc is -X c).
- Replace the ad-hoc _INLINE_SOURCE_FLAG_RE rescan in
  gateway_spawn_intent_subcommand with the index the same walk returns; the
  regex could not find spellings the walk accepts and would raise
  StopIteration.

Reported by an automated review on PR #121635 and reproduced here.

Refs #107002

---------

Co-authored-by: Austin Pickett <austinpickett@users.noreply.github.com>
2026-09-25 14:15:22 -04:00
Hermes Agent
d0cb567273 fix(gateway): take host identity from the live host record on replay
The settled-flag fix covers a process that multiplexes itself. The update
and fleet processes replay a FOREIGN gateway's captured argv with no
settled flag of their own, so a selector-less argv fell back to the
ambient HERMES_HOME comparison — the exact coordinate the review rejects
(#93943): a host launched from a named profile was replayed as that
profile, donating the named credentials to the respawned host.

Both restart edges now consult, in order: this process's settled
multiplex verdict, then the live host gateway's published rendezvous
record (its SETTLED served set, proven live), and only then the
compatibility default-root comparison. The raw config re-read stays last
so no settled identity exists => unchanged compatibility behavior.

Regressions: selector-less replay from a named home with a live host
record is host; without one it stays profile-scoped; the restart watcher
env takes the default root and drops the named token when only the host
record proves hostness.
2026-09-25 12:01:44 -05:00
Hermes Agent
8bde3a72e8 fix(gateway): preserve settled host identity across restart 2026-09-25 12:01:44 -05:00
brooklyn!
bfdeb64474 fix(gateway): keep the venv on the Windows restart watcher
The watcher env no longer copies the parent dotenv, so VIRTUAL_ENV was
missing and the respawn could not import hermes.
2026-09-25 12:01:44 -05:00
brooklyn!
1ac24fa209 fix(gateway): do not let a launching profile own the host gateway
A profile-scoped parent donated its environ to the host multiplexer, so a
named launcher was treated as the primary adapter owner and its platform
token became the primary claim. Spawn the host with served_profile_child_env
for the default root, mark multiplex active before that primary load, and
name the env-derived side in a duplicate-credential refusal.
2026-09-25 12:01:44 -05:00
Hermes Agent
85c7e87fc7 fix(gateway): recognize Atomic desktop-gateway.py in gateway detection
Atomic Hermes' bundled desktop runner (desktop-gateway.py) shares
HERMES_HOME with the CLI but was not recognized by the gateway
command-line matcher, so `gateway run --replace` skipped the
terminate-and-scoped-lock-handoff path and collided with the desktop
runner's still-held scoped locks (e.g. the Discord bot-token lock),
leaving Discord responses down until the desktop gateway was killed by
hand. Recognize desktop-gateway.py as a gateway entrypoint in
_gateway_command_subcommand, covering both live process detection and
the PID-record metadata fallback.

Co-authored-by: namhyuk kim <happykimnh@icloud.com>
2026-09-25 11:23:44 -05:00
kshitijk4poor
4a0d698640 fix(api-server): one _sse_headers helper resolves CORS for every in-handler SSE writer
Routes session chat stream, the live-bot-chat SSE sibling, /v1/runs/{id}/events
and the OpenAI-compat stream through a shared helper so CORS reaches the wire
before prepare() flushes the head (#72892, #6358).

Co-authored-by: kawanoii <61106070+kawanoii@users.noreply.github.com>
2026-09-25 21:30:22 +05:30
luyifan
5af9ac8ed1 fix(api): classify failed tool stream events
(cherry picked from commit 5ab0a5ee26e61d4e96d65e1dacdc2b2e1e524f1b)
2026-09-25 21:30:22 +05:30
0xGr1mm
6c9f61efed fix(api): flush the SSE head before waiting for the first run event
`_handle_run_events` called `response.prepare(request)` and went straight
into the queue wait without writing a byte. aiohttp keeps the response head
in the socket buffer until the first body write, so a client that subscribes
before the run emits anything receives no headers at all — `fetch()` and
`EventSource` never resolve and the stream looks dead rather than connected.

The approval flow is the worst case. `approval.request` is only emitted once
the model has thought about the turn, routinely 20-60s, and a UI that
subscribes right after `POST /v1/runs` is supposed to be showing "connected"
that whole time. Instead it has nothing: no status, no headers, no way to
tell a live stream from a hung one. Absent any event, the first write is the
30s keepalive, so that is the ceiling on time-to-first-byte.

Write a single SSE comment frame right after `prepare()`. Comment lines are
ignored by every conforming consumer — `EventSource` dispatches nothing for
them — so this changes the head's arrival time and nothing else.

Scoped to the reported handler. Three sibling streams in this file
(chat-completions and the two responses streams) share the shape; they write
their first frame promptly enough that the same wait has not been reported
against them, and widening a latency fix into them without that evidence
would be guessing.

Tests drive the real handler over aiohttp's TestClient with a registered run
whose queue never fills — the exact "subscribed before the first event"
shape. On unpatched main the first-byte read times out; with the flush it
returns immediately. A second test pins that the preamble stays a comment, so
no consumer can mistake it for an event.

Refs #80757

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit e3968505194f1e64e58e28402a47fcf5c7c0bcf2)
2026-09-25 21:30:22 +05:30
nikkoxgonzales
c7b8e44ffe fix(api-server): send CORS headers on run events SSE stream
(cherry picked from commit 47dc4fc3fc34e0850185b161173a81397bbcb77b)
2026-09-25 21:30:22 +05:30
Tranquil-Flow
a49fc38b4a fix(api-server): resolve CORS headers on session chat stream SSE response (#72892)
The CORS middleware cannot inject headers into StreamResponse after
prepare() flushes them, so streaming endpoints must resolve CORS headers
up front. _handle_session_chat_stream was the only SSE handler that did
not, leaving Access-Control-Allow-Origin absent on the streamed response
even when API_SERVER_CORS_ORIGINS was configured and the origin was
allowed. The sibling endpoints /v1/chat/completions and /v1/responses
already applied the same pattern; this applies it to the session stream.

(cherry picked from commit a2355e12a646a7baf32ff011a8bea358fdccc2c9)
2026-09-25 21:30:22 +05:30
kshitijk4poor
0c84aff676 fix(gateway): register the detached hygiene worker with shutdown at submit time
The session-hygiene compressor runs on the loop's default executor
(run_in_executor(None, ...)), which the self._executor quiesce never joins.
It was only registered with _track_deferred_agent_worker once a timeout,
turn-hold or unwind deferred it, so a gateway stop() that reached the
SessionDB close while the awaiting turn was still waiting saw zero deferred
workers and closed/checkpointed state.db under the worker's late write
(#101064 shape).

Track the future right after run_in_executor, mirroring
run_codex_hygiene_compaction. The existing close guard (#102198) now skips
close:session_db while the worker is live, and shutdown's interrupt pass
reaches the hygiene agent. _defer_agent_cleanup_until_future_done is kept
for cleanup; re-tracking the same future is idempotent (dict key).

The default executor is deliberately NOT shut down: stop() still uses
asyncio.to_thread afterwards (terminal runtime-status flush).

Regression test adapted from @pmaho's
test_default_executor_worker_is_seen_by_the_close_guard (#121360), now
driving the real _hmwa_hygiene_detached_attempt submission site.

Co-authored-by: pmaho <42786356+pmaho@users.noreply.github.com>
2026-09-25 21:25:57 +05:30
kshitijk4poor
61286a889e refactor(api): single source of truth for run SSE subscribers (#25583)
- drop _run_stream_subscribers; the sweep reads _RunStream.subscribers
- per-write timeout via asyncio.timeout (no Task per token), force_close kept
- _sse_frame(id=) instead of hand-prepended id: lines
- reconnect queue gets headroom for the replay length
2026-09-25 14:27:23 +05:30
kshitijk4poor
eea4419ddc fix(api_server): honour platforms.api_server.tool_progress_events for Chat Completions SSE (#12020)
Strict OpenAI clients reject the named hermes.tool.progress SSE frames. Setting
tool_progress_events: false under platforms.api_server (loaded into
PlatformConfig.extra by from_dict) now drops them; default stays on.
Reimplements the intent of #42640 against the adapter config actually read in
production. Overlaps #49069 (erikerosev).

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-09-25 14:27:23 +05:30
doniocode
10963689dc fix(api): sweep overflowed run SSE transports
(cherry picked from commit 6ac7f627265745db816163a4bc26fdbb7aca44e6)
2026-09-25 14:27:23 +05:30
doniocode
4937863e4d fix(api): bound and harden run SSE transports
(cherry picked from commit 11c1d792bbab68bf9f99b2080b6095d58082c0a7)
2026-09-25 14:27:23 +05:30
doniocode
52a2835136 fix(api): fan out and replay run SSE events
(cherry picked from commit 2cb4751f571c3985fac3ddb4199c48833fde940c)
2026-09-25 14:27:23 +05:30
kshitijk4poor
e42b61be43 fix(api_server): emit final_response on chat-completions SSE when no deltas streamed (#31449)
Recovery paths (guardrail halt, partial_stream_recovery) can return a
final_response without firing any content delta; /v1/chat/completions
streaming then closed with an empty body. Mirror _ResponsesStream.collect_result.

Co-authored-by: fmercurio <15571697+fmercurio@users.noreply.github.com>
2026-09-25 14:27:23 +05:30
ethernet
0f65d698d0 Merge remote-tracking branch 'origin/main' into ethie/pm-clean 2026-09-24 13:10:14 -04:00
kshitijk4poor
4cc2b17e8e fix(gateway): log failed transformed-final edits; make fail_result required
The transformed-branch caller passed fail_result=None, so a rejected edit was
silent. Give it a real log string, drop the None branch and fix the docstring.
2026-09-24 22:31:37 +05:30
kshitijk4poor
20fb755609 refactor(gateway): single StreamingConfig.enabled_for gate for both streaming sites (#53697)
Both the TurnRunner and proxy paths now call StreamingConfig.enabled_for(), so the
master-switch + per-platform override logic lives in one place. The proxy path also
returns before re-reading config.yaml when the global switch is off.
2026-09-24 22:31:37 +05:30
xz-dev
3b3890bb15 fix(matrix): enable progressive response edits
Signed-off-by: xz-dev <xz-dev@users.noreply.github.com>
(cherry picked from commit e6988dbfbf3cdadf074d7e9f977c4ff63aef2906)
2026-09-24 22:31:37 +05:30
KoNit-K
6882b64ff5 fix(gateway): retain transformed api stream output
(cherry picked from commit 222db214a102aaf683dfe3f9996eb2844820859b)
2026-09-24 22:31:37 +05:30
Sahil-SS9
ad5271b217 fix: gate per-platform streaming behind global streaming.enabled (#53697)
The effective streaming resolver in gateway/run.py had two sites where
per-platform display.platforms.<plat>.streaming values were used directly
without checking the global streaming.enabled master switch.

When  was set globally, the Telegram platform
still had streaming enabled because its per-platform default
bypassed the global gate.

Fix: introduce an intermediate  variable that
combines  and , then AND it with
the per-platform override (or default-true when no override exists).

This ensures streaming.enabled: false acts as a hard global kill switch
regardless of per-platform defaults or overrides.

Affects two resolver sites:
- Line ~14854: session streaming setup
- Line ~16034: stream consumer initialization

(cherry picked from commit 2b210eb2a0e0b9421dae9e3c1984ceb59760e3a3)
2026-09-24 22:31:37 +05:30
kshitijk4poor
3a271156f4 fix(api-server): make streamed chat-completion approvals resolvable via /v1/runs
Stamp owner + running status for the completion id so POST /v1/runs/{id}/approval
reaches the mapping instead of 404ing in _load_owned_run; retire it (terminal status via
terminal_run_status, owner release) from the single existing EOS done-callback through a
new _spawn_stream_agent on_done hook. Extract _approval_request_event (redact + choices +
_run_event envelope) for the runs bridge, session stream and chat completions; both new
surfaces park waiting_for_approval like /v1/runs. _run_agent reuses
_unregister_approval_notify.
2026-09-24 22:30:59 +05:30
kshitijk4poor
583d5b407b fix(api-server): route approvals on /api/sessions/{id}/chat/stream to POST /v1/runs/{run_id}/approval (#58856)
Session chat streams already register the agent under the run id (stop works),
but guarded tools had no approval notifier and failed closed. Register a
run-id-keyed approval session, emit approval.request on the SSE stream, and drop
the mapping when the turn ends.

Co-authored-by: baleian <baleian90@gmail.com>
2026-09-24 22:30:59 +05:30
kshitijk4poor
8aebb1c00e fix(api-server): mark streamed completion run terminal when the turn ends (#51871) 2026-09-24 22:30:59 +05:30
liuhao1024
ee8eeb3172 fix(api-server): emit approval events on legacy chat-completions SSE stream
Hand-grafted from #51878 onto the split api_server_openai_routes.py; approvals keyed
by completion id and resolved via POST /v1/runs/{id}/approval (#51871).
2026-09-24 22:30:59 +05:30
kshitijk4poor
f026de7d4d fix(stream): thread only full fallback resends
The contributor's hunk passed reply_to=_initial_reply_to_id on every
fallback chunk, which also re-threaded tail continuations on every
platform. On main this chunk loop sends with no reply_to at all, so a
full resend (Feishu post vs cleaned-text prefix mismatch, #103068) lands
unthreaded next to the preview it replaces.

Thread only the full-resend case (continuation == final_text), and only
its first chunk. Tail continuations keep main's unthreaded delivery.

Co-authored-by: HanYunChenLuo <23613397+HanYunChenLuo@users.noreply.github.com>
Co-authored-by: Bartok9 <259807879+Bartok9@users.noreply.github.com>
Co-authored-by: huangyoje <25878791+huangyoje@users.noreply.github.com>
2026-09-24 22:28:19 +05:30
686f6c61
11d5fb153d fix(feishu): delete truncated stream previews on fallback
Feishu had no delete_message, so a failed finalize-edit plus fallback
send left the truncated edit bubble next to the full final. Implement
the SDK delete and thread the fallback send to the originating message.

(cherry picked from commit c61add84ad40b1bc39288405a0a05b4f621e00fc)
2026-09-24 22:28:19 +05:30
kshitijk4poor
a7c44e9ede docs(gateway): say the sub-floor segment-break preamble is dropped, not accumulated
The #99026 guard returns True at a tool-boundary finalize, so _end_segment
treats the segment as delivered and resets it: a 1-3 char preamble ("Ok")
is discarded on non-cumulative transports. Carrying it into the next
segment is not done here: the authoritative finish(final_text) adoption
replaces the carried buffer wholesale, and coalesced 'Ok'×N crosses the
floor and lands as its own message anyway. Record the tradeoff.
2026-09-24 22:27:32 +05:30
kshitijk4poor
5a7c26d5c1 refactor(gateway): resolve streaming len budget via guarded _fallback_len_budget
Reuse StreamFallbackMixin._fallback_len_budget() (message_len_fn default,
debug-logged per-chat override) instead of a second silent try/except ladder,
so the live budget and the fallback chunker can't drift. Drop the
message_len_fn=None test: message_len_fn is a BasePlatformAdapter property,
so that state is unreachable for real adapters.
2026-09-24 22:27:32 +05:30
liuhao1024
d02a254912 fix(gateway): gate sub-floor preamble finalizes on tool boundaries
A segment-break finalize never carries the streaming cursor (the cursor
is only appended to mid-stream frames), so the sub-floor
standalone-message guard's cursor-membership test let every 1-2 token
preamble land as its own durable message at each tool boundary. Each
landing fired on_new_message, resetting the gateway's tool-progress
anchor and fragmenting accumulated tool progress into one persistent
message per tool on draft-streaming platforms (#99026).

Extend the guard to cover segment-break finalizes (finalize and not
is_turn_final). Turn finals stay exempt so a complete short answer is
never swallowed. Update the existing per-segment callback test to use
segment text above the floor, and add draft-mode regression coverage:

- short preamble rounds: no durable sends, no progress resets, drafts
  and the gateway final path unaffected
- full preamble rounds: still one durable send + one reset per segment
  (#17280 chronological-order contract preserved)
- turn-final short answer: always delivered

(cherry picked from commit 5ac896e1f22e6b2164544287c342c7fc7ec2c373)
2026-09-24 22:27:32 +05:30
kshitijk4poor
7d4b02cf7e fix(gateway): fall back to legacy message_len_fn when per-chat len API is missing
Ladder message_len_fn_for_chat -> message_len_fn -> len in
_resolve_length_budget (teknium1 review on #72629) and add regression tests
for the hot-reload mixed-version adapter (#72628).
2026-09-24 22:27:32 +05:30
webtecnica
c3fb100a65 fix(stream_consumer): guard message_len_fn_for_chat against hot-reload AttributeError
When git pull updates the code while the gateway is running, a lazy
import of stream_consumer.py (which happens on the first message) can
pick up a NEW version of the module while the adapter classes in memory
(base.py) are still the OLD version. This causes an AttributeError when
the new stream_consumer.py calls methods that didn't exist in the old
adapter.

The call at stream_consumer.py:683-687 used a bare ternary expression
without error handling. Two other call sites in the same file and
in run.py already had try/except Exception guards.

Fix: replace the ternary with an if/else block wrapped in try/except
Exception, consistent with the pattern at line 1368-1373.

Fixes #72628

(cherry picked from commit 84a70ccaf322c8aaa764e79b767293168ce2014e)
2026-09-24 22:27:32 +05:30
kshitijk4poor
6979cb9bab refactor(gateway): apply shutdown-notice interim marker once in _send_notice_logged
Also type continuation parts as (text, is_stub) tuples end-to-end and bound
the overlap scan to the tail of the joined text.
2026-09-24 22:26:11 +05:30
kshitijk4poor
5f5d0efd03 fix(gateway): mark cron interrupt notice as an interim send
Same send shape as the shutdown notices grafted from #98445: it fires during
drain while turns may still stream, so it must not seal a live answer (#98432).
2026-09-24 22:26:11 +05:30
liuhao1024
93be421d48 fix(gateway): mark shutdown/restart notifications as interim sends
Grafted from #98445 onto gateway/run_shutdown.py (the shutdown notifier moved
out of gateway/run.py). Both the active-chat and home-channel sends now carry
_interim_metadata so a stream-is-the-message adapter never seals an in-flight
answer with the shutdown advisory.

Fixes #98432
2026-09-24 22:26:11 +05:30
ethernet
ed6e37f9c1 Merge remote-tracking branch 'origin/main' into ethie/pm-clean 2026-09-24 11:58:45 -04:00
kshitijk4poor
d7f6de6e3f refactor(gateway): read the declared _gateway_accepted field directly in busy routing
_gateway_accepted is a declared MessageEvent field (event.py), so the
defensive getattr is unnecessary; wake.py already reads it directly. Drop
the dead 'handled = False' initializer: both try/except branches assign
it and a CancelledError propagates before it is read.
2026-09-24 21:27:59 +05:30
delltrak
00ee5db3f4 fix(gateway): a busy follow-up queued as the turn ends is no longer left without an owner
The base adapter sends a message to the runner's busy handler while the
previous turn still holds the session guard. The handler awaits before it
decides: the compression-lock read in the stock interrupt mode, or the
profile secret-scope load when profiles are multiplexed. If the previous
turn reaches _finish_session_task meanwhile, it finds the slot empty,
releases the guard and exits. The follow-up is then queued with no task
to drain it, either by the runner FIFO or by the base adapter when the
handler returns False. It waits until the next inbound message, whose new
turn runs BEFORE it (M1, M3, M2). If nothing else arrives, it never runs.

_handle_message_while_active now checks the guard after the handler
returns. If the guard is gone, it starts what the handler queued. If the
handler left the event to the base path and nothing is queued, it starts
the event itself.

A handler that raises after it stored the event (for example when
composing the busy ack fails) now counts as having handled it, based on
the event's _gateway_accepted receipt. Otherwise the new path would start
the stored event and the base path would queue it again, so it would run
twice. This also stops the older no-race case from merging the same text
into the slot twice ("M2\nM2").

tests/gateway/test_busy_followup_guard_release.py covers the stock
interrupt and multiplexed queue-text routes, and the case where the
handler raises after it stored the follow-up.

Known behavior, unchanged or out of scope:
- During a restart drain with busy_input_mode queue/steer, a follow-up
  started this way hits the drain gate ("not accepting new work")
  instead of waiting in the slot for the shutdown flush. The non-race
  path does the same (#82381).
- A message can still run before an earlier follow-up if it arrives
  after the guard is released but before that follow-up's handler
  returns (for example during its busy-ack send). Nothing is lost.
- The interrupt decision still uses the running agent read before the
  handler awaits.
- The "Interrupting current task" ack can arrive after the turn has
  already ended.

(cherry picked from commit bb9f15c7fa29cdffc56790ea3118d8baac2195f1)
2026-09-24 21:27:59 +05:30
ethernet
2be53ecd7b fix(encoding): read kernel pseudo-files as plain utf-8
utf-8-sig exists to tolerate BOMs that Windows tooling adds to files
users edit. /proc and /sys files are generated by the Linux kernel, never
BOM'd and absent on Windows, so -sig there only muddies the read/write
policy. Switch every literal /proc/ and /sys/ read to utf-8 and teach
the footgun read rule that string literals starting with /proc/ or
/sys/ are exempt (user-edited files keep utf-8-sig).
2026-09-24 11:50:30 -04:00
ethernet
3071d0bec8 fix(gateway): stop exporting fallback CA bundles on import 2026-09-24 09:20:50 -04:00