Skip to content

Bug: Agent loop self-replies when message IDs are non-monotonic (2.8% of sessions) #28986

Closed as not planned

Description

@kheerand

Bug Description

The agent loop continues generating responses after the model has stopped (finish reason: "stop", no tool calls), causing the AI to reply to its own previous response. This creates a visible self-conversation where the AI answers questions it asked itself.

Root Cause

The agent loop exit condition in SessionPrompt.run uses lexicographic message ID comparison:

// From the compiled binary (SessionPrompt.run loop):
if (L?.finish && !["tool-calls"].includes(L.finish) && !yA && sA.id < L.id) {
  break; // exit loop
}

Where sA = latest user message, L = latest assistant message.

The sA.id < L.id check assumes message IDs are monotonically increasing with creation time. This assumption is wrong. When the ID generator produces IDs within milliseconds of each other, the ordering can be inverted — the user message ID ends up higher than the assistant message ID, even though the user message was created first.

Evidence

Example from a real session

Message ID Created (ms) Role
User question msg_e55166ef1001... 1779543994009 user
Assistant response msg_e55166ed1001... 1779543994065 assistant

The user message ID prefix e55166ef is lexicographically higher than the assistant ID prefix e55166ed, despite being created 56ms earlier. So sA.id < L.id evaluates to false, and the loop does not exit.

The model stopped with finish: "stop" and zero tool calls, but the agent loop continued anyway, generating a second response that answered its own question from the first response.

Scale (measured from local database)

  • 16,069 user→assistant message pairs created within 1 second of each other
  • 453 pairs (2.8%) have inverted IDs
  • 143 orphan continuations (self-replies) detected across 36 sessions
  • Each orphan continuation wastes tokens and produces confusing output

Self-reply pattern

What the user sees:

AI: No. It's just a cron job that fires every 15 minutes... Want me to disable it?

AI: Yeah, exactly right. The heartbeat is just a cron job with extra steps... Let me disable it.

AI: Heartbeat disabled. Script's still there if we ever need it.

Three consecutive AI responses to a single user question, with the AI treating its own text as new context and continuing.

Suggested Fix

Replace the ID-based comparison with timestamp-based:

// Current (broken when IDs are non-monotonic):
if (L?.finish && !["tool-calls"].includes(L.finish) && !yA && sA.id < L.id)

// Fixed:
if (L?.finish && !["tool-calls"].includes(L.finish) && !yA && sA.info.time.created < L.info.time.created)

Alternatively, if the model finished with "stop" and there are no pending tool calls, the loop should simply exit — the additional ID guard may be unnecessary, or could use the assistant's parentID field to verify it's responding to the correct user message.

Environment

  • opencode version: 1.15.6
  • Model: glm-5.1 (via zai-coding-plan provider)
  • Platform: Linux x86_64
  • Storage: SQLite (local)

Activity

  1. github-actions commented on May 23, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of an existing issue. Please check:

    If you believe this is a distinct scenario worth tracking separately, please add a comment clarifying the difference. Otherwise, consider adding your evidence and reproduction data to #28618.

  2. github-actions commented on Jul 23, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

  3. thib3113 commented on Aug 1, 2026

    @thib3113

    I've been hitting this too, and honestly way more often than 2.8% — it felt like it happened all the time, so I decided to dig into my local database and measure it properly.

    What I did

    I have ~30k messages across ~38 days in ~/.local/share/opencode/opencode.db. I joined each assistant message to its parent user message (via the parentID field in the message.data JSON) where the pair was created within 1 second of each other, then compared the IDs lexicographically.

    Query — inversion rate
    WITH pairs AS (
      SELECT 
        u.id as user_id,
        a.id as assistant_id,
        u.time_created as user_time,
        a.time_created as assistant_time,
        u.session_id
      FROM message u
      JOIN message a ON a.session_id = u.session_id 
        AND json_extract(a.data, '$.parentID') = u.id
        AND json_extract(a.data, '$.role') = 'assistant'
        AND json_extract(u.data, '$.role') = 'user'
      WHERE ABS(a.time_created - u.time_created) <= 1000
    )
    SELECT 
      COUNT(*) as total_pairs,
      SUM(CASE WHEN user_id > assistant_id THEN 1 ELSE 0 END) as inverted,
      ROUND(100.0 * SUM(CASE WHEN user_id > assistant_id THEN 1 ELSE 0 END) / COUNT(*), 2) as pct_inverted
    FROM pairs;

    What I found

    Metric Value
    User→assistant pairs (≤1s) 3,510
    Inverted pairs 967 (27.55%)
    Sessions affected 114 of 742 (15.4%)

    So 27.5% here, almost 10x the reported rate. But it gets more interesting when you break it down.

    Most of those inversions (824 of them) are on tool-call messages. Those don't actually matter: the loop keeps going for tool-calls anyway, so the ID order is never consulted. The inversion only matters when finish=stop, because that's the branch where the loop should exit — and that's where it fails.

    Metric Value
    Total stop messages 2,507
    Inverted stop messages 161
    ...that produced a follow-up assistant (same parent, no user between) 161 (100%)
    Wasted follow-up messages 319
    Sessions affected 48

    So every single inverted stop produced at least one extra assistant message. Here's the query I used to find those:

    Query — self-reply detection
    WITH self_replies AS (
      SELECT a1.id as stop_id, a2.id as next_id, a1.session_id,
             CASE WHEN a1.id < json_extract(a1.data, '$.parentID')
               THEN 'INV' ELSE 'OK' END as inv,
             json_extract(a2.data, '$.finish') as f2
      FROM message a1
      JOIN message a2 ON a2.session_id = a1.session_id
          AND a2.time_created > a1.time_created
          AND a2.time_created < a1.time_created + 600000
          AND json_extract(a2.data, '$.role') = 'assistant'
      WHERE json_extract(a1.data, '$.role') = 'assistant'
        AND json_extract(a1.data, '$.finish') = 'stop'
        AND json_extract(a1.data, '$.parentID') = json_extract(a2.data, '$.parentID')
        AND NOT EXISTS (
          SELECT 1 FROM message mid
          WHERE mid.session_id = a1.session_id
            AND mid.time_created > a1.time_created
            AND mid.time_created < a2.time_created
            AND json_extract(mid.data, '$.role') = 'user'
        )
    )
    SELECT 'INV stops replied to', COUNT(DISTINCT stop_id)
    FROM self_replies WHERE inv = 'INV'
    UNION ALL
    SELECT 'OK stops replied to', COUNT(DISTINCT stop_id)
    FROM self_replies WHERE inv = 'OK'
    UNION ALL
    SELECT 'INV follow-up msgs (wasted)', COUNT(*)
    FROM self_replies WHERE inv = 'INV'
    UNION ALL
    SELECT 'OK follow-up msgs (wasted)', COUNT(*)
    FROM self_replies WHERE inv = 'OK';

    The provider angle

    I route a lot of traffic through a LiteLLM proxy, so I also split the inversion rate by provider:

    Provider Pairs Inversions %
    litellm 1,710 715 41.8%
    opencode-go 939 161 17.1%
    opencode 754 106 14.1%
    antigravity-manager 121 20 16.5%
    openrouter 26 2 7.7%

    Litellm is clearly the worst, but it's not the only one — every provider shows inversions, so this isn't a proxy-only issue.

    A concrete example of what it looks like

    In one session the model replied to a question, ended with an offer to create a resource and asked "Do you want me to?" — then the loop didn't exit, the model ran again, interpreted its own question as the user confirming, and started creating the resource. The reasoning in the follow-up literally said "the user agrees" when no user had typed anything since the previous message.

    The pattern in the data is always the same:

    • user message created first (say at T)
    • assistant stop message created ~39ms later, but with an ID that sorts before the user's
    • another assistant message follows with the same parent, no user input in between

    In short: the 2.8% figure seems to only cover the pairs that inverted. In my dataset 27.5% of pairs invert, and every inverted stop — 6.4% of all stop messages — turns into an unwanted extra model run.


    Fixed by 1.18.16

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions