Skip to content

dispatch() of a kind:'signal' message always starts a model turn, so context-only events cannot be delivered to an idle agent #451

Description

@steven4354

Summary

Delivering a kind: 'signal' message through dispatch() (or the direct agent POST, which shares the same wire shape) always admits a durable submission that is processed to completion: the runtime appends the signal record and immediately runs a full model turn, producing an assistant response nobody asked for. There is no delivery mode, alternate API, or route that lands a signal while the agent is idle so the model consumes it only on the next externally-triggered turn.

Evidence

Traced on current main (c5e7ef4):

  • AgentDispatchRequest is { id, message } and DeliveredMessage is kind: 'user' | 'signal' with no delivery flags (packages/runtime/src/types.ts), so a signal cannot be marked context-only at the wire.
  • enqueueDispatch enqueues unconditionally (packages/runtime/src/runtime/dispatch.ts), and the queue's only consumers — the Node and Cloudflare coordinators' claim loops — both call the shared processSubmission.
  • processSubmission never branches on message.kind (packages/runtime/src/runtime/agent-submissions.ts); a signal takes the same path as a user prompt into session.processSubmissionInput.
  • In packages/runtime/src/session.ts, runPersistedSubmissionInput documents itself as "the single input path for both a direct HTTP prompt and a dispatch() call", and runPersistedContextInput appends the canonical record then unconditionally awaits resumeConversationToCompletion. A fresh input with no assistant after it classifies as resume/input_only, whose documented contract is "start the first turn" — a real provider call, since toTurnMessage renders the signal into model context as a user message.
  • The dispatch() doc says "It does not wait for model processing" — fire-and-forget means not-awaited, not not-processed, so the per-signal model turn is easy to miss until the bill or the transcript shows it.

Why this matters

The callers signals are documented for — channels modeling multi-user conversations, applications recording out-of-band events (a webhook, another participant's message, a human resolving a pending approval) — mostly want the event to inform the next turn, not provoke one. Today every ambient event costs its own LLM call and injects an unprompted assistant reaction into the durable transcript.

Expected behavior

A signal delivery can opt out of waking the agent: the record persists durably and reaches live conversation observers, and the model consumes it whenever the next turn is started by something else. An explicit per-dispatch delivery mode would suffice.

Current workaround

A downstream application patches @flue/runtime (1.0.0-beta.9) to expose an out-of-turn append that writes the canonical signal record through the agent coordinator's own conversation writer: live subscribers receive the UI chunk immediately, no turn runs, and the append throws while an assistant turn is in progress so the reducer's linear-append invariant holds.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions