Skip to content

feat(gateway): tag agent:start payload with turn trigger + interrupt_depth - #37291

Closed
Couiz wants to merge 2 commits into
NousResearch:mainfrom
Couiz:feat/agent-start-trigger
Closed

feat(gateway): tag agent:start payload with turn trigger + interrupt_depth#37291
Couiz wants to merge 2 commits into
NousResearch:mainfrom
Couiz:feat/agent-start-trigger

Conversation

@Couiz

@Couiz Couiz commented Jun 2, 2026

Copy link
Copy Markdown

What

agent:start is emitted at two sites in gateway/run.py — the main inbound dispatch and the interrupt/drain follow-up path in _run_agent — and both previously emitted an identical payload shape. Hooks (voice-echo, activity loggers, visualizers) therefore could not distinguish a fresh user turn from an interrupt-driven follow-up turn.

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

  • trigger — a string (not a bool, so future turn kinds like "goal" / "schedule" extend the contract without breaking it): "message" on the main dispatch, "interrupt" on the drain follow-up. Hooks read it backward-compatibly via 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 (stale /goal, transcription→None), so it fires once per turn only when the follow-up actually proceeds to the agent.

Tests

  • test_drain_emits_agent_start.py: updated the exact-dict assertion to the new shape; added a first-level (depth == 1) and a nested (_interrupt_depth=1 → depth == 2) case driving the real _run_agent drain path. The existing no-emit-on-discard tests are unchanged and still pass.
  • test_agent_start_trigger.py (new): the main-dispatch emit lives ~630 lines deep behind session-store/DB/env I/O, so rather than mock that world this statically inspects the actual dict literal handed to hooks.emit("agent:start", …) and pins trigger="message" / interrupt_depth=0.

Notes

Depends on #37269 (the drain-emit fix this builds on). The first commit in this PR is that fix; it will drop out once #37269 merges.

Couiz and others added 2 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>
@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 P3 Low — cosmetic, nice to have type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants