Skip to content

Outbound Telegram messages arrive out of order when multiple tool results fire concurrently #11044

@hariseldonsradiant

Description

@hariseldonsradiant

Bug Description

When the agent triggers multiple tool calls in a single turn, the resulting Telegram messages frequently appear out of order — the first message appears last and vice versa. This is a consistent, user-visible issue.

Root Cause

Tool result messages in src/auto-reply/reply/agent-runner-execution.ts are dispatched in a fire-and-forget pattern. Each tool result creates an independent async task that is added to pendingToolTasks without awaiting previous results:

onToolResult: onToolResult ? (payload) => {
    const task = (async () => {
        const { text, skip } = normalizeStreamingText(payload);
        if (skip) return;
        await params.typingSignals.signalTextDelta(text);
        await onToolResult({ text, mediaUrls: payload.mediaUrls });
    })().catch((err) => {
        logVerbose(`tool result delivery failed: ${String(err)}`);
    }).finally(() => {
        params.pendingToolTasks.delete(task);
    });
    params.pendingToolTasks.add(task);  // No await — runs concurrently
} : void 0

pendingToolTasks is only awaited collectively via Promise.allSettled at the end of the turn — after messages have already been sent to Telegram. Network latency jitter causes later-dispatched messages to arrive before earlier ones.

Contributing factors

  1. No global send serialization across concurrent reply types — block replies use sendChain, but tool results bypass it entirely.
  2. apiThrottler (@grammyjs/transformer-throttler) can batch-release queued requests during rate limit recovery, amplifying reordering.
  3. The sequentialize middleware only handles inbound ordering, not outbound.

Reproduction

  1. Send a message that triggers 2+ independent tool calls (e.g., two parallel read or web_search calls)
  2. Observe the tool result messages arriving in reversed or shuffled order in Telegram

Suggested Fix

Serialize tool result delivery using a promise chain instead of fire-and-forget:

let toolResultChain = Promise.resolve();

onToolResult: onToolResult ? (payload) => {
    toolResultChain = toolResultChain.then(async () => {
        const { text, skip } = normalizeStreamingText(payload);
        if (skip) return;
        await params.typingSignals.signalTextDelta(text);
        await onToolResult({ text, mediaUrls: payload.mediaUrls });
    }).catch((err) => {
        logVerbose(`tool result delivery failed: ${String(err)}`);
    });
} : void 0

For a more robust fix, a per-chat global send queue would prevent interleaving across all reply types (block replies, tool results, final replies).

Environment

  • OpenClaw latest (npm)
  • Channel: Telegram
  • macOS (Apple Silicon)

Severity

High — frequently visible to end users, degrades conversation readability.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingstaleMarked as stale due to inactivity

    Type

    No type
    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