Skip to content

fix(harness): detach memory maintenance from agent call response - #2326

Open
Asaduddin18 wants to merge 15 commits into
agentscope-ai:mainfrom
Asaduddin18:fix/harness-memory-maintenance-detach
Open

fix(harness): detach memory maintenance from agent call response#2326
Asaduddin18 wants to merge 15 commits into
agentscope-ai:mainfrom
Asaduddin18:fix/harness-memory-maintenance-detach

Conversation

@Asaduddin18

Copy link
Copy Markdown

AgentScope-Java Version

2.0.1-SNAPSHOT

Description

Background: MemoryMaintenanceMiddleware.onAgent concatenated the memory-maintenance Mono onto the returned Flux via .concatWith(...). Because ReActAgent.callInternal ends its call chain with .takeLast(1) (which can't emit until the upstream Flux signals onComplete), any caller consuming the agent response to completion — blockLast(), takeLast(1), or a WebFlux controller awaiting the Mono<Msg> — ended up waiting for the full LLM-based consolidation call inside consolidateMemory() (consolidator.consolidate(rc).block()), inflating agent-call latency by up to ~30s in the reported case. This also contradicted the harness memory documentation, which describes this step as running in the "background" in a "fire-and-forget" fashion.

Root cause: doOnComplete(...) (no scheduler switch) was the original implementation and matches what the docs still describe. A later, unrelated commit swapped it for .concatWith(Mono.fromRunnable(...).subscribeOn(Schedulers.boundedElastic())...), which moved the blocking work onto a different thread pool but did not actually detach it from the response — concatWith keeps the maintenance Mono part of the same Flux the caller is awaiting.

Changes:

  • MemoryMaintenanceMiddleware.onAgent: replaced .concatWith(...) with .doOnComplete(() -> ...).subscribe() so the maintenance Mono is genuinely detached — subscribed independently of the returned Flux, matching the fire-and-forget pattern already used elsewhere in this package (AsyncToolMiddleware, SubagentsMiddleware). The returned Flux now completes as soon as the underlying agent call does, regardless of how long maintenance takes. Error-handling semantics (onErrorResume + log.warn) are unchanged, only relocated.
  • MemoryConsolidator.consolidate(): added a .timeout(CONSOLIDATION_TIMEOUT) on the model call (reusing ExecutionConfig.MODEL_DEFAULTS's 5-minute timeout) so a hung model provider can no longer tie up a shared boundedElastic worker thread indefinitely — this pool is shared process-wide with 40+ other call sites.
  • Updated the class-level Javadoc on MemoryMaintenanceMiddleware to describe the actual (now-correct) detached behavior instead of the stale concatWith reference.

Why no doc changes: This fix restores the exact behavior the existing harness docs (docs/v2/*/docs/harness/memory.md, compaction.md, workspace.md) already describe (background/fire-and-forget maintenance) — so those docs are accurate again as a result of this fix, rather than needing separate correction.

How to test:

mvn test -pl agentscope-harness -am -Dtest=MemoryMaintenanceMiddlewareAsyncBehaviorTest,MemoryConsolidatorTimeoutTest
  • MemoryMaintenanceMiddlewareAsyncBehaviorTest.onAgent_completesBeforeSlowConsolidationFinishes: mocks a slow MemoryConsolidator.consolidate() call and asserts onAgent(...)'s returned Flux completes in well under the consolidation duration — this test fails against the pre-fix concatWith code (verified locally: takes 3s instead of completing immediately).
  • MemoryMaintenanceMiddlewareAsyncBehaviorTest.onAgent_onErrorResume_handlesExceptionFromDetachedMaintenance: verifies the detached maintenance error path is still handled by onErrorResume (using Hooks.onErrorDropped to detect if it would otherwise be silently dropped).
  • MemoryConsolidatorTimeoutTest.consolidate_timesOutInsteadOfHangingForever: uses StepVerifier.withVirtualTime to confirm a model that never responds causes consolidate() to error with a TimeoutException instead of hanging — verified this test fails to even compile without the fix (confirming it's tied to the change).

Full harness module suite: 637/637 tests passing, 0 failures/errors. Every changed/added line has full line and branch coverage (verified via JaCoCo — no partial coverage on any touched line).

Fixes #2225

Checklist

  • Code has been formatted with mvn spotless:apply
  • All tests are passing (mvn test)
  • Javadoc comments are complete and follow project conventions
  • Related documentation has been updated (e.g. links, examples, etc.)
  • Code is ready for review

Asaduddin18 and others added 9 commits July 16, 2026 09:59
MemoryMaintenanceMiddleware concatenated the maintenance Mono onto the
returned Flux via concatWith, so callers that consume the response to
completion (blockLast(), takeLast(1)) waited for the full LLM-based
consolidation call to finish -- inflating agent call latency by up to
30s and contradicting documentation that describes this as a
background/fire-and-forget step.

Detach maintenance via doOnComplete(...).subscribe() instead, so the
returned Flux completes as soon as the underlying agent call does,
independent of maintenance duration. Also add a timeout to the
consolidation LLM call so a hung provider can't tie up a shared
boundedElastic thread indefinitely.

Fixes agentscope-ai#2225
Detaching MemoryMaintenanceMiddleware's fire-and-forget maintenance run
(agentscope-ai#2225's fix) left it fully untracked: HarnessAgent.close() returned
immediately even while a background maintenance write to the workspace
was still in flight. In tests using JUnit @tempdir this raced against
directory cleanup and intermittently failed with
DirectoryNotEmptyException; in production it could tear down a
workspace/sandbox out from under an in-progress write.

Track each scheduled maintenance run's Disposable and add
MemoryMaintenanceMiddleware.close(), called first from
HarnessAgent.close(), which stops scheduling new runs and waits
(bounded, 5s) for outstanding ones to finish before disposing stragglers.
…ce guard

Adds coverage for the branches introduced by the previous commit that
jacoco flagged as untested: scheduleMaintenance() no-op'ing after
close(), and close()'s interrupted-wait / dispose-stragglers paths.

Also drops the extra "dispose immediately if closed flipped true right
after pending.add(d)" guard in scheduleMaintenance(). It only mattered
for a TOCTOU race too narrow to hit deterministically in a test, and
close()'s own wait-then-dispose-all loop already reclaims that
disposable in every realistic timing.
@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

HarnessAgent.close()'s `if (memoryMaintenanceMw != null)` guard only
had its true branch exercised (every existing test that calls close()
builds an agent with memory hooks enabled), leaving the disabled-hooks
path an untested branch.

@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 #2326 — fix(harness): detach memory maintenance from agent call response

Verdict: Changes Requested — CI is failing

Concept: Approved ✅

The approach is correct and addresses issue #2276. Moving memory maintenance (consolidate + flush) out of the concatWith chain so it runs asynchronously is the right fix. The close() method properly cleans up the middleware.

Issue: Build failures on both Linux and Windows

The agentscope-harness module has test failures:

[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.5:test 
on project agentscope-harness: There are test failures.

Please check the surefire reports and fix the failing tests. Common causes:

  1. Timing-sensitive async tests — the new async behavior may cause race conditions in tests that assert on timing
  2. Constructor signature changes — the new MemoryMaintenanceMiddleware parameter in the HarnessAgent constructor may break existing test setups that use the old constructor
  3. Mock setup — tests that mock the middleware chain may need updating for the new detached behavior

Code observations:

  • The nested try-finally in close() is correct for resource cleanup ordering
  • The doFinally for cleanup is a good pattern
  • Consider adding a timeout to the async maintenance to prevent resource leaks on shutdown

Asaduddin18 and others added 2 commits July 28, 2026 20:03
…ntion

localFilesystem_filesPersistAcrossCalls wrote its persistence-demo
content to MEMORY.md, which the harness's own memory-maintenance
subsystem also owns (on by default whenever a model is configured).
Detaching maintenance (this branch's own fix) means background
consolidation is no longer serialized with the test's manual write, so
it could race in and overwrite MEMORY.md with the stub model's output
before the test's second assertion ran -- observed as a flaky CI
failure on both Linux and Windows.

Move the demo content to notes.md, a path the harness has no opinion
about, so the test only exercises the local-filesystem persistence
behavior it's actually named for.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]:MemoryConsolidator blocks agent call via concatWith + .block()

2 participants