Skip to content

fix(model): streaming retry re-delivers already-sent chunks downstream - #2479

Open
gxgeek-n wants to merge 3 commits into
agentscope-ai:mainfrom
gxgeek-n:fix/streaming-retry-duplicates-chunks
Open

fix(model): streaming retry re-delivers already-sent chunks downstream#2479
gxgeek-n wants to merge 3 commits into
agentscope-ai:mainfrom
gxgeek-n:fix/streaming-retry-duplicates-chunks

Conversation

@gxgeek-n

Copy link
Copy Markdown
Contributor

Fixes #2478.

Problem

ModelUtils.applyTimeoutAndRetry attaches retryWhen directly to the streaming Flux. retryWhen re-subscribes its upstream, so a retryable error arriving mid-stream makes the model regenerate the whole response — and the chunks already handed to middlewares, memory and the UI get delivered again.

ExecutionConfig.RETRYABLE_ERRORS classifies IOException and TimeoutException as retryable, which is exactly what a dropped streaming connection looks like. With MODEL_DEFAULTS (maxAttempts=3) the same content can land downstream three times.

Affects the streaming path of every provider routed through this helper: OpenAIChatModel, DashScopeChatModel, AnthropicChatModel, OllamaChatModel.

Change

Track whether the stream has emitted anything and fold that into the retry filter:

AtomicBoolean emitted = new AtomicBoolean(false);
final Predicate<Throwable> retryableError = retryOn;

.filter(error -> !emitted.get() && retryableError.test(error))
...
responseFlux = responseFlux.doOnNext(r -> emitted.set(true)).retryWhen(retrySpec);

doOnNext sits upstream of retryWhen so every emitted element flips the flag. Failures before the first chunk still retry — connection setup failures and HTTP 429 ahead of the first token — which is where retrying is safe and valuable. Non-streaming calls emit a single element and are unaffected when they fail before producing it.

14 lines changed in production code.

Verification

ModelUtilsStreamingRetryTest, four cases:

case without fix with fix
mid-stream retryable error FAIL[Hello, world] expected, got [Hello, world, Hello, world, Hello, world] (upstream subscribed 3x) PASS
failure before first chunk still retries PASS PASS
non-retryable mid-stream error PASS PASS
clean stream unaffected PASS PASS

Only the first case fails without the change; the other three pass both ways, which pins that retries are not over-disabled — the fix is narrow rather than "turn retry off for streaming".

Reproduced with no live model: upstream emits two chunks then throws IOException.

Relationship to #2279

This likely explains the 30 s of silence reported there. doFinally is attached to the lifecycle Mono, so it cannot fire until that Mono terminates; a retry keeps it alive across backoff plus a full regeneration. The timeline in that report — middlewares onComplete at 00:15:33, POST_CALL at 00:16:03 — matches "the Mono finished 30 s later" rather than "the completion signal took 30 s to propagate".

Worth noting the fix proposed in #2279, sink.tryEmitComplete(), cannot compile: FluxSink has no tryEmit* method in any Reactor 3.x release (checked with javap against 3.1.7.RELEASE / 3.4.16 / 3.6.18 / 3.7.6) — tryEmitComplete() belongs to Sinks.Many. I have not closed #2279 since I cannot reproduce its exact environment; if maintainers confirm this is the same root cause it can be linked or closed as a duplicate.

spotless:apply has been run.

Copilot AI review requested due to automatic review settings July 29, 2026 08:59
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — PR #2479

Streaming Retry Fix (commit 2) — ✅ Good

The core fix is clean and well-targeted:

  • AtomicBoolean emitted correctly tracks whether any chunk has been delivered
  • .filter(error -> !emitted.get() && retryableError.test(error)) narrows retry to pre-first-chunk failures only
  • .doOnNext(r -> emitted.set(true)) placed upstream of retryWhen — correct ordering
  • 14 lines in production code — minimal surface area
  • 4 test cases in ModelUtilsStreamingRetryTest cover the bug scenario + regression cases (pre-first-chunk retry still works, non-retryable errors pass through, clean stream unaffected)

The fix preserves the valuable retry cases (connection setup, HTTP 429 before first token) while preventing the duplication bug. Well done.

Concerns

1. PR mixes two independent fixes

This PR contains two commits from different authors:

  • Commit 1 (waterWang): fix(#2475): store userId/sessionId directly on CallExecution — already submitted as standalone PR #2476
  • Commit 2 (gxgeek-n): fix(model): stop retrying a streaming response once chunks were delivered — the actual #2478 fix

Mixing them creates merge/review complexity. If #2476 is merged first, this PR will have a conflict on ReActAgent.java. Consider rebasing this PR onto main after #2476 lands, or splitting into a standalone PR for just the streaming fix.

2. CI failures — Spotless formatting

Both Linux and Windows builds fail with Spotless violations in ReActAgent.java:

  • Extra blank lines before initialStateLoad Javadoc (line 352-353)
  • CallExecution constructor parameters need one-per-line formatting

Fix with:

mvn spotless:apply -pl agentscope-core

Summary

The streaming retry fix itself is solid. Address the Spotless formatting and consider the PR composition concern.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves correctness in two areas of AgentScope’s streaming and session state handling: (1) it prevents retryWhen from re-subscribing a streaming model response after chunks have already been emitted (avoiding duplicate delivery downstream), and (2) it fixes AgentState persistence when sessionId contains / by avoiding round-tripping through a concatenated slot key.

Changes:

  • Update ModelUtils.applyTimeoutAndRetry to stop retrying once any streaming chunk has been emitted.
  • Add regression tests covering streaming retry semantics (mid-stream retryable failure, pre-first-chunk retry, non-retryable failure, clean stream).
  • Fix ReActAgent AgentState persistence for sessionIds containing / and add end-to-end regression tests for the slot integrity issue.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
agentscope-core/src/main/java/io/agentscope/core/model/ModelUtils.java Stops retries after the first emitted streaming element to prevent duplicate downstream chunks.
agentscope-core/src/test/java/io/agentscope/core/model/ModelUtilsStreamingRetryTest.java Adds tests pinning streaming retry behavior and preventing regression.
agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java Persists AgentState using original (userId, sessionId) instead of parsing a concatenated slot key.
agentscope-core/src/test/java/io/agentscope/core/state/AgentStateSlotKeySessionIdSlashTest.java Adds end-to-end regression coverage for sessionIds containing / to ensure state lands in the correct slot.

Comment on lines 119 to 123
java.util.concurrent.atomic.AtomicBoolean emitted =
new java.util.concurrent.atomic.AtomicBoolean(false);
final Predicate<Throwable> retryableError = retryOn;

Retry retrySpec =
Comment on lines 421 to 429
private Mono<Void> saveStateToSession(CallExecution scope) {
if (stateStore == null) {
return Mono.empty();
}
syncToolkitToState(scope.state);
SlotRef ref = SlotRef.parse(scope.slotKey);
AgentState toSave = scope.state;
return Mono.<Void>fromRunnable(
() -> stateStore.save(ref.userId, ref.sessionId, "agent_state", toSave))
() -> stateStore.save(scope.userId, scope.sessionId, "agent_state", toSave))
.subscribeOn(Schedulers.boundedElastic());
…ered

ModelUtils.applyTimeoutAndRetry attaches retryWhen directly to the streaming
Flux. retryWhen re-subscribes its upstream, so when a retryable error arrives
mid-stream the model regenerates the whole response and downstream consumers
(middlewares, memory, UI) receive the already-delivered chunks a second time.

With MODEL_DEFAULTS (maxAttempts=3) the same content can be delivered three
times. It also inflates latency by a full regeneration, which for long or
thinking-mode responses is tens of seconds of apparent silence.

Reproduced without a live model — upstream emits two chunks then throws
IOException (retryable per ExecutionConfig.RETRYABLE_ERRORS):

  before: upstream subscribed 3x, downstream got
          [Hello, " world", Hello, " world", Hello, " world"]
  after:  upstream subscribed 1x, downstream got [Hello, " world"]

Fix: track whether anything has been emitted and fold that into the retry
filter, so retries stop after the first chunk reaches downstream. Failures
*before* the first chunk (connection setup, HTTP 429 ahead of the first token)
still retry — that is where retrying is both safe and valuable.

Affects the streaming path of every provider routed through this helper:
OpenAI, DashScope, Anthropic and Ollama.

Adds ModelUtilsStreamingRetryTest covering four cases: mid-stream retryable
error, failure before the first chunk, non-retryable mid-stream error, and a
clean stream. Only the first fails without this change; the other three pass
both ways, pinning that retries are not over-disabled.
@gxgeek-n
gxgeek-n force-pushed the fix/streaming-retry-duplicates-chunks branch from ba74fb1 to 178a982 Compare July 29, 2026 09:19
@gxgeek-n

Copy link
Copy Markdown
Contributor Author

Apologies — I force-pushed to correct a mistake on my side.

The branch was accidentally cut from a local branch based on #2476's head rather than from main, so this PR initially carried three commits:

That explains @copilot-pull-request-reviewer describing this PR as covering "two areas" and @oss-maintainer referring to the streaming fix as "commit 2".

The branch is now rebuilt directly on main and contains one commit, two files:

178a9820 fix(model): stop retrying a streaming response once chunks were delivered

agentscope-core/src/main/java/io/agentscope/core/model/ModelUtils.java          +12 -2
agentscope-core/src/test/java/io/agentscope/core/model/ModelUtilsStreamingRetryTest.java  +183

ReActAgent.java is no longer touched by this PR at all. Credit for the #2475 slot-key fix stays entirely with #2476.

Re-verified after the rebuild: ModelUtilsStreamingRetryTest 4/4 pass, and the mid-stream case still fails without the ModelUtils change (expected [Hello, world], got it three times over).

Thanks @oss-maintainer for the review of the streaming portion — the assessment of the approach matches the intent: emitted is checked inside the retry filter and doOnNext sits upstream of retryWhen so ordering is guaranteed. Happy to adjust anything further.

@gxgeek-n

Copy link
Copy Markdown
Contributor Author

Correction to my previous comment: the author of #2476 / commit 0bdb6bb9 is @waterWang, not the name I wrote. Apologies for the misattribution — @oss-maintainer had it right. Credit for the #2475 slot-key fix belongs entirely to @waterWang and #2476.

Responding to both concerns in the review — both are resolved by the force-push:

1. PR mixed two independent fixes — fixed. The branch is rebuilt on main and now carries a single commit touching two files:

178a9820 fix(model): stop retrying a streaming response once chunks were delivered

agentscope-core/src/main/java/io/agentscope/core/model/ModelUtils.java                     +12 -2
agentscope-core/src/test/java/io/agentscope/core/model/ModelUtilsStreamingRetryTest.java   +183

ReActAgent.java is no longer in the diff, so the conflict-after-#2476-lands concern goes away too.

2. Spotless failures in ReActAgent.java — those came entirely from the mis-included commit. With ReActAgent.java out of the diff there is nothing left to format there. mvn spotless:apply -pl agentscope-core had already been run against my own files; Check License and Check Module Sync are green on the rebuilt branch and the two build jobs are re-running now.

Thanks for the precise review — the composition problem was real and entirely my error in cutting the branch.

Review feedback: the emitted flag and the Retry instance were created once per
applyTimeoutAndRetry call and captured by the returned Flux. A Flux may be
subscribed more than once, so a second subscriber could inherit emitted=true
from an earlier subscription and skip retries it should have performed.

Moved the retry wiring inside Flux.defer so both the flag and the Retry spec
are allocated per subscription.

Adds emittedFlagIsPerSubscription: the same wrapped Flux is subscribed twice,
each subscription emitting a chunk before a retryable failure. Shared state
would make the second subscription diverge from the first.

ModelUtilsStreamingRetryTest: 5 tests, 0 failures.
@gxgeek-n

Copy link
Copy Markdown
Contributor Author

Thanks @copilot — that catch is correct and I've fixed it.

The emitted flag and the Retry instance were created once per applyTimeoutAndRetry call and captured by the returned Flux. Since a Flux can be subscribed more than once, a later subscriber could inherit emitted=true from an earlier subscription and skip retries it should have performed. Exactly the shared-mutable-state-across-subscribers problem you described.

Fixed by moving the retry wiring inside Flux.defer, so both the flag and the Retry spec are allocated per subscription:

responseFlux = Flux.defer(() -> {
    AtomicBoolean emitted = new AtomicBoolean(false);
    Retry retrySpec = Retry.backoff(retryMaxAttempts - 1, retryInitialBackoff)
            .maxBackoff(retryMaxBackoff)
            .jitter(0.5)
            .filter(error -> !emitted.get() && retryableError.test(error))
            .doBeforeRetry(...);
    return retrySource.doOnNext(response -> emitted.set(true)).retryWhen(retrySpec);
});

Added emittedFlagIsPerSubscription to pin it: the same wrapped Flux is subscribed twice, each subscription emitting one chunk before a retryable failure. With state shared across subscriptions the second subscription diverges from the first; now they match.

ModelUtilsStreamingRetryTest is 5/5 green, and the original mid-stream case still fails without the ModelUtils change.

Your second comment (about this PR also modifying ReActAgent for #2475) was accurate at the time — that was my branch-cutting error and it has since been force-pushed away. This PR now carries only the two streaming-retry commits and touches only ModelUtils.java plus its test.

@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Codecov flagged one partial branch on the retry filter
`!emitted.get() && retryableError.test(error)`. The existing non-retryable case
emits a chunk first, so the emitted guard short-circuits and the predicate's
false path was never taken.

Adds a case that fails with a non-retryable error before emitting anything, so
both operands of the condition are exercised in both directions.

6 tests, 0 failures.
@gxgeek-n

Copy link
Copy Markdown
Contributor Author

Addressed the Codecov partial-branch warning (96.15%, 0 Missing and 1 partial).

The partial was on the retry filter's compound condition:

.filter(error -> !emitted.get() && retryableError.test(error))

The existing non-retryable case emits a chunk before failing, so !emitted.get() short-circuits and retryableError.test(...) returning false was never reached. Added nonRetryableErrorBeforeFirstChunk_noRetry — fails with a non-retryable error before emitting anything, so both operands are now exercised in both directions.

ModelUtilsStreamingRetryTest is 6/6 green.

This PR now stands at: 3 commits, 2 files (ModelUtils.java +12/-2 and its test). Since the earlier force-push reset the review state to REVIEW_REQUIRED, a fresh look would be appreciated whenever convenient — the substantive changes since the last review are the Flux.defer per-subscription fix and this branch-coverage test.

@gxgeek-n gxgeek-n changed the title fix(model): stop retrying a streaming response once chunks were delivered fix(model): streaming retry re-delivers already-sent chunks downstream Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants