Skip to content

transfer_task sub-agent loops forever when its final message ends with a bare EOF (no finish_reason) #3668

Description

@awschmeder

Summary

A sub-agent reached via transfer_task is expected to return control to its hub
by finishing its turn: a final message with content and no tool call. When the
provider closes that turn's SSE stream with a bare EOF -- no per-choice
finish_reason -- and the message has real content but no tool calls,
handleStream reports Stopped=false. runTurn then treats the turn as
continuable, re-enters the model with an unchanged message list, and the model
re-emits the same completion. The hand-off back to the hub never happens and the
sub-agent spins on an identical message forever.

The same defect applies to any turn that ends with a bare EOF and produces
content but no tool calls; the transfer_task hub-and-spoke hand-off is where it
is most visible, because a sub-agent's normal, correct way to return control is
exactly such a turn.

Environment

  • Provider: OpenAI-compatible gateways that terminate the stream with only a
    [DONE] sentinel and never send a per-choice finish_reason (observed with
    litellm / CBORG-style gateways).
  • Component: pkg/runtime/streaming.go (handleStream) bare-EOF completion path.

Root cause

pkg/runtime/streaming.go computes the turn-stop decision differently on its
two exit paths:

  • The mid-stream path (a chunk carried an explicit finish_reason) already keys
    the stop decision on whether tool calls remain:

    Stopped: len(toolCalls) == 0, // stop only when there are no tool calls to execute
  • The bare-EOF path (stream closed with no finish_reason) originally keyed the
    stop decision on empty content:

    stoppedDueToNoOutput := strings.TrimSpace(fullContent.String()) == "" && len(toolCalls) == 0

The original guard came from #3145 / #3327, which targeted whitespace-only /
reasoning-only empty turns. It correctly stops when there is no content, but a
turn that produced real content and no tool calls falls through with
Stopped=false. There is nothing left for the outer run loop to execute -- no
pending tool call -- yet runTurn continues, re-resolves the same message list,
and re-invokes the model, which deterministically re-emits the same text.

A turn on the bare-EOF path is terminal whenever there are no tool calls left to
run, regardless of whether it produced content. The two exit paths should agree.

Reproduction

  1. Point the runtime at an OpenAI-compatible gateway that ends streams with a
    bare EOF and no per-choice finish_reason.
  2. Run a turn (or a sub-agent in a multi-agent team) that emits a normal text
    completion and requests no tool calls -- e.g. a specialist finishing its work
    and returning a final status message to a hub agent.
  3. Observe the same assistant message emitted repeatedly; the run/hand-off never
    completes.

Proposed fix

Key the bare-EOF stop decision on the absence of tool calls, matching the
mid-stream path:

stoppedDueToNoOutput := len(toolCalls) == 0

The empty-content cases the original #3145 guard covered (token limit,
whitespace-only or reasoning-only reply) remain covered, because those turns
also have no tool calls. The finishReason inference below this line already
distinguishes content-vs-no-content for reporting purposes, so no further change
is needed there.

Test

TestHandleStream_ContentOnlyBareEOFStops: build a stream that emits real
content and no tool calls, then closes with a bare EOF (no terminal chunk /
finish reason). Assert res.Calls is empty and res.Stopped is true, so the
run loop exits instead of re-entering with identical messages. This complements
the existing TestHandleStream_WhitespaceOnlyContentStops (the #3145 case).

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/agentFor work that has to do with the general agent loop/agentic features of the apparea/providersFor features/issues/fixes related to LLM providers (Bedrock, LiteLLM, Qwen, custom, etc.)area/runtimeRuntime engine, agent loop execution, tool dispatch, loop detection

    Type

    Fields

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions