Skip to content

fix(gateway): emit agent:end on interrupt/drain follow-up turns - #38915

Closed
Couiz wants to merge 3 commits into
NousResearch:mainfrom
Couiz:pr/agent-end-drain
Closed

fix(gateway): emit agent:end on interrupt/drain follow-up turns#38915
Couiz wants to merge 3 commits into
NousResearch:mainfrom
Couiz:pr/agent-end-drain

Conversation

@Couiz

@Couiz Couiz commented Jun 4, 2026

Copy link
Copy Markdown

Problem

On an interrupted/drained turn the gateway emits agent:start for the drained follow-up (added in the prior fix that introduced the drain agent:start emit) but never emits a matching agent:end. The only agent:end emit lives in the outer _handle_message_with_agent path (main dispatch); the drained follow-up re-enters _run_agent recursively, not that outer caller, so its end event is silently dropped. Hooks that pair agent:start/agent:end (SessionStart-style integrations, activity loggers, turn visualizers) therefore see one unmatched start on every interrupt.

Fix (minimal, symmetric)

In the drain path of _run_agent, the existing agent:start payload is now built once as followup_hook_ctx and reused for a paired agent:end emitted right after the recursive follow-up completes — mirroring exactly how the main path reuses hook_ctx for both its start (line ~9287) and end (~9428) emits. The end carries the same trigger="interrupt" / interrupt_depth tagging so it's attributable to the same drained turn, and its response is the follow-up's final_response[:500] (same shape as the main-path end).

The emit sits below the discard and _MAX_INTERRUPT_DEPTH early-returns, so paths that emit no start also emit no end — symmetry is preserved on every branch (verified by a max-depth boundary test and the two discard tests).

Verification (empirical first)

A characterization pass confirmed the asymmetry is real before any production change: the drain path emitted start with no matching end. The regression suite (tests/gateway/test_drain_agent_end_symmetry.py, 6 tests) covers: start/end balanced on a single interrupt, end payload mirrors start, nested-depth end increments interrupt_depth, no end at _MAX_INTERRUPT_DEPTH, and the two discard paths emit neither. RED against the pre-fix code (end count < start count), GREEN after. Full tests/gateway/ suite shows zero new failures attributable to this change (the only diff vs. base is these 3 fix-dependent tests flipping RED→GREEN).

Stacking

Depends on #37269 (emit agent:start on interrupt/drain) and #37291 (tag agent:start payload with trigger/interrupt_depth). This PR is based on the #37291 branch and completes the start/end symmetry that #37269 began. Please merge those first.

Couiz and others added 3 commits June 2, 2026 07:26
The main message dispatch emits the `agent:start` hook before running the
agent, but the interrupt/drain follow-up path in `_run_agent` promoted a
queued message straight into a recursive `_run_agent` call without emitting
`agent:start`. Every hook listening on `agent:start` (SessionStart-style
integrations, activity loggers, visualizers) silently missed interrupt/queue
follow-up turns — an event-emission gap, not a hook bug.

Emit `agent:start` on the drain path right before the recursive `_run_agent`,
mirroring the main-dispatch payload (platform, user_id, chat_id, session_id,
message[:500]) but built from the follow-up turn's source (`next_source`) and
the final, already-transcribed text (`next_message`) — so voice follow-ups
carry the transcript, not the raw audio placeholder. The emit sits after every
discard guard (draining, interrupt depth-cap, stale /goal continuation,
transcription→None) so it fires exactly once per turn, only when the follow-up
actually proceeds to the agent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…depth

Both agent:start emit sites — the main inbound dispatch and the
interrupt/drain follow-up path in _run_agent — previously emitted an
identical payload shape, so hooks (voice-echo, activity loggers,
visualizers) could not tell a fresh user turn from an interrupt-driven
follow-up turn.

Add two discriminator fields to the agent:start payload at both sites:

  * trigger — a string, not a bool, so future turn kinds like "goal" or
    "schedule" can be added without breaking the contract: "message" on the
    main dispatch, "interrupt" on the drain follow-up. Hooks read it
    backward-compatibly as context.get("trigger", "message").
  * interrupt_depth — an int: 0 on the main dispatch (a fresh turn is never
    an interrupt); _interrupt_depth + 1 on the drain path, matching the
    depth handed to the recursive _run_agent call (first interrupt -> 1,
    interrupt-of-an-interrupt -> 2, ...).

Both payloads stay shape-consistent (7 keys); no existing key changes. The
drain emit still sits after every discard guard, so it fires once per turn
only when the follow-up actually proceeds to the agent.

Depends on NousResearch#37269.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@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 Jun 4, 2026
@Couiz

Couiz commented Jun 4, 2026

Copy link
Copy Markdown
Author

Consolidated into #39126 — the three changes (agent:start on drain, trigger/depth tagging, agent:end on drain) are one coherent concern, and the agent:end fix's payload carries the trigger/interrupt_depth tagging, so they aren't cleanly separable. A single PR off fresh main reviews better than three stacked PRs across diverging bases. Closing in favor of #39126.

@Couiz Couiz closed this Jun 4, 2026
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.

2 participants