Skip to content

fix(gateway): run exec quick commands inline when agent is mid-turn (#25783)#25804

Closed
briandevans wants to merge 2 commits into
NousResearch:mainfrom
briandevans:fix/gateway-exec-quick-command-bypasses-busy-25783
Closed

fix(gateway): run exec quick commands inline when agent is mid-turn (#25783)#25804
briandevans wants to merge 2 commits into
NousResearch:mainfrom
briandevans:fix/gateway-exec-quick-command-bypasses-busy-25783

Conversation

@briandevans

Copy link
Copy Markdown
Contributor

Summary

  • Exec-type quick commands (`quick_commands..type: exec`) now run inline when a user sends them while the agent is mid-turn, instead of being queued and replayed to the LLM as raw text.
  • Extracts the existing exec dispatch + execution into `_try_execute_exec_quick_command` so both `_handle_message` (cold path) and `_handle_active_session_busy_message` (busy path) share one source of truth.
  • Adds a regression test that exercises the busy-path bypass, the alias-not-short-circuited contract, the draining-wins precedence, and the helper's None / error return paths.

The bug

`gateway/run.py` registers `_handle_active_session_busy_message` as each adapter's `busy_session_handler`. Whenever a message arrives for an active session that's mid-turn, the adapter routes through that handler instead of the cold path `_handle_message` — so the existing exec dispatch at `gateway/run.py:6582` was unreachable. The busy handler only knew queue / interrupt / steer:

```
user sends `/note remember to buy milk` during a tool-call turn
→ adapter detects active session
→ calls _handle_active_session_busy_message
→ merge_pending_message_event(...) # queued
→ next turn: agent sees "/note remember to buy milk" as user text
```

The user's daily-note script never runs; the agent receives the slash command as conversation input.

The fix

  1. `_try_execute_exec_quick_command(command)` — new helper that performs the dispatch lookup, runs the subprocess with the same env-sanitization + 30s timeout + redaction as the cold path, and returns the output string. Returns `None` when no exec quick command matches, so callers fall through. `alias`-type quick commands intentionally pass through (they rewrite into agent input and keep normal busy semantics).
  2. `_handle_active_session_busy_message` — after the existing auth + draining branches, call the helper. If it returns a string, deliver via `adapter._send_with_retry` and return `True` (handled). Precedence mirrors the cold path: draining wins, exec quick commands next, queue/interrupt last.
  3. `_handle_message` — refactored to call the same helper, so both call sites use one implementation.

Test plan

  • `tests/gateway/test_busy_quick_command_bypass.py` (new):
    • `test_exec_quick_command_runs_inline_during_busy_session` — exec subprocess spawned, output delivered, `interrupt` and `merge_pending_message_event` never called.
    • `test_non_exec_command_falls_through_to_queue_logic` — plain message still queues + acks.
    • `test_alias_quick_command_is_not_short_circuited` — alias still falls through to queue.
    • `test_exec_quick_command_blocked_during_drain` — draining still wins; user sees the draining message.
    • `test_helper_returns_none_for_non_exec_command` + `test_helper_reports_missing_exec_command_string` — helper contract.
  • Adjacent suites pass with no regressions: `tests/gateway/test_busy_session_ack.py` + `tests/cli/test_quick_commands.py` (33 tests, including the cold-path exec coverage that now exercises the shared helper).
  • Regression guard: before this PR, `_handle_active_session_busy_message` had no exec branch, so the first test would call `running_agent.interrupt` and `merge_pending_message_event` instead of spawning the subprocess — `mock_spawn.assert_called_once()` fails on `origin/main`.

Sibling code paths that may need the same fix: `built-in slash commands like `/new`, `/voice`, `/goal` are also queued during a busy session even though their handlers don't touch agent state. Intentionally left out of this PR's scope to keep the diff small — happy to widen if preferred.

Related

Copilot AI review requested due to automatic review settings May 14, 2026 15:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Extracts exec-type quick command execution into a reusable helper so that quick commands can bypass the busy-session queue/interrupt path and run inline during an active agent turn (fixes #25783).

Changes:

  • Adds _try_execute_exec_quick_command helper consolidating exec subprocess logic with env sanitization, timeout, and redaction.
  • Inserts a short-circuit in _handle_active_session_busy_message (after the draining branch) so exec quick commands run inline and reply without touching the running agent.
  • Refactors the cold-path quick command branch to delegate to the new helper, plus adds a new test module covering busy bypass, drain precedence, alias fall-through, and helper edge cases.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
gateway/run.py Extracts exec quick command runner; short-circuits busy-session path for exec commands.
tests/gateway/test_busy_quick_command_bypass.py New tests verifying busy bypass, drain precedence, alias fall-through, and helper behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread gateway/run.py
Comment on lines +2515 to +2517
exec_cmd = qcmd.get("command", "")
if not exec_cmd:
return f"Quick command '/{command}' has no command defined."
Comment thread gateway/run.py Outdated
Comment on lines +2606 to +2612
reply_to=(
reply_anchor
if event.source.platform == Platform.TELEGRAM
and event.source.chat_type == "dm"
and event.source.thread_id
else (None if event.source.platform == Platform.TELEGRAM and event.source.thread_id else event.message_id)
),
Comment thread gateway/run.py
try:
# Sanitize env to prevent credential leakage — quick commands run
# in the gateway process which has all API keys in os.environ.
from tools.environments.local import _sanitize_subprocess_env
Comment on lines +115 to +117
with patch("gateway.run.asyncio.create_subprocess_shell", new=AsyncMock(return_value=fake_proc)) as mock_spawn, \
patch("gateway.run.merge_pending_message_event") as mock_merge:
result = await runner._handle_active_session_busy_message(event, sk)
Comment on lines +208 to +213
event = _make_event(text="/note while draining")
sk = build_session_key(event.source)
runner.adapters[event.source.platform] = adapter

with patch("gateway.run.asyncio.create_subprocess_shell", new=AsyncMock()) as mock_spawn:
result = await runner._handle_active_session_busy_message(event, sk)
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery labels May 14, 2026
@briandevans

Copy link
Copy Markdown
Contributor Author

@copilot Addressed in 49b06ec51:

  • gateway/run.py:2612 (reply_to nesting) — Factored into _busy_ack_reply_to(event, reply_anchor) and reused at all three sibling send sites in _handle_active_session_busy_message (drain branch, exec-bypass branch, normal busy-ack branch). Same behaviour; the helper just prevents the three Telegram-rule branches from drifting on future changes.
  • tests/test_busy_quick_command_bypass.py:117 (real-module dependency) — Added patch("tools.environments.local._sanitize_subprocess_env", return_value={}) and patch("agent.redact.redact_sensitive_text", side_effect=lambda x: x) to the busy-bypass test so it's truly hermetic alongside the existing telegram stub.

Deliberately not changing in this PR:

  • gateway/run.py:2517 (cold-path None from helper) — The cold path enters this branch only after quick_commands[command]["type"] == "exec", which exactly matches the helper's accept condition: helper returns None only when command is empty, missing from quick_commands, the entry isn't a dict, or type != "exec". All four are excluded by the surrounding guards, so the cold path can never see None here unless something concurrently mutates self.config mid-call (not a pattern this codebase supports). Open to revisiting if you'd prefer a defensive fallthrough anyway.
  • gateway/run.py:2521 (_sanitize_subprocess_env privacy) — Promoting the underscore-prefix to a public API touches tools/environments/local.py and any other call sites; out of scope for this bug fix. Happy to open a follow-up if maintainers want the public symbol.
  • test:213 (_running_agents[sk] not set in drain test) — Intentional: the drain branch (lines 2557–2583) runs before the function reads _running_agents, so setting it isn't needed to reach the assertion path. The point of that test is verifying drain precedence over exec bypass; a running-agent fixture would just be inert state.

All 6 tests in test_busy_quick_command_bypass.py pass locally, plus the 86 adjacent busy/drain tests (test_busy_session_ack.py, test_command_bypass_active_session.py, test_restart_drain.py, test_pending_drain_*, test_cancel_background_drain.py) still pass — no regression from the helper extraction.

@briandevans

Copy link
Copy Markdown
Contributor Author

CI audit — all 46 test failures in the latest Tests run are pre-existing baselines on clean origin/main (b08f53a, run 25872221704). The failure set is byte-for-byte identical between this PR's run 25871056323 and the main run, and none touch gateway/run.py busy-session handling or tests/gateway/test_busy_quick_command_bypass.py (6/6 of which pass locally).

Cluster Symptom Root cause on main
dingtalk / weixin / wecom_callback / platform_http_client_limits (~21 failures) AttributeError: module 'cryptography.utils' has no attribute 'DeprecatedIn46' / Cryptography_HAS_OP_NO_RENEGOTIATION CI's lazy-installed mautrix[encryption] pulls a newer cryptography into the venv that the pinned alibabacloud-tea-openapi / wechatpy / cffi shims aren't compatible with.
bedrock (5) ModuleNotFoundError: No module named 'botocore' [bedrock] extra (botocore) not in CI install.
transcription / tts_kittentts (3) ModuleNotFoundError: faster_whisper, ImportError test_tts_kittentts Optional voice extras not installed in CI.
run_agent/test_compression_feasibility (8), test_auxiliary_client (3), test_provider_parity (2), test_switch_model_context (1), test_background_review (1) assert None is not None, expected call not found Aux-client / provider-chain refactor regression — same failure set on main.
test_plugin_discovery (1) Expected 33 profiles, got 34 Profile count drifted on main (new arcee profile listed).
test_bedrock_integration / test_update_autostash / test_registry / test_context_compressor_summary_continuity / test_feishu_bot_admission / test_matrix (8) Various baseline assertion drifts All reproduce identically on b08f53a.

All six focused tests in tests/gateway/test_busy_quick_command_bypass.py pass locally on this branch. Happy to rebase once the cryptography/aux-client baselines clear on main.

@briandevans briandevans force-pushed the fix/gateway-exec-quick-command-bypasses-busy-25783 branch from 49b06ec to d9fb3ea Compare May 18, 2026 21:25
@briandevans briandevans force-pushed the fix/gateway-exec-quick-command-bypasses-busy-25783 branch from d9fb3ea to 634a3f6 Compare May 22, 2026 06:11
@briandevans briandevans force-pushed the fix/gateway-exec-quick-command-bypasses-busy-25783 branch from 634a3f6 to f9640a5 Compare May 24, 2026 03:09
@briandevans briandevans force-pushed the fix/gateway-exec-quick-command-bypasses-busy-25783 branch from f9640a5 to 38606b2 Compare May 26, 2026 07:10
briandevans and others added 2 commits May 28, 2026 11:11
…ousResearch#25783)

Adapters route incoming messages to ``_handle_active_session_busy_message``
whenever the agent is mid-turn, but that path only knew how to
queue/interrupt/steer. Exec-type quick commands (``quick_commands.<name>.type:
exec``) bypass the LLM by design — they spawn a subprocess and return its
output. When a user sent ``/note ...`` during a multi-tool-call turn, the
busy handler had no knowledge of quick commands, so the message was
``merge_pending_message_event``'d into the pending queue and replayed to the
agent as raw text on the next turn. Net effect: the daily-note script never
ran, and the agent saw ``/note remember to buy milk`` as a user message.

Extract the existing exec dispatch + execution from ``_handle_message`` into
``_try_execute_exec_quick_command`` so both the cold path and the busy path
can call it. In the busy handler, run the helper after the draining branch
(matching the precedence in ``_handle_message``: draining wins, exec quick
commands next) and deliver the output via ``adapter._send_with_retry``.
``alias`` quick commands intentionally are NOT short-circuited — they rewrite
into agent input and follow normal busy semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…olation

Addresses Copilot inline feedback on NousResearch#25804:

* Extract `_busy_ack_reply_to(event, reply_anchor)` to centralize the
  three-way Telegram reply-target rule that was duplicated across the
  drain, exec-bypass, and normal busy-ack send sites in
  `_handle_active_session_busy_message`. The behaviour is unchanged —
  the goal is just preventing the three branches from drifting on
  future Telegram changes.
* Patch the helper's lazy cross-module imports
  (`tools.environments.local._sanitize_subprocess_env`,
  `agent.redact.redact_sensitive_text`) inside the busy-bypass
  test so it stays hermetic with the `telegram` stub already at the
  top of the file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@briandevans briandevans force-pushed the fix/gateway-exec-quick-command-bypasses-busy-25783 branch from 38606b2 to ee3dc72 Compare May 28, 2026 18:11
@briandevans

Copy link
Copy Markdown
Contributor Author

Closing to focus the queue on security/file-safety work where civilian merges are landing. Happy to reopen if maintainers want this picked up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: exec quick commands routed to agent as text when agent is mid-turn

3 participants