01: Stream Once
One provider generation pass, wrapped with a rolling AbortController SDK timeout.
File: src/utils/chat/toolLoop.ts:142-195
Mission
Section titled “Mission”Call provider.streamToDiscord(...) with the current accumulated context and
tool history, and race the result against a configurable SDK timeout. The
timeout is rolling: it resets on every onStreamProgress heartbeat, so a
long but active stream is not killed — only a truly stalled one is. Returns a
StreamResult describing how the generation ended.
params: ToolLoopParams— full loop context (provider, config,ChatTurnContext).accumulatedModelParts: Array<Record<string, unknown>>— provider-native model turn parts accumulated across prior iterations of the tool loop. Empty on the first iteration; grows as each tool call appends its model response. Passed by reference and read (not written) insidestreamOnce.functionHistory: ToolHistoryEntry[]— paired call/response records from prior tool dispatches. Passed to the provider so it can continue the multi-turn tool conversation. Empty on the first call.
Output
Section titled “Output”Promise<StreamResult> — defined in src/types/provider/interfaces.ts. The
status field drives the outer loop’s switch:
status | Meaning |
|---|---|
"completed" | Provider finished; accumulatedText carries the final response |
"error" | Provider threw a non-timeout error |
"timeout" | SDK call exceeded STREAM_SDK_CALL_TIMEOUT_MS with no heartbeat |
"empty_response" | Provider returned with no text and no tool call |
"stopped_by_user" | User triggered /stop while streaming |
"follow_up_interrupt" | A follow-up message arrived; caller should yield |
"function_call" | Provider requested a tool call; data carries the call payload |
Side effects
Section titled “Side effects”- Sets
params.context.streamingContext.abortSignalto a freshAbortController.signalbefore each call. Provider adapters consume this signal to abort in-flight HTTP requests when the timeout fires. - Sets
params.context.streamingContext.onStreamProgressto therefreshTimeoutcallback before the call and resets it toundefinedin thefinallyblock. Provider adapters call this on each token delivery to prevent the timeout from firing on active streams. - Registers
killStreamon the channel lock entry viasetChannelStreamKill(channelId, killStream).killStreamis a unified callback that both callsabortController.abort()and rejects thePromise.race— ensuring the HTTP request is cancelled and the race unblocks simultaneously. This is what/bot killtriggers viaforceKillChannelStream. - Clears the timeout and the kill registration (
clearTimeout,setChannelStreamKill(channelId, null)) in thefinallyblock regardless of success or error. - Derives the
replyToMessageargument passed to the provider. Queued turns (isFromQueue) normally reply to their trigger message so the response renders as a Discord reply. Scene turns are the exception: every queued scene persona job shares the same trigger message, so replying would make all of them render “replying to” one message. Whenincoming.sceneTurnis set,replyToMessageis forced toundefinedso the generated scene reads as a free-standing dialogue.
Invariants
Section titled “Invariants”After this stage runs:
params.context.streamingContext.onStreamProgressisundefined— the heartbeat reference is always cleaned up.- The channel lock’s
activeStreamKillisnull— the kill callback is always deregistered infinally. - If the result status is
"timeout", it originated from the SDK-call timeout race (error message prefix"SDK_CALL_TIMEOUT:"), not from a provider-specific timeout mechanism. - Errors that are not SDK timeouts are re-thrown to the caller
(
runToolLoop), which does not catch them — they propagate torunGenerationTurn’s outercatch.
Extension points
Section titled “Extension points”| Surface | Plugin-relevance |
|---|---|
provider.streamToDiscord(...) call | The provider contract is the seam — see provider pipeline |
STREAM_SDK_CALL_TIMEOUT_MS / rolling onStreamProgress | Internal — timeout behavior is an operational concern, not plugin-relevant |
params.context.streamingContext.abortSignal | Internal — consumed by provider adapters only |
Configuration
Section titled “Configuration”| Env var | Default | Minimum | Purpose |
|---|---|---|---|
STREAM_SDK_CALL_TIMEOUT_MS | 120000 (2 min) | 10000 (10 s) | Idle timeout for one provider call; resets on each heartbeat |
Related docs
Section titled “Related docs”- Provider streaming contract: → provider pipeline
- Tool-loop coordinator: →
README.md